Files
hyprcrd/relay
thandClaude Opus 4.6 a67bedc6f2 relay: burger menu for mobile, admin reachable, badge matches header buttons
The relay's nav row used to flatten on mobile by hiding admin / user
identity / compose buttons outright, leaving only "account" + "logout".
Admin was completely unreachable from a phone. Replaces that with a
proper burger menu and exposes every nav target.

* layout.php: new <button id="nav-burger"> with a ph-list icon between
  the spacer and #nav-buttons. Inline `style="display:flex"` removed
  from #nav-buttons — display is now CSS-driven, so the burger
  popover and the desktop row don't fight each other.
* app.js showView(): the mobile-block on `name === 'admin'` is gone.
  Burger button visibility is now mirrored against the same
  setup/login/change-password gating that hides #nav-buttons. Picking
  any view also clears `.menu-open` so the popover collapses behind
  the freshly-shown view.
* app.js toggleNavMenu(): exposed on window for the inline onclick.
  Document-level click outside closes the menu.
* style.css: default `#nav-buttons:not(.hidden)` becomes flex (same
  semantics as the old inline). #nav-burger is `display:none` on
  desktop, `inline-flex` on mobile. Mobile media query drops the old
  display:none rules for #nav-admin / #view-admin / #nav-compose,
  keeps #nav-user hidden (redundant with the menu), and adds a
  `.menu-open` rule that turns #nav-buttons into a popover anchored
  to the header (position:absolute, right:12px, surface bg, border,
  shadow, z-index 50). Buttons inside the popover go full-width
  left-aligned. header { position:relative } so the absolute
  positioning has an anchor.
* style.css: also #view-admin .section gets a simple display:block
  override on mobile so the desktop grid-area layout doesn't break
  the page when admin is opened from a phone.

header .user .badge sizing (carried over from the previous turn): the
admin badge in the relay header now matches the .nav-btn box-model
exactly (5px 10px padding, 9px font, 1px border, 3px radius, line-
height:1, pixel font), so it sits flush with the nav buttons instead
of being a tiny pill in the middle of the row.

chore: bump version to 0.9.8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:40:26 +02:00
..
bak
2026-04-09 19:14:52 +02:00

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 in the future: 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

  1. UI login password (set by admin) — opens the relay web UI for that tenant
  2. Client secret (set by the user themselves on first login) — encrypts hyprcrd ↔ relay traffic
  3. 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. Strict end-to-end encryption for the planned chat feature is on the roadmap and will require a per-pair key exchange (Curve25519/ECDH) — this is documented as future work in index.php.

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:

  1. 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.
  2. Drop them on the account page to set their client secret.
  3. 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:

  1. Open Settings → Relay
  2. 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)
  3. 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

(currently in the relay UI; hyprcrd-side recipient picker is coming)

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 currently sends to your own inbox; recipient picker on the hyprcrd side is on the TODO.

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}
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

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, with chmod 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 (planned for the encrypted chat feature):

  • 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:

  1. Detects the legacy schema by checking if messages.recipient_id exists. If not, it DROPs the old messages and attachments tables. (They contain only 24h-ephemeral data anyway.)
  2. Creates the new tenants, sessions, messages, and attachments tables via CREATE TABLE IF NOT EXISTS.
  3. The tenants table 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:

  1. Detects the v2 schema by checking if tenants.secret_hash is NOT NULL. If so, the table is recreated with nullable columns (and the new password_changed column).
  2. Existing tenant rows (username, password, is_admin) are kept intact, but their secret_hash and secret_encrypted are cleared to NULL.
  3. Non-admin users get password_changed = 0 so they're forced to change their password on the next login (the v2 password was admin-set, not self-chosen). Admins keep password_changed = 1.
  4. The next time each user logs in, the relay walks them through: forced password change → set client secret → start using.
  5. The previously generated inter_tenant_key is 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:

  1. ALTER TABLE tenants ADD COLUMN password_changed INTEGER NOT NULL DEFAULT 1
  2. UPDATE 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 username field 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:

  1. Keep mobile users out of admin tasks (form is cramped on small screens)
  2. Avoid showing the username in shoulder-surfing range — most relay use on phone is ad-hoc note capture in public
  3. 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 .ph and .ph-{name} rules
  • Phosphor.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
├── 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-username is 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:

  1. Clear browser cookies + localStorage for the relay domain (the v1 stored credentials in localStorage which is now stale)
  2. Check the relay log for [hyprcrd-relay] migrated: lines confirming the legacy tables were dropped
  3. As a last resort, wipe the volume: docker compose down -v && docker compose up -d You 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.