Add Electron desktop app section, document all 8 plugins, update features list (assets, super-buckets, find/replace, arrow nav, PDF upload), fix relay docs (chat shipped, not future), add missing API endpoints, correct electron data storage paths and php-bin fetch info. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hyprcrd Relay
A self-hosted multi-tenant message relay for hyprcrd. Multiple users share one relay instance; each one has their own login, their own client encryption secret, and their own inbox. Cards and encrypted chat messages can be sent between tenants on the same relay.
The relay is a single-file PHP app — drop the directory into any PHP webspace and it works.
Concepts
| Term | Meaning |
|---|---|
| Tenant | A user account on the relay. Has a username + UI password (set by admin) + a self-managed client secret (set by the user themselves). |
| Admin | A tenant with is_admin = 1. Can create, modify, and delete other tenants. The first tenant created at install time is automatically admin. |
| Client secret (per-tenant) | The key your hyprcrd client uses to encrypt outgoing messages. Set by the user themselves in the relay UI's "your account" view (not by the admin). Each user picks or generates their own. The relay stores it encrypted with the master key so it can route messages between tenants. |
| Super-shared secret | A relay-wide secret set by the admin during initial setup. This is the cross-tenant routing key — used by the relay to re-encrypt messages at rest so they can flow between any pair of tenants. Stored as inter_tenant_key in the settings table, encrypted with the master key. Set once, can't be changed without re-setup (would invalidate all stored messages). |
| Master key | A 32-byte random value generated on first install, stored in data/master.key (chmod 600, blocked by .htaccess). Encrypts the super-shared secret and per-tenant client secrets at rest, so a DB leak alone doesn't compromise the relay. |
| Inbox | Each tenant's incoming messages. 24-hour ephemeral storage. |
Three secrets, three jobs
- UI login password (set by admin) — opens the relay web UI for that tenant
- Client secret (set by the user themselves on first login) — encrypts hyprcrd ↔ relay traffic
- Super-shared secret (set by admin once at install) — encrypts everything in the DB and is the pivot for cross-tenant routing
The admin only ever touches secrets #1 and #3. The user only ever touches secret #2. They are stored separately and serve different purposes.
Encryption flow (cross-tenant)
┌──────────────┐ ┌─────────────────────┐
│ hyprcrd A │ │ relay │
│ (alice) │ │ │
│ │ │ 1. auth alice │
│ encrypts │ POST /messages │ 2. decrypt body │
│ body with ├─────────────────►│ with alice's │
│ Sa (alice's │ X-Relay-User:A │ secret │
│ secret) │ X-Relay-Key:Sa │ 3. re-encrypt with │
│ │ │ inter-tenant key │
└──────────────┘ │ 4. store with │
│ recipient = bob │
┌──────────────┐ │ │
│ hyprcrd B │ │ 5. on B's poll: │
│ (bob) │ GET /messages │ decrypt with │
│ │ X-Relay-User:B │ inter-tenant key │
│ decrypts │◄─────────────────┤ 6. re-encrypt with │
│ body with │ X-Relay-Key:Sb │ bob's secret │
│ Sb (bob's │ │ 7. return │
│ secret) │ │ │
└──────────────┘ └─────────────────────┘
The relay does see plaintext briefly during the cross-tenant hop (steps 2–3 and 5–6). This is the same trust model as email: you trust the relay operator. Chat between tenants is shipped (via the chat plugin with delivery + read receipts). Strict end-to-end encryption with per-pair key exchange (Curve25519/ECDH) is on the roadmap.
For at-rest data, all sensitive values are encrypted with the master key, so a stolen DB without the master key file gives an attacker nothing.
Setup
1. Deploy
Webspace (any PHP host with mod_rewrite and pdo_sqlite):
cd relay
./dist.sh
# upload hyprcrd-relay.zip and extract into your webspace
Docker:
cd relay
docker build -t hyprcrd-relay .
docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay
The data/ directory is created on first request, contains the
SQLite DB and the master key file, and is blocked from web access via
.htaccess.
2. Initial admin setup
Open the relay URL from a desktop browser (the setup wizard is desktop-friendly). On first visit you'll see the setup form.
Fill in:
- admin username (2-32 chars, letters/digits/
_/-) - admin password (min 4 chars, used for the relay UI login)
- super-shared secret (min 12 chars, the relay-wide cross-tenant routing key — set once, can't be changed)
Click create admin + activate relay. You're logged in and dropped straight onto the account page where you'll set your own client secret.
3. Set your own client secret
Every tenant — including the admin — sets their own client secret on the account page. The admin never knows or sets it for other users.
You can either:
- Type your own secret (min 8 chars) and click save
- Generate a random one (48-char hex) and click the dice button
The secret is shown in plaintext on the account page so you can copy it to your hyprcrd's relay settings. You can return to the account page anytime to view it again.
4. Create more users (admin)
Once logged in as admin, click the users tab in the header. Use the form to create additional tenants:
- their own username
- a temporary password (the user is forced to change it on first login)
- optional admin privileges (checkbox)
The admin does not set the new user's client secret — the user themselves sets it on first login from the account page.
Tell each user their credentials (just username + temporary password) out-of-band (signal, encrypted email, in person). On their first login, the relay UI will:
- Show a forced password change screen — they must replace the admin-issued temp password with one of their own (min 4 chars). No other view is reachable until they do.
- Drop them on the account page to set their client secret.
- Then they can copy the secret to their hyprcrd Settings → Relay.
The admin themselves is never forced to change password (the admin set their own password during the initial setup wizard).
5. Connect a hyprcrd instance to the relay
In any hyprcrd instance:
- Open Settings → Relay
- Enter:
- relay url (e.g.
https://relay.example.com) - username (the one the admin gave you)
- shared secret (the one you set on the account page)
- relay url (e.g.
- Click save, then test to verify the connection works
After this, hyprcrd polls the relay every 45 seconds and will fetch incoming messages.
6. Send a card to another tenant
(in the relay UI compose tab)
In the relay UI compose tab:
- Pick a recipient from the dropdown (defaults to yourself)
- Type a title and body
- Click send
The recipient will see it on their next hyprcrd poll, materialized as a card in their board.
You can also right-click a card in hyprcrd and pick push to relay — this sends to your own inbox (or pick a recipient in the relay UI).
Data lifetime
- Messages auto-delete after 24 hours (whether fetched or not)
- Attachments same
- Sessions expire after 24 hours of inactivity
- Rate-limit entries cleaned up every 5 minutes
The relay is not a long-term archive. Anything you want to keep should be extracted as a card in hyprcrd.
Database schema
tenants
id, username, password_hash (bcrypt), secret_hash (bcrypt),
secret_encrypted (master-key encrypted), is_admin, created_at
settings
key='inter_tenant_key', value=master-encrypted blob
messages
id, recipient_id → tenants(id), sender_id → tenants(id),
sender_username, title, body (both encrypted with inter-tenant key),
color, tags, relay_uid, fetched, created_at
attachments
id, recipient_id → tenants(id), relay_uid, filename, data, created_at
sessions
id (random token), tenant_id, created_at, expires_at
rate_limits
ip, endpoint, attempted_at
recipient_id and sender_id cascade-delete when a tenant is removed.
API reference
All endpoints under /api/. JSON in/out.
Public (no auth)
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/status |
— | {setup, has_admin, logged_in, username?, is_admin?, inbox_count?} |
| POST | /api/setup |
{username, password, secret} |
{ok, username, is_admin} — only works when no tenants exist |
| POST | /api/login |
{username, password} |
{ok, username, is_admin} — sets session cookie |
| POST | /api/logout |
— | {ok} |
Session-authenticated (UI cookie)
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | /api/me |
— | Current tenant info |
| GET | /api/tenants |
— | List of all usernames (for recipient picker) |
| POST | /api/messages |
{title, body, recipient?} |
UI compose path: server encrypts with inter-tenant key |
| GET | /api/messages/inbox |
— | Read own inbox messages (decrypted for display) |
| DELETE | /api/messages/{id} |
— | Delete a message from own inbox |
| GET | /api/chat/sent |
— | Sent chat messages with delivery/read status |
| POST | /api/chat/seen |
{ids: [...]} |
Mark chat messages as seen (read receipts) |
Admin only (session + is_admin)
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | /api/users |
— | List all tenants |
| POST | /api/users |
{username, password, secret, is_admin?} |
Create new tenant |
| PATCH | /api/users/{id} |
{password?, secret?, is_admin?} |
Update existing tenant |
| DELETE | /api/users/{id} |
— | Cascade-delete tenant + their messages |
Last-admin protection: you cannot demote or delete the only remaining admin, and you cannot delete your own account.
Client API (header-authenticated)
Headers required:
X-Relay-User: alice
X-Relay-Key: <alice's shared secret>
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /api/messages |
{title, body, recipient?, color?, tags?, relay_uid?} |
title and body must already be client-encrypted with sender's secret. Server decrypts, re-encrypts with inter-tenant key, stores. |
| GET | /api/messages?since=ISO |
— | Returns messages re-encrypted with the requesting tenant's secret. Marks them as fetched. |
| DELETE | /api/messages/{id} |
— | Only deletes own messages (recipient_id must match) |
| POST | /api/attachments |
{relay_uid, filename, data, recipient?} |
data is base64-encoded encrypted blob |
| GET | /api/attachments?relay_uid=... |
— | Fetches own attachments only |
Encryption format
Client-side messages use a versioned envelope:
HYPRCRD-RELAY-V1\n<base64(IV || ciphertext)>
- Cipher: AES-256-CBC
- Key:
substr(sha256(secret_string), 0, 32)interpreted as 32 ASCII bytes - IV: 16 random bytes per message, prepended to ciphertext
This format is compatible with the JS encryption in hyprcrd's relay
plugin and the PHP clientEncrypt/clientDecrypt helpers in the relay.
The inter-tenant key uses the exact same scheme — it's just a longer
random secret (64 hex chars = 32 bytes after the substr step gives 32
ASCII chars from the hex prefix). All re-encryption hops in the relay
use the same code path.
Security model
What's protected:
- Tenant secrets and the inter-tenant key are encrypted at rest with the master key
- The master key file lives in
data/, blocked from web access by.htaccess, withchmod 600 - All passwords (UI login + client secrets) are bcrypt-hashed for auth verification
- Sessions use random 32-byte tokens, HttpOnly + SameSite=Strict cookies
- Rate-limiting: 5 attempts/minute per IP for setup, login, and verify
- Request body limited to 256 KB (15 MB for attachment uploads)
- Security headers: X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy
- All sensitive endpoints require authentication
- Last-admin protection prevents accidental lockout
What's NOT protected (by design, current trust model):
- The relay operator can decrypt all messages — the master key file + the DB give them access to everything. This is the same trust model as email.
- Cross-tenant messages are seen in plaintext briefly during the re-encryption hop in PHP memory. They are not written anywhere unencrypted.
- A compromised relay machine = compromised messages. Use HTTPS, keep PHP up to date, restrict shell access.
Future work for stronger guarantees (roadmap for strict E2E):
- Per-tenant Curve25519 key pair stored in the DB (public key plus master-key-encrypted private key, or even private key never on the server)
- ECDH key exchange between tenant pairs
- Per-conversation symmetric key derived from the shared secret
- Forward secrecy via ratcheting
- This would make the relay a true blind router: it sees only opaque blobs and metadata.
Migrations
v1 (single-tenant) → v3 (multi-tenant, self-set secrets)
The old schema had relay_password and relay_key in the settings
table and messages/attachments tables without recipient_id. On first
request after the upgrade, the relay automatically:
- Detects the legacy schema by checking if
messages.recipient_idexists. If not, itDROPs the oldmessagesandattachmentstables. (They contain only 24h-ephemeral data anyway.) - Creates the new
tenants,sessions,messages, andattachmentstables viaCREATE TABLE IF NOT EXISTS. - The
tenantstable starts empty → the setup wizard is shown the next time you load the relay URL in a browser.
v2 (admin-set secrets) → v3 (self-set secrets)
In v2 the admin set the client secret when creating each user. In v3 this changed: secrets are nullable and each user sets their own. On first request after the upgrade, the relay automatically:
- Detects the v2 schema by checking if
tenants.secret_hashisNOT NULL. If so, the table is recreated with nullable columns (and the newpassword_changedcolumn). - Existing tenant rows (username, password, is_admin) are kept
intact, but their
secret_hashandsecret_encryptedare cleared to NULL. - Non-admin users get
password_changed = 0so they're forced to change their password on the next login (the v2 password was admin-set, not self-chosen). Admins keeppassword_changed = 1. - The next time each user logs in, the relay walks them through: forced password change → set client secret → start using.
- The previously generated
inter_tenant_keyis kept as-is so cross-tenant messages flow through the same key. Admin can rotate it later (currently requires a manual DB edit; rotate API endpoint is on the TODO).
v3.0 → v3.1 (forced password change flag)
A small additive migration. If tenants.password_changed is missing,
the relay does:
ALTER TABLE tenants ADD COLUMN password_changed INTEGER NOT NULL DEFAULT 1UPDATE tenants SET password_changed = 0 WHERE is_admin = 0— forces existing non-admin users to change their password too, since they were originally admin-set.
Admins keep password_changed = 1 because they set their own password
during the initial setup wizard.
After running through the setup wizard once on a desktop:
- You're the new admin
- A fresh inter-tenant key is generated and stored encrypted with the master key
- All previously connected hyprcrd instances need to update their
Settings → Relay to add the new
usernamefield and the new shared secret you set
There's no plaintext recovery of the old relay_key because it
was bcrypt-hashed in the old schema — you have to re-create users and
re-enter the secret on every connected hyprcrd. Treat the upgrade as a
fresh install with credential reset.
The legacy relay_password and relay_key rows in the settings table
are left in place (unused) so you can downgrade if needed.
Development (both relay + hyprcrd together)
From the project root:
./dev.sh
Starts hyprcrd on http://localhost:8088 and the relay on
http://localhost:8081 in the same Docker network. Source files are
bind-mounted — changes are live without rebuilding.
From inside the hyprcrd container, the relay is reachable at
http://relay:80. This is the URL to put in hyprcrd's relay settings
when developing locally.
Docker bind-mounts
The relay's docker-compose.yml bind-mounts these paths from the host:
volumes:
- ./relay/index.php:/var/www/html/index.php
- ./relay/.htaccess:/var/www/html/.htaccess
- ./relay/fonts:/var/www/html/fonts
- ./relay/inc:/var/www/html/inc
- ./relay/ui:/var/www/html/ui
- relay-data:/var/www/html/data
If you add new top-level directories to relay/, mount them here too —
otherwise the container won't see them and require will fail silently
(error_reporting(0) swallows the fatal). Symptoms: blank page or 500
on every request.
After updating bind mounts you must docker compose down && docker compose up -d --build
— Compose remembers the previous mount config until you tear down.
Wiping the relay state
Sometimes you want a completely fresh DB (e.g. after schema changes during development):
docker compose down -v # -v also removes the relay-data volume
docker compose up -d
Without -v the volume persists between restarts. Wiping forces the
relay back to setup-wizard mode on the next visit.
UI
Responsive layout
The UI is desktop-first but works on mobile. The header collapses to icon-only buttons on narrow screens. The compose card and inbox stack naturally.
Recipient picker auto-hides for solo tenants
When the relay only has one tenant (typical for a fresh install or a single-user deployment), the recipient dropdown in the compose form is hidden — there's no other tenant to send to. The compose form becomes just title + body.
As soon as the admin creates a second tenant via the users panel, the dropdown reappears on the next render of the compose view, defaulting to "you" with the other tenants listed below.
The default recipient is always "self" if not explicitly set, so the backend behaves the same whether the picker is visible or not.
Mobile is compose-only
On mobile (screens narrower than 700px) the relay strips down to a single-purpose tool: dump content into the inbox. There's no navigation, no admin, no identity display — just compose + your own inbox count.
What's hidden on mobile via CSS media query (@media (max-width: 700px)):
- The users nav button (
#nav-admin) - The entire users management view (
#view-admin) - The logged-in username + admin badge in the header (
#nav-user) - The compose nav button (
#nav-compose) — since there's nothing to navigate between, the button is dead weight
The mobile header shrinks to just: pulsing dot + "hyprcrd relay" title
- inbox count + logout button.
For defense-in-depth, the JS also redirects showView('admin') calls
to compose when the viewport is mobile, so the admin panel can't be
opened programmatically either.
The backend admin API endpoints (/api/users) are still reachable —
they're protected by the session+is_admin check, so only the UI is
restricted. The intent is to:
- Keep mobile users out of admin tasks (form is cramped on small screens)
- Avoid showing the username in shoulder-surfing range — most relay use on phone is ad-hoc note capture in public
- Reduce the mobile UI to its single purpose: capture and send
If you need to administrate from a mobile device, use a desktop browser's mobile-responsive emulator OR temporarily widen the viewport (rotate to landscape on a tablet etc.).
Icons (Phosphor, locally hosted)
The relay uses Phosphor Icons — the same icon family hyprcrd uses. They're hosted locally (no CDN), so the relay remains a fully self-contained drop-in app with zero external dependencies at runtime.
The font + CSS files live in relay/fonts/:
phosphor.css(~78 KB) — the.phand.ph-{name}rulesPhosphor.woff2(~147 KB) — the actual font file
To add an icon anywhere in the UI:
<i class="ph ph-paper-plane-tilt"></i> send
Sizing is controlled in ui/style.css per context (nav buttons 11px,
section headers 13px, action buttons 12px). Custom sizes can be set
inline via style="font-size:20px;".
The full icon catalog is at https://phosphoricons.com.
Files
relay/
├── README.md # this file
├── index.php # entry point: bootstraps, dispatches API routes, renders UI
├── inc/
│ ├── bootstrap.php # DB connect, master key, schema migration, helpers
│ ├── crypto.php # masterEncrypt/Decrypt, clientEncrypt/Decrypt, inter-tenant key
│ ├── tenants.php # tenant DB lookup helpers
│ ├── auth.php # rate limit, sessions, header-based client auth
│ └── routes/
│ ├── public.php # /api/status, /setup, /login, /logout, /me
│ ├── admin.php # /api/users CRUD, /api/tenants directory
│ ├── messages.php # /api/messages, /api/attachments, cross-tenant routing
│ ├── chat.php # /api/chat/sent, /api/chat/seen (read receipts)
│ └── collab-buckets.php # /api/collab-buckets CRUD + card sync
├── ui/
│ ├── layout.php # HTML shell + view containers
│ ├── style.css # all CSS (including responsive media queries)
│ └── app.js # all JS
├── fonts/ # all locally-hosted UI assets
│ ├── silkscreen-400.ttf # pixel display font
│ ├── silkscreen-700.ttf
│ ├── jetbrains-mono-400.ttf # monospace body font
│ ├── phosphor.css # Phosphor icon font CSS rules
│ └── Phosphor.woff2 # Phosphor icon font binary
├── .htaccess # rewrite + blocks /data/, /inc/, /ui/*.php
├── Dockerfile
├── dist.sh # builds hyprcrd-relay.zip with all of the above
└── data/ # created on first run, NOT in the dist
├── relay.db # SQLite database
└── master.key # 32-byte master key (chmod 600)
Each include file in inc/ and ui/ checks RELAY_BOOTSTRAPPED at the
top and exits with 404 if accessed directly. The .htaccess adds a
defense-in-depth [F,L] block on inc/ and ui/*.php so they're never
served by Apache anyway.
The dist.sh script zips up index.php, .htaccess, inc/, ui/,
fonts/, and README.md recursively — drop the resulting
hyprcrd-relay.zip into any PHP webspace.
Troubleshooting
"too many attempts" on login Rate limit. Wait one minute.
"unauthorized" from a hyprcrd instance
- Verify
relay-usernameis set in hyprcrd Settings → Relay - Verify the username matches a tenant created on the relay
- Verify the shared secret matches what the admin gave you
- Check the relay log if you have access — bcrypt mismatch is the most common cause
Can't see new tenants in the dropdown after admin created them The recipient picker is loaded once per page session. Refresh the relay UI tab to repopulate.
Lost the master key
You're locked out. There's no recovery: the inter-tenant key and all
stored tenant secrets are unrecoverable. You'll need to wipe data/
and re-setup. Back up data/master.key to a safe place after install
if you care about durability beyond the 24h message window.
Lost the admin password
Open data/relay.db directly with sqlite3 and reset:
UPDATE tenants SET password_hash = '$2y$10$...' WHERE username = 'admin';
Generate the bcrypt hash with php -r "echo password_hash('newpw', PASSWORD_BCRYPT);".
Blank page / 500 error after upgrade in Docker
The most common cause is that docker-compose.yml is missing bind
mounts for relay/inc and relay/ui. When the new modular index.php
runs require __DIR__ . '/inc/bootstrap.php' and the file doesn't exist
in the container, PHP fatally errors. error_reporting(0) in the relay
swallows the error → blank page.
Fix: ensure these mounts are present in docker-compose.yml:
- ./relay/inc:/var/www/html/inc
- ./relay/ui:/var/www/html/ui
Then docker compose down && docker compose up -d --build.
Setup wizard doesn't appear after upgrading from v1
The auto-migration drops the legacy messages and attachments tables
on first request. If you still don't see the setup wizard:
- Clear browser cookies + localStorage for the relay domain (the v1 stored credentials in localStorage which is now stale)
- Check the relay log for
[hyprcrd-relay] migrated:lines confirming the legacy tables were dropped - As a last resort, wipe the volume:
docker compose down -v && docker compose up -dYou lose nothing important — the relay is 24h-ephemeral by design.
Mobile user wants admin access By design, mobile screens (≤700px) hide the entire admin UI. Use a desktop browser, or in your phone browser's dev tools rotate to landscape on a tablet, or use the request-desktop-site option to widen the viewport. The admin API endpoints still work via curl with a valid session cookie if you really need scripted access.