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>
This commit is contained in:
1 parent
a29708df85
commit
26ffa0e71f
16 files changed
+3017
-13
No files matched your search
@@ -0,0 +1,358 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user