Files
thandClaude Opus 4.7 d83de30d6d security: v2.0 — AES-GCM crypto, SSRF guards, rate limits, hardening
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>
2026-04-17 17:02:04 +02:00
..

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 + Bucket button in the bucket bar. The plugin intercepts the button click (via window._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-buckets instead 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 buckets table, 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_at exceeds the local last_seen_at show a red dot top-right of the tab and pulse 3 times on the next render. Clicking the tab triggers mark-seen which clears the dot/pulse immediately and persists to the plugin backend.

Architecture

Two places store bucket state locally:

  1. Main buckets table (core) — gains a collab_uid column so collab buckets are indistinguishable from local buckets in the bucket bar, bucket selects, card-to-bucket joins, etc. collab_uid is unique and NULL for regular local buckets.
  2. Plugin collab_buckets table — 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_at on the relay. The local poll only re-saves rows whose updated_at actually changed, otherwise it's a no-op and last_seen_at is 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:

  1. Drain tombstones. The collab_tombstones outbox table is populated by a BEFORE DELETE trigger on cards — 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 to DELETE /api/collab-cards/{bucket}/{card} and drops the outbox entry on success. Failed pushes stay queued and retry next poll.

  2. Push dirty cards. One query finds every card in a collab bucket whose updated_at is greater than collab_card_state.last_push_at (or never-pushed). Each is encrypted with the local relay_key and POSTed to /api/collab-cards. On success, last_push_at is stamped with the server's returned updated_at so the next poll skips it. Cards without a relay_uid get one assigned (16 hex chars) and the core cards.relay_uid column is updated so the card has a stable cross-tenant identity.

  3. Pull deltas. GET /api/collab-cards?since=<stored> fetches every card updated or tombstoned since the stored marker (core settings row collab_cards_last_remote_at). For each incoming card:

    • If deleted: true → look up the local card by relay_uid and DELETE it. The BEFORE DELETE trigger is suppressed via the collab_suppress_outbox temp-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 with bucket_id resolved via buckets.collab_uid. In both cases collab_card_state.last_push_at and last_remote_at are set to the remote's updated_at so the next push phase doesn't re-send what was just received.

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-seen sets last_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 + Bucket workflow 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_at is 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.