Files
thandClaude Opus 4.6 26ffa0e71f plugin(collab-buckets): collaborative buckets via relay
End-to-end shared buckets between relay tenants. Bucket metadata + members
sync through new relay routes (collab-buckets, collab-cards, collab-locks);
card content rides the existing inter-tenant encryption hop. Edit locks,
ownership transfer, LWW conflict stashing, undo for local-bucket destructive
actions, "Make collaborative…" upgrade path, and a resilient outbox for
relay-down create attempts. See plugins/collab-buckets/README.md and
TODO.md for the full picture.

Core hooks added so the plugin can integrate without monkey-patching:
- buckets.js context menu: collab-aware rename/color/delete + Members &
  owner / Make collaborative entries
- editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the
  per-card edit-lock heartbeat
- app.js exposes window.state for the plugin's MutationObserver
- index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab
  buckets directly (must go through the relay)
- Database.php adds buckets.collab_uid column + unique partial index
- import handler now restores color and collab_uid (was silently dropping
  color, which caused the test runner's auto-restore to wipe custom
  bucket colors)
- relay bootstrap creates collab_buckets, collab_bucket_members,
  collab_cards, collab_card_locks tables and prunes tombstones / expired
  locks on every request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:49:53 +02:00

359 lines
19 KiB
Markdown

# 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=<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)
```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=<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.