# 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` | `require`s 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=` | Pull cards updated since `` (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) ```sql 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) ```sql 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=` 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.