Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so existing user secrets, stored messages, and inter-tenant key keep working. Old clients and relays lose cross-version chat until both sides are upgraded. Relay - Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived per-ciphertext salts; keep CBC-V1 fallback on decrypt only. - On-bootstrap migration re-envelopes tenant secret_encrypted and the inter-tenant key to GCM-V2 (idempotent). - CORS: stop reflecting arbitrary Origin; allow own origin + optional RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials. - Generic error messages replace "no such user: <name>" enumeration. - Exception messages no longer echoed back; logged server-side. - Collab-lock max hold capped at 10min so a member can't grief-refresh forever. - Attachment filenames validated against hex.<ext> pattern. - Passwords min length 10; /api/account/password rate-limited. App / Electron client - Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read). - Chat + collab-buckets plugins delegate to the shared helpers. - Poll + push + attachments migrated to the dual-format path. - /api/relay/settings GET returns has_key boolean; secret never echoed. - Setup endpoint now CSRF-checked. - SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks favicon fetch also blocks private/loopback/link-local. - Login / password-change rate limiter moved to DB-backed IP keying. - Passwords min length 10 throughout. - Zip import enforces per-file (25MB) and total (500MB) caps, extension allowlist, no-overwrite. - Verbose relay-push debug gated behind APP_DEBUG env. Version bumped to 2.0.0 (protocol-breaking). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
collab-buckets plugin
Collaborative buckets shared between relay tenants. Each bucket has an owner and a set of members; membership changes and metadata updates propagate through the existing relay. Collab buckets are first-class rows in the normal bucket bar — the plugin just owns the sync plus an unread indicator (red dot + 3-cycle pulse) on any tab that has a fresh update.
Phase 1 through 5 shipped. Metadata + membership sync, bucket-bar
integration, full card sync (create / edit / delete, LWW conflict
resolution with local Conflicts-bucket stashing, encrypted round-trip
via the relay's decrypt-reencrypt hop), advisory per-card edit locks
with a 🔒 badge on cards another user is currently editing,
bucket-ownership transfer through a members & owner modal, and the
Phase 5 hardening pass: "Make collaborative…" upgrade path for
existing local buckets, undo for local bucket delete/leave,
resilient create via a local outbox that queues intents when the
relay is unreachable and retries on the next poll, and
rate-limiting on all collab endpoints. Remaining nice-to-haves
(position propagation, presence, read receipts, inline member picker,
owner approval flow) are in TODO.md.
What it does
- Create a collab bucket from the normal
+ Bucketbutton in the bucket bar. The plugin intercepts the button click (viawindow._collabBucketsCreateDialog) and shows a modal with an optional "collaborative" checkbox. Unchecked → a regular local bucket, same as before. Checked → reveals a member picker listing all other relay tenants, and the submit path goes through/api/collab-bucketsinstead of the core/api/buckets. - See other members' buckets appear automatically in the bucket bar.
Every 30 seconds the plugin polls the relay for a full resync; any new
or updated bucket the current user is a member of is mirrored into the
main
bucketstable, so the core bar renders it alongside everything else. - Collab tabs are visually distinguished: the folder icon is swapped
for
ph-users-three, there's a subtle left-edge accent stripe, and the right-click menu's rename / color / delete actions route through the plugin endpoints so changes reach the relay and other members. - Unread state: brand-new buckets and ones whose
updated_atexceeds the locallast_seen_atshow a red dot top-right of the tab and pulse 3 times on the next render. Clicking the tab triggersmark-seenwhich clears the dot/pulse immediately and persists to the plugin backend.
Architecture
Two places store bucket state locally:
- Main
bucketstable (core) — gains acollab_uidcolumn so collab buckets are indistinguishable from local buckets in the bucket bar, bucket selects, card-to-bucket joins, etc.collab_uidis unique and NULL for regular local buckets. - Plugin
collab_bucketstable — holds collab-only metadata (members_json,owner_username,updated_at,last_seen_at) that the main table doesn't need. On every poll the plugin keeps both tables in sync.
┌───── Browser ─────┐ ┌──── Relay ────┐
│ │ │ │
│ bucket-bar (core) │ │ │
│ ├─ bucket rows │ POST /api/collab-buckets │ collab- │
│ │ incl. collab │ (create via + Bucket modal) │ buckets.php │
│ │ collab_uid │ ───────────────────────────▶ │ │
│ │ │ │ collab_ │
│ collab-buckets.js │ POST /api/collab-buckets │ buckets │
│ ▲ observer │ /poll (every 30s) │ │
│ │ MutationObs │ ◀─────────────────────────── │ collab_ │
│ │ + delegation │ full resync │ bucket_ │
│ ▼ │ │ members │
│ backend.php │ │ │
│ ├─ collab_buckets│ │ │
│ │ plugin table │ │ │
│ └─ upserts/ │ │ │
│ deletes rows │ │ │
│ in main │ │ │
│ buckets table │ │ │
└───────────────────┘ └───────────────┘
- Relay is source of truth for bucket metadata + membership. The two
local tables are mirrors that together add
last_seen_at(unread state, user-specific, never synced). - Full resync every 30 s. Locally cached buckets that are no longer
returned by the relay for this user = we were kicked (or the bucket was
deleted); both tables drop them and any cards referencing them fall back
to unsorted via
ON DELETE SET NULL. - LWW per bucket: every patch bumps
updated_aton the relay. The local poll only re-saves rows whoseupdated_atactually changed, otherwise it's a no-op andlast_seen_atis untouched so unread state survives unrelated polls.
Files
| File | Purpose |
|---|---|
plugin.json |
Plugin manifest (auto-enabled unless listed in data/plugins.json) |
backend.php |
Plugin backend: collab_buckets plugin table, relay proxy, full-resync poll, mark-seen, + collabUpsertMainBucket / collabDeleteMainBucket helpers that keep the core buckets table in sync |
collab-buckets.js |
Frontend: polling loop, exposes window._collabBucketsCreateDialog for the core + Bucket button, MutationObserver on #bucket-bar to decorate collab tabs with unread dot + pulse, delegated click handler for mark-seen, helpers for rename / color / delete that core's context menu calls for collab_uid buckets |
collab-buckets.css |
Collab-tab decoration (left accent stripe, unread dot, pulse animation) + the create / edit modal styling |
README.md |
This file |
TODO.md |
Deferred features + Phase 3 roadmap |
Core-side touchpoints (not in this directory)
| File | What changes |
|---|---|
src/Database.php |
Migration adds buckets.collab_uid TEXT + a unique partial index |
public/index.php |
api/buckets GET returns collab_uid (via SELECT *). PATCH only allows position on collab buckets (name/color must go through the plugin so other members see them). DELETE refuses collab buckets with a 400 pointing to /api/collab-buckets/{uid} |
public/js/app.js |
Exposes window.state = state so the plugin's observer can read state.buckets to resolve collab_uid per tab |
public/js/modules/buckets.js |
+ Bucket click checks window._collabBucketsCreateDialog before falling back to the simple dialog. Rename / color / delete actions check bucket.collab_uid and route through window._collabBucketsPatch / _collabBucketsDelete when set |
Relay-side files (not in this directory, part of the main relay deployment):
| File | Purpose |
|---|---|
relay/inc/bootstrap.php |
Adds collab_buckets + collab_bucket_members tables (auto-migrated on first boot) |
relay/inc/routes/collab-buckets.php |
POST/GET/PATCH/DELETE /api/collab-buckets[/{uid}] |
relay/index.php |
requires the new route file after chat.php |
API (local)
All routes live under /api/collab-buckets and are registered via
PluginManager::instance()->onApiRoute().
| Method | Path | Purpose |
|---|---|---|
GET |
/api/collab-buckets |
List local mirror. Each row has has_unread computed from last_seen_at vs updated_at. |
POST |
/api/collab-buckets |
{name, color, members[]} — generates UID, pushes to relay, mirrors local. The creator's own last_seen_at = now so the tile isn't unread for them. |
PATCH |
/api/collab-buckets/{uid} |
{name?, color?, members?} — owner-only, proxied to relay. Local row updates on next poll. |
DELETE |
/api/collab-buckets/{uid} |
Owner → delete; member → leave. Relay decides based on the authenticated caller. |
POST |
/api/collab-buckets/poll |
Full resync. Drops stale local rows, upserts changed remote rows, preserves last_seen_at. |
POST |
/api/collab-buckets/mark-seen |
{uid} — sets last_seen_at = now so the dot / pulse clear. |
GET |
/api/collab-buckets/relay-tenants |
Proxy to relay /api/tenants for the member-picker. |
API (relay)
Exact endpoints the local plugin forwards to. Authentication: the standard
X-Relay-User / X-Relay-Key client-API headers that the existing chat and
messages routes also use.
Buckets
| Method | Path | Behaviour |
|---|---|---|
POST |
/api/collab-buckets |
{uid, name, color, members: [usernames]}. Unknown usernames are silently dropped; the owner is always added to the member list regardless of whether they included themselves. |
GET |
/api/collab-buckets |
Returns all buckets where the caller is a member (owner or otherwise). Each row carries its full member list as members: [usernames]. |
PATCH |
/api/collab-buckets/{uid} |
Owner-only. Member-list replacement is total — pass the full desired list. |
DELETE |
/api/collab-buckets/{uid} |
Owner → DELETE of bucket + memberships (with action: deleted). Member → just that one membership row (with action: left); the bucket's updated_at is bumped so the owner sees the change on next poll. |
Cards (Phase 3)
| Method | Path | Behaviour |
|---|---|---|
POST |
/api/collab-cards |
{bucket_uid, card_uid, payload} — upsert. payload is a HYPRCRD-RELAY-V1 envelope encrypted with the sender's client secret. The relay decrypts it with the sender's secret (via masterDecrypt of their stored secret_encrypted), re-encrypts with the inter-tenant key, stores. Server generates updated_at so all clients agree on the authoritative LWW timestamp. Membership-guarded. |
GET |
/api/collab-cards?since=<ts> |
Pull cards updated since <ts> (ISO string from a previous response's max_updated_at) across all buckets the caller is a member of. Each row is re-encrypted under the caller's client secret on the way out. Tombstoned rows return with deleted: true and no payload. Hard-capped to 1000 cards per response. |
DELETE |
/api/collab-cards/{bucket_uid}/{card_uid} |
Mark tombstone (not hard-delete) so late pollers still observe the deletion within the 7-day TTL. Membership-guarded. |
Schema (local)
CREATE TABLE collab_buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL, -- 16-hex string generated by this client
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#38bdf8',
owner_username TEXT NOT NULL,
members_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT -- NULL = never opened → unread
);
-- Phase 3: per-card sync state. Attached to the core `cards` table via
-- card_id FK. `last_push_at` mirrors the updated_at we last sent to the
-- relay; the push phase picks up cards where c.updated_at > last_push_at.
-- `last_remote_at` mirrors the updated_at we last applied from the relay.
CREATE TABLE collab_card_state (
card_id INTEGER PRIMARY KEY,
relay_uid TEXT NOT NULL,
bucket_uid TEXT NOT NULL,
last_push_at TEXT,
last_remote_at TEXT,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
);
-- Phase 3: deletion outbox. Populated by a BEFORE DELETE trigger on the
-- cards table; drained in the push phase of the next poll.
CREATE TABLE collab_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
relay_uid TEXT NOT NULL,
bucket_uid TEXT NOT NULL,
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
members_json is a JSON array of usernames — flat and cheap to render.
Because the relay is the source of truth, we don't need to normalise
members into a separate local table.
The plugin also creates a BEFORE-DELETE trigger on cards that inserts a
tombstone row whenever a card in a collab bucket is deleted — unless the
suppress temp table collab_suppress_outbox has a row, which the plugin
sets during the poll's apply phase so remotely-triggered deletes don't
re-enqueue.
Schema (relay)
CREATE TABLE collab_buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
owner_id INTEGER NOT NULL,
owner_username TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#38bdf8',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE TABLE collab_bucket_members (
bucket_uid TEXT NOT NULL,
tenant_id INTEGER NOT NULL,
username TEXT NOT NULL,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (bucket_uid, tenant_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- Phase 3: the actual synced card content. `payload` is the re-encrypted
-- envelope stored at rest under the inter-tenant key. `deleted = 1`
-- marks a tombstone; bootstrap.php prunes them after 7 days.
CREATE TABLE collab_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket_uid TEXT NOT NULL,
card_uid TEXT NOT NULL,
author_username TEXT NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
UNIQUE (bucket_uid, card_uid)
);
Owner deletion cascades via ON DELETE CASCADE on the owner_id FK.
Member rows are pruned explicitly on leave (or cascade when a tenant is
deleted outright). collab_cards rows belonging to a deleted bucket are
orphaned but unreachable — the GET query joins collab_bucket_members
and returns nothing for them, so they're effectively gone. Future hardening
could add a cascade trigger.
Card sync flow (Phase 3)
Every poll does three things in strict order inside one request:
-
Drain tombstones. The
collab_tombstonesoutbox table is populated by a BEFORE DELETE trigger oncards— whenever a user deletes a card that sits in a collab bucket, the trigger inserts a row{relay_uid, bucket_uid}into the outbox. The poll pushes each row toDELETE /api/collab-cards/{bucket}/{card}and drops the outbox entry on success. Failed pushes stay queued and retry next poll. -
Push dirty cards. One query finds every card in a collab bucket whose
updated_atis greater thancollab_card_state.last_push_at(or never-pushed). Each is encrypted with the localrelay_keyand POSTed to/api/collab-cards. On success,last_push_atis stamped with the server's returnedupdated_atso the next poll skips it. Cards without arelay_uidget one assigned (16 hex chars) and the corecards.relay_uidcolumn is updated so the card has a stable cross-tenant identity. -
Pull deltas.
GET /api/collab-cards?since=<stored>fetches every card updated or tombstoned since the stored marker (coresettingsrowcollab_cards_last_remote_at). For each incoming card:- If
deleted: true→ look up the local card byrelay_uidandDELETEit. The BEFORE DELETE trigger is suppressed via thecollab_suppress_outboxtemp-table flag so it doesn't re-enqueue the tombstone back into the outbox. - Otherwise, decrypt the payload, find the local card by
relay_uid, and apply LWW (remote.updated_at > local.updated_at→ overwrite). New cards are INSERTed withbucket_idresolved viabuckets.collab_uid. In both casescollab_card_state.last_push_atandlast_remote_atare set to the remote'supdated_atso the next push phase doesn't re-send what was just received.
- If
Conflict resolution is strict LWW on updated_at. If two users save the
same card within the same second, the second writer wins; the first
writer's changes are silently overwritten on their next pull. Phase 4
adds optional edit-locks to surface the conflict before it happens.
Unread / pulse state
has_unread is computed by the plugin backend on every read of
/api/collab-buckets:
has_unread = (last_seen_at IS NULL) OR (updated_at > last_seen_at)
- Brand-new bucket → poll inserts the plugin row with
last_seen_at = NULL→ unread. - Metadata / member change → relay bumps
updated_at→ poll upserts →updated_at > last_seen_at→ unread. - Click tab →
mark-seensetslast_seen_at = now→ unread clears on the next render.
The plugin frontend watches #bucket-bar via MutationObserver. After
every core re-render, it walks the tabs, looks up each bucket in
state.buckets, and applies .bucket-tab-collab plus (if unread) the
.pulsing class and an injected .collab-tab-dot element. The pulse
animation is CSS-driven (collab-bucket-pulse 1.2s ease-in-out 3), and
the JS strips .pulsing after 3.7 s so subsequent re-renders don't
retrigger it mid-cycle.
Theming
All chrome hooks into the standard design tokens:
--ui-radius-button— tile and button rounding--ui-radius-modal— create/edit modal--ui-radius-input— form inputs--ui-shadow-modal— modal drop shadow--ui-blur-overlay— modal backdrop blur--collab-tile-color(per-tile) — bucket color, mixed into border, text, hover background, and pulse ring
So a collab tile automatically inherits the currently selected theme and the user's design-token choices without any plugin-side theming code.
Why this shape
- First-class bucket rows, not a separate UI. Collab buckets show up
exactly where users already look for buckets. No extra header tiles, no
second "collab+" button — the existing
+ Bucketworkflow just gains a "collaborative" checkbox. The mental model stays "this is a bucket", the plugin only layers sync on top. - Polling, not push. No long-lived connection, no WebSocket, no daemon. 30-second cadence is perfectly fine for the "someone added a card to our shared bucket" latency envelope and reuses the existing relay HTTP path.
- Relay as source of truth. The local mirror is almost stateless — only
last_seen_atis user-specific and never leaves the device. Wipes and re-syncs are trivial. - Owner-only writes. Simpler than ACLs. Owners who want to hand off control can delete-and-recreate, or we add a transfer endpoint in Phase 4.
- Soft coupling to core. Core exposes two tiny hooks
(
window._collabBucketsCreateDialog,window.state) plus a single column on the buckets table. Everything else — dialog, decoration, polling, mark-seen — lives in the plugin directory and goes away when the plugin is disabled.
Disabling
Like every other plugin, add "collab-buckets" to the disabled array in
data/plugins.json. The local table is left in place (not dropped) so
re-enabling doesn't lose unread state.