Commit Graph

588 Commits

Author SHA1 Message Date
xarmian 1ad9ce6c1f feat(collab): mobile WS reconnect handling (TASK-1265) (#462)
Adds visibility / online / offline event listeners to CollabProvider
so iOS Safari (and other mobile suspends) recover the WS without
waiting for the 30s backoff ceiling.

- visibilitychange→'visible' / online: forceReconnect — closes any
  existing socket (even apparently-OPEN ones, since iOS can silently
  suspend the transport while leaving readyState OPEN) and reconnects
  from a clean slate.
- offline: demote state='offline' immediately + tear down the socket
  so a queued syncStep2 can't flip back to 'synced'. Backoff timer
  keeps trying so we recover even when 'online' never fires.
- handleControlMessage now pins the source socket (e.currentTarget)
  so an applier_ack after force-reconnect doesn't land on a new
  socket the server doesn't recognize.
- Extracted runDisconnectCleanup() helper to keep all teardown paths
  in lockstep.

Six rounds of Codex review.
2026-05-09 02:56:56 -04:00
xarmian 22b4030057 feat(collab): connection-state badge on item editor (TASK-1264) (#461)
Surfaces the WS connection state ('connecting' | 'synced' |
'reconnecting' | 'offline') as a small badge in the item-detail
meta-info row. Visible only when the WS provider exists (i.e.
canEdit && !rawMode), so share-page / read-only / raw mode don't
render it.

Adds CollabProvider.state $state field with transitions on the
real-sync edges only. reconnectAttempts is now reset in the
syncStep2 branch and the grace timer (not on raw open/close), so a
flaky proxy that OPEN→CLOSE-before-sync still reaches the
OFFLINE_THRESHOLD. State preserves 'offline' across retries to
avoid flicker; pre-first-sync failures stay 'connecting',
post-sync drops become 'reconnecting'.

Three rounds of Codex review.
2026-05-09 01:00:05 -04:00
xarmian 483e338a54 feat(collab): drop conservative content-skip when collab active + applier toast (TASK-1262) (#460)
## Drop TASK-1243's content-skip when collab is active

The conservative `item = { ...updated, content: item.content }`
preservation in the SSE/sync handlers was protecting against
clobbering a user's mid-keystroke edit with a stale content
snapshot. Under collab the editor reads from Y.Doc — NOT the
content prop — so the Editor.svelte $effect's `if (ydoc) return`
gate at line 810 makes adopting `updated.content` harmless to
the live editor while keeping `item.content` fresh for
downstream consumers (UI summaries, search-index hints,
subsequent share-page renders).

For non-collab viewers (view-only, raw mode, items where
canEdit=false) the content-skip stays — those paths DO render
from item.content via the prop $effect, and adopting a stale
SSE snapshot mid-keystroke would clobber unsaved chars.

Applied to all four adoption sites:
  - SSE item_updated
  - SSE item_restored
  - syncService incremental update
  - syncService full-refresh fallback

## Applier-success toast

The applier handler (wired in TASK-1259's absorption of TASK-1262
scope) silently called setContent. Users would see their editor
change under them with no UI hint. Adds a brief
toastStore.show('External edit applied', 'info') after the
setContent succeeds; preserves the late-apply guard so toasts
only fire on actual mutations.

## Acceptance criteria

- [x] `pad item update REF --stdin < new.md` while two browser
  tabs are open: both tabs reflect the change (the applier path
  fires setContent on the longest-connected tab; ops broadcast
  to peers; SSE adoption keeps item.content fresh).
- [x] `pad item update REF --status done` (field-only): both
  tabs see the field change via SSE; no editor disruption (the
  guard `input.Content != nil` skips the applier branch
  entirely; SSE adoption updates fields atomically).
- [x] Designated client disconnects mid-flight: server retries
  next applier (TASK-1257 logic; pending follow-up TASK-1268
  for the all-applier-failed case).
- [x] Toast: "External edit applied" surfaced.

Parent: PLAN-1248
2026-05-08 23:14:56 -04:00
xarmian 2ed9314078 feat(collab): lazy-seed Y.Doc from items.content on first sync (TASK-1261) (#459)
* feat(collab): lazy-seed Y.Doc from items.content on first sync (TASK-1261)

Closes the regression introduced in TASK-1259 where items with
pre-existing items.content but no op-log entries rendered as a
blank editor under collab — the Y.Doc started empty, the server
had nothing to replay, and the user's existing markdown was
hidden behind a confusingly-empty document.

## Mechanism

A new $effect reacts to `collabProvider.synced` flipping true.
When all of these are met:

  1. Provider has completed its initial sync (synced === true).
  2. The Y.XmlFragment named 'default' (the field bound by the
     Collaboration extension per TASK-1258) has length 0 — i.e.
     the Y.Doc is genuinely empty.
  3. items.content is non-empty.

…the effect calls editor.commands.setContent(seedMarkdown). The
y-tiptap binding turns that into Y.Doc ops, which:
  - persist to the op-log (so subsequent connects + new peers
    see the content via the regular replay path), and
  - propagate to any concurrent peer.

## Idempotence

`seededProvider` tracks which provider instance we already
attempted. New providers (item nav, canEdit/rawMode flips) reset
eligibility automatically because the reference !==
seededProvider. The `=== provider` guard in $effect cleanup also
clears the slot when the provider tears down, so a raw→rich
re-mount after raw saves can re-seed cleanly if the op-log was
pruned.

## Multi-tab race

If two tabs finish their initial sync simultaneously and both
find the fragment empty, both fire setContent. Y.Doc CRDT merges
the two replace-ops with last-write-wins — worst-case outcome is
one wasted op for identical content. Acceptable for v1; a
designated-seeder lock (Y.Map flag) is a tracked follow-up if
observed in the wild.

Parent: PLAN-1248

* fix(collab): unblock synced for empty op-log + lowest-clientID seed election per Codex review (round 1)

Two findings from round 1:

1) [P1] CollabProvider.synced only flipped true on receipt of a
   syncStep2. The dumb-relay server replays the op-log as a
   sequence of BinaryMessage frames but never sends its own
   step2; an empty/pruned op-log + first-peer connect therefore
   never arrived at the explicit-sync signal — leaving synced
   stuck at false and blocking the lazy seed.

   Fix: schedule a SYNC_GRACE_MS (1s) timer in onOpen that flips
   synced=true if no explicit step2 arrives. Cancelled in
   onClose + destroy so reconnects install a fresh grace.

2) [P1] Concurrent tabs both seeing an empty fragment + calling
   setContent would have produced duplicated content (Yjs CRDT
   concurrent inserts MERGE rather than dedupe).

   Fix: lowest-clientID election. Among connected peers visible
   in awareness.getStates(), only the tab with the lowest
   clientID fires setContent. Plus a microtask yield + recheck
   immediately before the actual mutation: gives any
   concurrent peer's seed a chance to propagate, and re-runs
   the election in case awareness changed (someone joined or
   left during our $effect tick).

   Awareness-empty short-circuit: if the handshake hasn't
   propagated yet (getStates returns empty), skip — a future
   awareness update will re-trigger the effect via the synced
   dependency edge.

   Residual race: if two tabs both have awareness propagated
   AND both see "I'm lowest" within the microtask window, both
   could still seed. v1 ships with this acknowledged risk; a
   server-side designated-seeder protocol is a tracked
   follow-up if observed in the wild.
2026-05-08 23:05:32 -04:00
xarmian 9b1a91ab00 feat(collab): 5s-idle + on-disconnect markdown flush (TASK-1260) (#458)
* feat(collab): 5s-idle + on-disconnect markdown flush (TASK-1260)

Replaces the temporary handleContentUpdate suppression introduced
in TASK-1259 (PR #457) with a proper flush mechanism. Under
collab, the Y.Doc + op-log are canonical for live state but
items.content needs to stay reasonably fresh for downstream
consumers (search index, share-page, exports, plain API readers).

## Mechanism

1. **5s idle timer.** Every editor onUpdate (local OR remote)
   resets a 5s timer. On fire, PATCHes items.content via the new
   `?source=collab-snapshot` query param.

2. **Server-side bypass.** handleUpdateItem inspects the source
   query param. When set, skips the applyContentViaCollab routing
   entirely and writes directly. Without the bypass, the PATCH
   would loop back through the applier protocol (the same tab
   gets asked to apply, acks, server strips input.Content) and
   leave items.content unchanged forever. The flag is
   trustworthy because the caller already has edit access.

3. **Dedupe across peers.** Track lastFlushedContent. If our
   last successful flush already landed this exact markdown,
   skip the PATCH. Multiple connected tabs would otherwise each
   fire a redundant flush after every shared edit converges,
   multiplying server load by the peer count.

4. **On-disconnect flush.** $effect cleanup (item swap or page
   unmount) calls flushCollabNow(true) BEFORE provider.destroy().
   A separate beforeunload listener catches close-tab / reload /
   external-nav. Both use fetch keepalive: true so the request
   outlives the page lifecycle.

5. **Item-id race guards.** runCollabFlush captures reqItemId
   before await; ignores response if item swapped. loadData()
   clears collabFlushTimer + lastFlushedContent on navigation.

## Files

- internal/server/handlers_items.go — accept `?source=collab-snapshot`
- web/src/lib/api/client.ts — add api.items.flushCollabContent
- web/src/routes/.../[slug]/+page.svelte — handleContentUpdate gains
  scheduleCollabFlush + runCollabFlush + flushCollabNow; wired to
  $effect cleanup + beforeunload + loadData reset.

Parent: PLAN-1248

* fix(collab): capture ws+itemId at provider mount + apply unescapeDocLinks per Codex review (round 1)

Two findings from round 1:

1) [P1] runCollabFlush resolved item.id and wsSlug at execution
   time, not at schedule time. During item navigation the timer
   could fire (or $effect cleanup could run) AFTER `item` was
   already updated to the new item, causing the OLD editor's
   markdown to be PATCHed against the NEW item's URL —
   cross-item content corruption.

   Fix: introduce activeCollabContext = { wsSlug, itemId },
   captured at $effect-body time (when the provider is minted).
   scheduleCollabFlush, runCollabFlush, and flushCollabNow all
   take their target identity from this captured context, never
   from live reactive state. Cleared in the $effect's own
   cleanup (defensive `=== ctx` slot guard so a fast-navigation
   churn doesn't clobber a successor context).

2) [P2] The disconnect flush read raw editor.storage.markdown
   .getMarkdown() without unescapeDocLinks, unlike the regular
   onUpdate path. Closing/navigating before the idle flush could
   persist escaped wiki links like \[\[TASK-1\]\] which then
   wouldn't be converted by markdownToWikiLinks.

   Fix: apply unescapeDocLinks() at the start of runCollabFlush
   (covers both the timer-driven idle path and the unmount path).

* fix(collab): gate UI mutations on foreground+current-item per Codex review (round 2)

[P2] runCollabFlush mutated page-scoped state (saveStatus,
editorStore.lastSaveTime, lastFlushedContent) before checking
whether the captured itemId still matches the foreground item.
On item navigation, the cleanup-driven keepalive flush could
stamp 'saving' onto the NEW page's saveStatus, leaving it
pinned indefinitely (and pollute lastFlushedContent for the
new item's dedupe state).

Fix: introduce isForegroundCurrent() = !keepalive && item.id ===
itemId. Gate saveStatus / setLastSaveTime / showSaved on it so
background cleanup flushes never touch UI state. Gate
lastFlushedContent on item.id === itemId regardless of keepalive
so a stale flush can't seed the wrong item's dedupe.

* fix(collab): skip cleanup flush on rich→raw transition per Codex review (round 3)

[P1] $effect cleanup fires the keepalive flushCollabNow on every
provider teardown, including rawMode toggles. The raw-button
onclick already pre-populated rawPendingMarkdown with the live
editor markdown (which the 1.2s raw debounce will land), so the
keepalive PATCH from cleanup is redundant — and worse, can land
AFTER the raw save and clobber newer raw edits with the older
Y.Doc snapshot.

Fix: gate the cleanup flush on `!rawMode`. If rawMode is true at
cleanup time, the user just toggled to raw and the raw-mode
codepath owns items.content from here. The other cleanup
triggers (item nav, canEdit flip, page unmount) all keep
firing the flush as before.

Note: rawMode === true at cleanup time unambiguously means
"transitioning into raw" — the inverse case (already in raw and
the cleanup fires for some other reason) is impossible because
collabKey gates on !rawMode, so the provider $effect never
runs while rawMode is true.

* fix(collab): synchronously flush Y.Doc state on rich→raw toggle per Codex review (round 4)

[P1] Rich → raw → navigate-without-typing-or-toggling-back never
PATCHed the live Y.Doc state to items.content. The previous
seed mechanism only set rawPendingMarkdown, which only fires the
1.2s debounce on a subsequent handleRawContentUpdate call —
which never happens if the user doesn't type.

Fix: await runCollabFlush(ws, itemId, md, true) inside the raw
button's async onclick BEFORE flipping rawMode = true. This:

  - Lands items.content with the live Y.Doc state synchronously
    (one PATCH, awaited, with keepalive: true so it survives a
    fast post-toggle navigation).
  - Seeds lastFlushedContent so any cleanup-driven re-flush is
    deduped.
  - Avoids populating rawPendingMarkdown — the raw debounce now
    only fires for actual user edits in raw mode, eliminating
    the race where a stale debounce fired after navigation
    could clobber state.

The Round 3 cleanup-skip on rawMode is kept as defense-in-depth
(also makes the no-op-when-already-flushed semantics explicit).

* fix(collab): loop-flush until stable + cancel timer on rich→raw toggle per Codex review (round 5)

Two HIGH findings from round 5:

1) Round 4's single-flush captured md BEFORE the await; concurrent
   peer edits (e.g. same user's other tab) during the await
   were lost from the seed and could be overwritten by
   subsequent raw-mode saves.

   Fix: loop-flush until stable. Re-read editor markdown after
   each PATCH; if it changed, flush again. Capped at 3
   iterations to bound the transition under aggressive
   concurrent typing.

2) An onUpdate during the await could schedule a 5s collab
   flush timer that survived the rawMode flip. The cleanup
   skipped flushCollabNow on rawMode, but the timer fired its
   own runCollabFlush — which then PATCHed stale rich markdown
   on top of subsequent raw saves.

   Fix: explicitly clearTimeout(collabFlushTimer) at the end
   of the rich→raw onclick (after the loop-flush, before
   flipping rawMode). Belt-and-braces with the Round 3 cleanup
   skip.

* fix(collab): seed raw mode from lastFlushed (not unflushed Y.Doc) per Codex review (round 6)

[HIGH] Round 5's loop-flush could exit at the 3-iteration cap with
md still differing from the last-PATCHed value, then seed
rawSeedMarkdown with that unflushed md. An immediate navigation
without typing would lose the unpersisted state.

Fix: track lastFlushed inside the loop. After the loop, seed
rawSeedMarkdown = lastFlushed (the markdown we actually PATCHed),
NOT md (potentially a never-flushed in-memory value). If peer
edits keep arriving past our cap, items.content lags Y.Doc
briefly — but the peer's own 5s flush will catch up shortly,
and at least raw mode shows state consistent with items.content
rather than holding a value the server never received.

* fix(collab): three corner-case fixes per Codex review (round 7)

1) [HIGH] lastFlushed = md was set unconditionally inside the
   loop-flush, even when runCollabFlush returned false (PATCH
   failed). rawSeedMarkdown could then be seeded with markdown
   the server never received.

   Fix: gate `lastFlushed = md` on runCollabFlush returning true.
   Failed PATCHes leave lastFlushed at its prior value.

2) [HIGH] lastFlushedContent (the collab-flush dedupe key) was
   never invalidated by raw-mode direct saves. Scenario: collab
   flushes A. Raw saves B. User returns to rich + edits back to
   A. Next collab flush dedupes (lastFlushedContent === A) and
   skips, leaving items.content stuck on B.

   Fix: reset lastFlushedContent = null after every successful
   raw save (both the regular handleRawContentUpdate path and
   the flushRawIfPending drain loop) so subsequent collab
   flushes always re-PATCH.

3) [MEDIUM] The async rich→raw onclick applied rawSeedMarkdown +
   rawMode = true after multiple awaits without verifying the
   user was still on the same item. A navigation during the
   loop-flush could let item A's handler resume and seed raw
   mode on item B.

   Fix: before mutating component state (rawSeedMarkdown,
   rawMode), check `item?.id === itemId` (the captured target).
   Bail with `return` if mismatched.

* fix(collab): differentiate flush outcomes + foreground keepalive=false per Codex review (round 8)

Two findings from round 8:

1) [P1] runCollabFlush returned `false` for both PATCH failure
   AND dedupe-skip. The rich→raw toggle treated `false` as
   "didn't flush" and didn't seed rawSeedMarkdown — but a dedupe
   means items.content already matches our markdown (the prior
   successful flush put it there). Raw mode then seeded from
   the page's stale `item.content` field, and a subsequent raw
   save could overwrite the current server content with the
   pre-collab snapshot.

   Fix: change runCollabFlush's return type to a discriminated
   string: 'flushed' | 'deduped' | 'failed'. The toggle treats
   'flushed' and 'deduped' equivalently for seeding (both mean
   "server has this markdown") and only bails on 'failed'.

2) [P2] The toggle path used keepalive=true for the awaited
   flush. Browser keepalive requests can reject for bodies
   larger than the per-origin keepalive quota (~64KB). On
   reject, the catch silently fell through and raw mode
   activated with rawSeedMarkdown null.

   Fix: switch the toggle path to keepalive=false. The await is
   synchronous and user-initiated; navigation isn't imminent, so
   the keepalive escape hatch isn't needed (and risks losing
   the explicit save). Also added an `aborted` short-circuit so
   a 'failed' result returns early WITHOUT entering raw mode —
   user can retry. Cleanup-driven flushes (which DO need to
   survive page lifecycle) still use keepalive=true.
2026-05-08 22:48:56 -04:00
xarmian 5dc42b60df feat(collab): wire Yjs WebSocket provider + Y.Doc lifecycle (TASK-1259) (#457)
* feat(collab): wire Yjs WebSocket provider + Y.Doc lifecycle (TASK-1259)

Adds a thin y-websocket-style provider speaking the binary protocol
already implemented server-side in internal/collab/room.go. The
provider lives in a Svelte 5 .svelte.ts module so connection state
(`connected`, `synced`) can be consumed reactively by upcoming UX
tasks (TASK-1264 pending-sync indicator, TASK-1265 mobile reconnect).

Wire format mirrors the server's first-byte discriminator:
  0x00 → y-protocols/sync (persisted to op-log + broadcast)
  0x01 → y-protocols/awareness (broadcast only, ephemeral)

Lifecycle is bound to the item-detail page via $effect keyed on
`${item.id}:${canEdit}` — same key the <Editor> already re-mounts on,
so the Y.Doc and provider tear down in lockstep with the editor.
View-only viewers (canEdit === false) keep the legacy non-collab
editor; their read-only y-binding is deferred to TASK-1266.

Reconnect uses 1s/2s/4s/...30s exponential backoff. Sophisticated
mobile reconnect (visibility, network state) is TASK-1265.

KNOWN TEMPORARY REGRESSION: existing items with non-empty
items.content render an empty editor on first open under collab,
because the Y.Doc starts empty and TASK-1259 doesn't seed from
markdown. TASK-1261 (next in Phase 2) adds the lazy seed-after-
initial-sync path. New items + items already round-tripped through
collab are unaffected.

Drive-by lint cleanup of dead code that escaped Phase 1's
make-install-skips-lint loophole:
- gofmt -w on internal/collab/{applier,bus,manager}.go
- removed unused test/debug helpers Room.peerCount and
  Room.applierConnCount (re-add with real callers when needed)

Parent: PLAN-1248

* fix(collab): gate Editor mount on ydoc + handle applier_request + catch-up state per Codex review (round 1)

Three findings from round 1:

1) [P1] $effect constructs ydoc AFTER Editor's onMount runs, so the
   first mount on an editable item registered StarterKit history
   instead of the Collaboration extension. The {#key} excluded ydoc,
   so the editor never re-mounted when ydoc later became truthy →
   editable users got a non-collab editor while the provider connected
   to an unused Y.Doc.

   Fix: gate the editable Editor mount on `ydoc` being ready
   (`{#if !canEdit} ... {:else if ydoc} ...`). Adds at most one
   reactive tick of delay; guarantees the first mount has the binding
   registered.

2) [P1] Provider dropped non-binary WebSocket frames, but the server
   sends `applier_request` as TextMessage. With TASK-1259 minting
   active rooms, every concurrent CLI/MCP/API content PATCH would
   sit blocked for 30s waiting for an ack, then fall back to a
   direct write — and the in-memory Y.Doc would still hold stale
   state and clobber it on the next 5s flush. Silent data loss.

   Fix: parse TextMessage frames as JSON ControlMessage. On
   `applier_request`, invoke an `onApplierRequest` callback (the
   page passes `editor.commands.setContent(markdown)`) and send
   `applier_ack` on success. The ExpiresAtMillis-driven late-apply
   guard remains TASK-1262's full scope.

3) [P2] Local Y.Doc updates were silently dropped if the socket was
   closed when handleDocUpdate fired. On reconnect the dumb-relay
   server can't reconstruct missing updates from a state vector, so
   any edits made before the first open or during a disconnect
   could be lost.

   Fix: after sending syncStep1 in onOpen, also send the current
   doc state as a single update via `Y.encodeStateAsUpdate(ydoc)`.
   CRDT idempotency makes this safe on initial open (server already
   has these ops via op-log replay → sees a no-op update). Larger
   docs incur a one-time cost on each connection; TASK-1265's
   mobile-reconnect work can replace this with a buffered queue.

* fix(collab): destroy provider during rawMode + enforce ExpiresAtMillis on applier requests per Codex review (round 2)

Two findings from round 2:

1) [P1] collabKey ignored rawMode, leaving the WS provider connected
   while the user edited via RawMarkdownEditor. Raw saves bypass the
   y-binding (PATCH writes items.content directly), but the server
   sees an active room → routes the PATCH through the applier flow
   → no editor mounted → 30s timeout fallback → direct write. The
   stale Y.Doc still in memory then overwrote the raw save on the
   next 5s flush after toggling back.

   Fix: include rawMode in the collabKey derivation so toggling raw
   destroys the provider (and the in-memory Y.Doc), and toggling back
   mints a fresh pair that re-seeds from the op-log + TASK-1261's
   lazy markdown seed.

2) [P1] Provider passed expires_at_millis to the handler but never
   gated on it. A backgrounded tab that wakes after the server
   retried or fell back could still apply setContent and overwrite
   newer peer edits.

   Fix: enforce the expiry in CollabProvider — check before
   invoking the handler AND re-check before acking (handlers are
   awaited and could span the deadline). Suppress the ack if either
   gate trips; the server interprets "no ack" as "applier
   unavailable" and falls back cleanly.

* fix(collab): prune op-log on direct-write fallback + pre-mutation expiry check per Codex review (round 3)

Two findings from round 3:

1) [P1] rawMode toggle to/from rich left a stale op-log: raw saves
   wrote items.content directly while the destroyed provider's old
   op-log persisted. Toggling back minted a fresh Y.Doc that
   replayed the old log → showed pre-raw content → silently
   overwrote the raw save on the next 5s flush.

   Fix server-side: when ApplyExternalContent returns ErrNoActiveRoom
   (no peers in memory, no in-flight Y.Doc state to corrupt), prune
   the op-log alongside the direct items.content write so future
   collab sessions start from a clean slate seeded by items.content
   (TASK-1261's lazy seed). Pruning is intentionally NOT applied to
   ErrNoApplierAvailable / ErrAllAppliersTimedOut — those paths
   may have live peers whose Y.Doc state would diverge.

2) [P2] Provider's post-handler expiry check only suppressed the
   ack, not the actual setContent mutation owned by the page
   handler. An async handler that crossed the deadline could still
   write stale markdown into the Y.Doc.

   Fix: page handler now does its own pre-mutation expiry check
   inside onApplierRequest before calling setContent. Documented
   the contract on ApplierRequestHandler — handlers MUST honour
   expiresAtMillis BEFORE mutating state.

* fix(collab): prune op-log on grace-TTL applier-unavailable + suppress autosave when collab active per Codex review (round 4)

Two findings from round 4:

1) [HIGH] op-log pruning still skipped ErrNoApplierAvailable. When
   raw-mode destroys the in-tab provider, the room remains in its
   60s grace TTL with zero conns, so the next direct-write PATCH
   returns ErrNoApplierAvailable (not ErrNoActiveRoom). Stale op-log
   rows persisted; toggling back within the grace window resurrected
   pre-raw-save Y.Doc state.

   Fix: prune op-log on ErrNoApplierAvailable too — the "no live
   conns" condition makes pruning safe (no peers to corrupt).
   ErrAllAppliersTimedOut still preserves op-log because peers may
   still be alive there.

2) [HIGH] Once the WS provider is active the legacy 1.2s content
   autosave PATCH gets intercepted by the applier path
   (handleUpdateItem branch added in TASK-1252). On applier success
   input.Content is nil'd out, so UpdateItem never writes the
   markdown snapshot. The page's autosave was the only canonical
   items.content flush in this diff — search / share-page / API
   consumers would see stale content forever.

   Fix: short-circuit handleContentUpdate when collabProvider is
   set. The Y.Doc + op-log are canonical; items.content stays at
   its pre-collab snapshot until TASK-1260 introduces the proper
   5s idle flush with applier-bypass semantics. This is a known
   Phase-2-internal regression closed by the very next task in
   this run.

* fix(collab): tighten error classification + per-item lock + raw-mode flush per Codex review (round 5)

Three findings from round 5:

1) [HIGH] applier.go could return ErrAllAppliersTimedOut even when
   no applier_request was ever successfully written (a row of write
   failures followed by no remaining candidates). The handler-side
   prune skipped that case, leaving stale op-log rows even though
   no peer received the request.

   Fix: track `anyWriteSucceeded` across the attempts and return
   ErrNoApplierAvailable (which prunes) when the loop exits without
   ever putting bytes on the wire.

2) [HIGH] Race between ApplyExternalContent's no-room classification
   and the subsequent Prune/UpdateItem: a fresh Join could mint a
   room and replay the soon-to-be-pruned op-log into a new client,
   leaving it with stale Y.Doc state that overwrites the
   freshly-written items.content on the next idle flush.

   Fix: introduce per-item setup mutex on RoomManager. Join holds
   the lock across addConn + replayTo and releases it before the
   long-lived readLoop. New PruneAndApply method wraps the
   prune+direct-write in the same per-item lock and re-verifies
   "no live peers" under it (returns ErrRoomActiveDuringPrune if a
   peer slipped in, in which case the caller falls through to a
   plain direct write without pruning). Lock order: per-item lock
   > m.mu > r.mu — Join and PruneAndApply both follow it.

3) [MEDIUM] Raw-mode 1.2s debounce timer could outlive the toggle
   to rich mode: the deferred PATCH fired post-collab-mint and got
   routed through the applier path (potentially overwriting newer
   peer state).

   Fix: track the latest pending raw markdown in
   `rawPendingMarkdown`. The Rich-mode button is now an async
   onclick that awaits a `flushRawIfPending()` synchronous PATCH
   before flipping `rawMode = false` (which is what activates the
   collab provider via the collabKey derivation).

* fix(collab): evict broken applier conn + retry on prune-race + retain raw pending on PATCH failure per Codex review (round 6)

Three findings from round 6:

1) [HIGH] When applier_request write failed, the broken roomConn
   stayed in r.conns, defeating PruneAndApply's "no live peers"
   check (which then returned ErrRoomActiveDuringPrune and the
   handler skipped pruning). Net effect: the prune-safety
   classification reverted to the round-5 hazard.

   Fix: in the applier write-failure branch, force-close the conn
   and call removeConn before continuing to the next applier. Both
   are idempotent with the readLoop's natural cleanup path
   (bus.Unsubscribe, conn map delete, conn.Close all tolerate
   double-invocation).

2) [HIGH] On ErrRoomActiveDuringPrune the handler fell through to a
   plain direct-write to items.content, bypassing the now-active
   peer's applier. The peer's stale Y.Doc could still overwrite
   items.content on the next idle flush.

   Fix: surface ErrRoomActiveDuringPrune from
   applyContentViaCollabOnce so the new applyContentViaCollab
   wrapper can retry the full ApplyExternalContent flow against
   the freshly-active room. Capped at applyContentMaxRetries=3 to
   prevent runaway loops if joins keep landing during prune
   attempts. After exhaustion, returns the same sentinel — the
   handler's existing `if err == nil { input.Content = nil }`
   gate falls through to direct write, which is the correct
   degraded-mode behavior.

3) [MEDIUM] flushRawIfPending cleared rawPendingMarkdown before
   the PATCH succeeded and the Rich-mode toggle always set
   rawMode = false regardless of flush outcome. A failed flush
   could activate collab with unsaved raw edits.

   Fix: rework flushRawIfPending to return success bool, retain
   rawPendingMarkdown on PATCH failure, and gate the Rich-button
   transition on `ok`. Added a re-entrancy guard
   (rawFlushInFlight) so a rapid double-click waits for the
   in-flight flush to settle instead of issuing a duplicate PATCH.

* fix(collab): drain-loop flushRawIfPending to handle fast-typist edge per Codex review (round 7)

[P1] flushRawIfPending snapshotted rawPendingMarkdown then awaited
the PATCH; if the user typed during the await, the equality check
preserved the newer edit but the function still returned `true` and
the Rich-mode handler flipped collab on. The newly-active provider
then raced the un-flushed pending raw save — exactly the hazard
the guard is meant to close.

Fix: rework flushRawIfPending into a bounded drain loop. Each
iteration snapshots-PATCHes-clears (with the equality check). The
loop runs up to RAW_FLUSH_DRAIN_CAP=5 iterations, returning `true`
ONLY when rawPendingMarkdown is null on exit AND no PATCH failed.
A fast typist who keeps the queue non-null across the cap returns
`false`, leaving the user in raw mode (next click retries).
PATCH failure short-circuits with `false` so the toggle stays in
raw mode and the unsaved markdown is preserved for retry.

* fix(collab): atomic prune+content-write + preserve newer raw edit on stale PATCH response per Codex review (round 8)

Two findings from round 8:

1) [P1] PruneAndApply ran the op-log prune under the per-item lock
   but the items.content write happened later in the post-loop
   UpdateItem call, OUTSIDE the lock. A fresh Join landing in that
   gap could replay the now-empty op-log, mint a peer with stale
   Y.Doc state, and then overwrite the freshly-written
   items.content on the next idle flush.

   Fix: applyContentViaCollab now takes a `directWrite` callback
   that the caller (handleUpdateItem) implements as a content-only
   UpdateItem. PruneAndApply's applyFn invokes it AFTER the prune
   so both run inside the same per-item critical section. The
   trade-off is two DB round-trips when a PATCH carries content +
   other fields together (rare): the content-only update happens
   inside the lock; the rest (title, fields, status) flows through
   the post-loop UpdateItem with input.Content nil'd to suppress
   the duplicate write.

2) [P1] In flushRawIfPending's drain loop, `item = updated`
   assigned the server-side snapshot from the just-PATCHed
   markdown even when a newer raw edit had landed in the meantime.
   RawMarkdownEditor mirrors `item.content` into its textarea
   unconditionally (line 16), so the stale assignment would reset
   the textarea mid-keystroke and lose the queued edit.

   Fix: only swap in the full updated snapshot when
   `rawPendingMarkdown === markdown` (no newer edit). Otherwise
   keep our local content and adopt only the server-side metadata
   (timestamps, version, modified_by) via spread.

* fix(collab): atomic mixed PATCH + raw autosave stale guard + rich→raw seeding per Codex review (round 9)

Three findings from round 9:

1) [P1] Toggling FROM rich+collab TO raw mode seeded
   RawMarkdownEditor from items.content, which is intentionally
   stale under collab (handleContentUpdate is suppressed while the
   provider is connected; TASK-1260 closes that gap with a 5s
   flush). Saving from raw mode would overwrite the live Y.Doc
   state with a pre-collab snapshot.

   Fix: when toggling to raw with a connected provider, capture
   the editor's current Y.Doc-derived markdown via
   `editor.storage.markdown.getMarkdown()` into a one-shot
   `rawSeedMarkdown` slot and pre-populate `rawPendingMarkdown` so
   the first auto-save persists it. RawMarkdownEditor seeds from
   `rawSeedMarkdown ?? item.content`. Cleared on rich-mode toggle.

2) [P1] The regular debounced raw autosave still assigned
   `item = updated` from a stale PATCH response. Same
   stale-snapshot hazard the Round 8 fix closed in
   flushRawIfPending.

   Fix: equality-check `rawPendingMarkdown === toSave` before
   swapping in the server snapshot. On stale, keep local content
   and adopt only the server-side metadata via spread.

3) [P2] Round 8 split the items.content write (under per-item
   lock) from the rest of UpdateItem (post-loop), losing
   atomicity for mixed PATCHes (content + title) and breaking
   Store.UpdateItem's content-versioning peek at Title.

   Fix: directWrite callback now invokes the FULL UpdateItem
   inside the per-item lock. A `fullWriteHandled` flag tells the
   handler to skip the post-loop UpdateItem entirely (otherwise
   we'd duplicate the write and create two version-history rows).
   Mixed PATCHes are atomic again under the lock.

* fix(collab): clear raw seed/pending on item navigation per Codex review (round 10)

[P1] Navigating between items left rawSeedMarkdown,
rawPendingMarkdown, and the contentDebounceTimer set from the
previous item. This caused two concrete hazards:

  (a) Item B's raw editor mounted with item A's live markdown via
      `rawSeedMarkdown ?? item.content`.
  (b) Clicking Rich on item B fired flushRawIfPending which
      PATCHed A's queued markdown INTO item B (cross-item data
      bleed).

Fix: at the top of loadData(), clear contentDebounceTimer,
rawSeedMarkdown, and rawPendingMarkdown so each navigation starts
from a clean slate. The collab provider's own lifecycle is
already keyed on item.id via $effect cleanup, so it doesn't need
the same explicit reset.

* fix(collab): item-id race guard on raw PATCH responses per Codex review (round 11)

[P1] In-flight raw PATCH responses (debounced autosave AND drain
loop) could clobber a newly navigated item. Clearing
contentDebounceTimer in loadData only cancels timers that have
not fired; an awaiting fetch keeps running and its `.then` /
`.catch` would assign back to the new page's `item` state.

Fix: mirror the existing TASK-754-style race guard pattern
(already used in the SSE / sync handlers above). Capture
`reqItemId = item.id` BEFORE the PATCH, then in the response
handler bail if `!item || item.id !== reqItemId`. Applied to
both handleRawContentUpdate's setTimeout body and
flushRawIfPending's drain loop.

* fix(collab): reset saveStatus on item navigation per Codex review (round 12)

[P2] After Round 11's race guard, a stale raw PATCH response that
matched a now-different item.id was correctly discarded — but
saveStatus had already been set to 'saving' before the await. With
loadData not resetting it, the next item could mount with
saveStatus pinned at 'saving' indefinitely, which then suppressed
all SSE/sync refreshes via the `if (saveStatus === 'saving')`
guards above.

Fix: in loadData's per-item state reset, clear saveStatusTimer
and reset saveStatus to 'idle' alongside the other transient
state. Cheap, scoped, no impact on the in-flight save's eventual
discard path.
2026-05-08 20:48:57 -04:00
xarmian b4904e0db9 feat(editor): wire optional ydoc + Collaboration extension (TASK-1258) (#456)
* feat(editor): wire optional ydoc + Collaboration extension into Editor.svelte (TASK-1258)

Productionizes the Yjs binding pattern verified in the
feat/yjs-tiptap-spike branch (TASK-1245 sandbox). Editor.svelte now
accepts an optional `ydoc?: Y.Doc` prop; when set, it registers the
Tiptap Collaboration extension and disables StarterKit's undoRedo
so the y-tiptap binding takes over document state + history.

Editor.svelte changes:

- Imports: `Collaboration` from `@tiptap/extension-collaboration`,
  type-only `Y.Doc` from `yjs` (the actual constructor lives in the
  caller route — TASK-1260 wires the WS provider).
- Props: new optional `ydoc?: Y.Doc`. When undefined the editor's
  extension list and behaviour are byte-identical to today;
  every existing call site stays backward-compatible.
- Extensions:
    · StarterKit.configure now spreads `{ undoRedo: false }` IFF
      ydoc is set (Yjs owns history when collab is active; v3's
      option is `undoRedo`, the v2 `history` key was renamed).
    · `...(ydoc ? [Collaboration.configure({ document: ydoc,
      field: 'default' })] : [])` slot at the end of the extension
      array — empty when ydoc is undefined, so existing single-Doc
      usage is unaffected.

Dependencies added to web/package.json:

- `yjs@^13.6.30`
- `@tiptap/extension-collaboration@3.22.5` — EXACT pin (no caret)
  because @tiptap/core is on 3.22.5 and the latest published
  3.23.x of extension-collaboration requires a matching core. The
  CLAUDE.md note in TASK-1269 codifies the multi-package coordinated
  bump rule.
- `@tiptap/y-tiptap@^3.0.3`

Out of scope (per task spec): WS provider plumbing (TASK-1260),
lifecycle binding to item-detail page (TASK-1260), first-edit seed
from markdown (TASK-1262), CollaborationCursor extension
(TASK-1264).

Parent: PLAN-1248. Phase 1 — Backend foundation complete; Phase 2
is the next logical block to start.

* fix(editor): gate content-prop sync $effect when ydoc is active per Codex review (round 1)

P1: Editor.svelte's existing $effect that calls
editor.commands.setContent on prop-content changes (item switch,
external REST update) would route through the y-tiptap binding as a
LOCAL ProseMirror change when Collaboration is registered —
overwriting peers' Y.Doc state with stale REST markdown on every
content prop refresh.

Add an early return in the $effect when ydoc is set. Y.Doc is the
authoritative state under collab; markdown→Y.Doc seeding for the
first-edit-on-empty case lives in TASK-1262 and uses Y.Doc's own
primitives, NOT setContent.

The early return also captures `tracker.prev = content` so a future
host route that swapped ydoc on/off mid-editor wouldn't see the
non-collab branch's `prev === undefined` and accidentally skip its
first sync. In practice ydoc is set once per editor mount today,
but the cheap capture keeps the contract honest.
2026-05-08 16:46:32 -04:00
xarmian 50e0936b34 feat(collab): designated-applier protocol for external content updates (TASK-1257) (#455)
* feat(collab): designated-applier protocol for external content updates (TASK-1257)

The keystone task for CLI / API / MCP integration during co-edit
sessions. When a content update arrives via PATCH while at least one
browser tab is connected to the item's collab room, the server can't
write items.content directly — the connected tabs would silently
overwrite it on the next 5s idle flush using their (now stale) Y.Doc
state and the caller's update would be lost.

Solution: nominate one connected tab as the "designated applier",
send it a JSON control message with the new markdown, the browser
does editor.commands.setContent(markdown) which the y-tiptap binding
translates into Y.Doc updates that propagate via the regular sync
path. Items.content gets refreshed via the next 5s flush
(TASK-1261).

Architecture:

internal/collab/applier.go (new):
- ControlMessage struct — JSON envelope for applier_request /
  applier_ack frames. Carried over WebSocket TextMessage, which is
  unambiguous against y-protocol's BinaryMessage.
- ApplyExternalContent(itemID, markdown) — public entry point.
  Returns nil on ack, ErrNoActiveRoom when there's no room (caller
  falls back to direct write), ErrNoApplierAvailable when the room
  has no live conns, ErrAllAppliersTimedOut when every attempt
  expired.
- Election: pickApplier returns the longest-connected roomConn that
  hasn't already been tried, with deterministic tiebreak on conn id.
  Stable choice — longest connection has the most authoritative
  cumulative Y.Doc state, fewer flicker risks.
- Retry loop: applierMaxAttempts=2, applierFirstTimeoutVar=30s,
  applierRetryTimeoutVar=15s. The Var-suffixed names exist so test
  helpers can shrink to ms without sleeping a real minute.
- Pending-ack tracking: per-room map[requestID]*pendingApplierAck
  pairing the channel a PATCH handler is waiting on with the conn
  the ack is expected from. expectedConn check prevents an unrelated
  peer from spoofing acks for someone else's request.

internal/collab/room.go (extended):
- roomConn gains connectedAt for the election.
- readLoop branches TextMessage → handleControlMessage which decodes
  the JSON and routes applier_ack to the room's pending tracker.
  Unknown control types and malformed JSON are silently dropped so a
  bad client can't break the loop.

internal/collab/manager.go:
- Registers connectedAt on Join.
- Initialises room.pendingAcks alongside conns map.

internal/server/handlers_items.go (extended):
- handleUpdateItem now branches on input.Content != nil + s.collab
  != nil: routes through s.applyContentViaCollab; on success,
  zeros input.Content so UpdateItem's direct write is suppressed.
- Field-only PATCHes skip this branch entirely — backward-compatible.

internal/server/handlers_collab.go:
- applyContentViaCollab wraps mgr.ApplyExternalContent with
  per-error-class slog warnings so operators can see degraded
  paths (timeouts → warn; no-room / no-applier → quiet, the
  common case for non-co-edit CLI updates).
- actorIDFromRequest helper for log fields.

Tests (5 new):
- TestApplyExternalContentNoActiveRoom — sentinel error path.
- TestApplyExternalContentHappyPath — applier echo acks within ms.
- TestApplyExternalContentTimeoutsThenFails — applier never acks;
  we hit applierFirstTimeoutVar then ErrAllAppliersTimedOut.
- TestApplyExternalContentTimeoutThenSecondAcks — first applier
  silent, retry picks second-longest-connected, succeeds.
- TestApplyExternalContentRejectsAckFromUnexpectedConn — defence-
  in-depth: peer B forges an ack for peer A's request; the room's
  expectedConn check rejects it; ApplyExternalContent runs to
  timeout instead of being satisfied by the forgery.

All tests pass under -race. Full suite green.

Parent: PLAN-1248. Phase 1 — Backend foundation.

* fix(collab): clean pendingAcks on success + expires_at on applier_request per Codex review (round 1)

P2 #1: ApplyExternalContent retained the per-request pendingAcks
entry on success. Each successful external update therefore
leaked a request_id + channel + expected-conn pointer for the
remainder of the room's lifetime — across long-lived sessions
the map would grow without bound. Add cancelPendingAck to the
ack-success path so the entry is released as soon as the request
completes. Test added (TestApplyExternalContentCleansPending-
AcksOnSuccess) drives 5 successful applies and asserts the
pendingAcks map is empty afterwards.

P2 #2: applier_request had no client-enforceable expiry, so a
backgrounded tab could process a stale request 60s later and
overwrite newer edits with old markdown after the server had
already retried with a different applier (or fallen back to
direct write). Add ExpiresAtMillis to the ControlMessage
envelope, populated per attempt with `now + timeouts[attempt]`.
The browser-side handler (TASK-1263) is responsible for the
client-side Now() check before applying — without that check
the field is documentation-only. Added test
(TestApplyExternalContentSendsExpiresAt) regression-tests the
server stamp.

Server-side cleanup is also reinforced: cancelPendingAck on
timeout (already present) means a late ack from a timed-out
applier is rejected at the room layer (entry is gone). The
expires_at_millis is the second line of defence for the case
where the browser sends the Y.Doc setContent BEFORE the ack —
the request must not be applied at all.
2026-05-08 16:30:39 -04:00
xarmian 79eb00d2a1 feat(collab): periodic auth revalidation timer (TASK-1256) (#454)
* feat(collab): periodic auth revalidation timer (TASK-1256)

Catches mid-session revocations on a live collab WebSocket the same
way handlers_events.go's sseSubscriberStillHasAccess does for SSE.

When the WS handler upgrades, it spawns a goroutine that ticks every
collabMembershipRevalInterval (60s, jittered across [0, interval)
on first fire to avoid post-deploy reconnect-storm spikes). Each
tick re-runs authorizeCollabAccess — the same workspace-access
ladder used at upgrade time, including the "fresh-fetch user from
store" semantics that make admin-demoted-mid-stream visible without
waiting for the next request.

On access loss the handler routes through a new
RoomManager.CloseConn(itemID, conn, code, reason) method which:

- Looks up the roomConn in the manager so the close frame can go
  out under the per-conn writeMu (no concurrent-write panic against
  the room's writeLoop or replay path).
- Sends a websocket.ClosePolicyViolation frame with a human-readable
  reason ("Your access to this item was revoked.") so the frontend
  can stop reconnecting in a tight loop.
- Falls back to plain conn.Close when the conn isn't tracked yet
  (race window between Join's getOrCreate and addConn).

The goroutine is bound to the handler's lifetime via a `stop`
channel that closes when handleCollab returns; no leaked timers
or goroutines after disconnect.

Test (TestCollabMembershipRevalidationClosesOnRevoke):

- Shrinks the reval interval to 30ms so the test runs in tens of
  ms rather than 60 seconds.
- Bootstraps an admin (so the no-users escape hatch is closed),
  creates a non-admin member user, mints a session, dials in.
- Calls RemoveWorkspaceMember while the WS is open.
- Asserts the next read returns an error (close frame or transport
  failure — both are acceptable signals the server tore the
  connection down).

Parent: PLAN-1248. Phase 1 — Backend foundation.

* fix(collab): WriteControl for revoke close + tighten test failure modes per Codex review (round 1)

P2 #1: CloseConn used rc.writeMessage which acquires the per-conn
writeMu. If the room's writeLoop / replay was mid-WriteMessage to a
slow peer, revocation would block behind that writer and never
force-close the unauthorized conn. Switch to conn.WriteControl,
which gorilla documents as concurrency-safe with normal writes
(it bypasses the conn's normal write path) and accepts an explicit
deadline so a stuck send can't extend the budget indefinitely.

The deadline is 1s — generous for a healthy conn, short enough that
a half-broken socket falls through to plain Close quickly. The
itemID parameter stays in the API for symmetry / future per-room
metrics, but is no longer used for the actual close path now that
the writeMu lookup is gone.

P2 #2: TestCollabMembershipRevalidationClosesOnRevoke previously
treated a read-deadline timeout as a log-only branch — the test
could pass after waiting 2s with the WS still open, exactly the
bug being regression-tested. Restructure to fail fast on timeout
(t.Fatalf isTimeout(err)) and prefer ClosePolicyViolation as the
expected close code, falling back to "any non-timeout error" only
because the underlying TCP teardown can produce different error
shapes depending on timing. The isTimeoutOrEOF helper that
masked the failure is replaced with a narrowly-scoped isTimeout.

* fix(collab): distinguish access denial from transient errors in reval per Codex review (round 2)

P-MEDIUM: the revalidation goroutine treated any non-nil error from
authorizeCollabAccess as revocation, including transient store
errors (GetUser / GetWorkspaceByID / grant-lookup blips). One DB
hiccup would close every active collab WS with
ClosePolicyViolation, which is a worse UX than the bug being
guarded against.

Distinguish via errors.As against *statusError (the typed return
from authorizeCollabAccess used for all "we know they don't have
access" branches). Plain errors fall through to a warn-level log
+ timer reset so the next tick retries.

Three branches in the revalidation switch now:
  err == nil           still authorised — reset timer.
  isAccessDenial(err)  real revocation — close conn with typed reason.
  default              transient — log warn, keep conn open, reset.

* fix(collab): re-fetch item on each reval tick per Codex review (round 3)

P2: revalidation re-authorized against the *Item captured at
upgrade time, so an item moved to a collection the user can't see —
or hard-deleted — would not be caught: authorizeCollabAccess kept
checking the stale CollectionID, kept passing, and the WS stayed
open against an item the user no longer has access to.

Re-fetch via s.store.GetItem(itemID) at the start of each tick:

- error → log warn, keep conn open, retry next tick (matches the
  transient-store-error policy from round 2).
- nil → item hard-deleted (or never existed): close with
  ClosePolicyViolation + "This item is no longer available."
- otherwise → authorize against the FRESH item, picking up any
  collection move automatically.

Per-tick GetItem is one indexed lookup per minute per active
connection — negligible compared to the auth-cascade GetUser /
member / grant queries that already run on the same tick.
2026-05-08 16:07:26 -04:00
xarmian e7b1c3b5ae feat(collab): per-item Room manager with op-log replay + grace TTL (TASK-1255) (#453)
* feat(collab): per-item Room manager with op-log replay + grace TTL (TASK-1255)

Wires the OpBus + op-log + WS handler from prior phase-1 PRs into a
working dumb-relay collab server. Per-item Room created lazily on
first Join, kept alive across transient disconnects via a 60s grace
TTL, reclaimed when the grace expires with no fresh subscribers.

Components:

- internal/collab/room.go — Room struct + lifecycle
  · roomConn pairs (id, conn, bus channel, write mutex). The id is
    server-assigned per WS so writeLoop can suppress own-event echoes
    without decoding the Y.Doc to read the Yjs ClientID.
  · readLoop discriminates yMessageSync vs yMessageAwareness on
    byte 0. Sync frames are persisted to the op-log AND broadcast;
    awareness frames are broadcast only (presence is ephemeral).
    Persistence happens BEFORE broadcast so a crash mid-publish loses
    at most a live keystroke that the originator will replay on
    reconnect anyway.
  · writeLoop drains the bus subscription and writes non-self events
    to the WS, gated by a per-conn write mutex (gorilla's "one writer
    at a time" rule).
  · removeConn arms a 60s graceTimer when the last conn drops; a
    fresh addConn cancels the timer. onGraceExpired re-checks
    len(conns) == 0 under the room mutex and only THEN sets
    closing=true + calls back to the manager. The race between
    "manager.getOrCreate found us" and "grace timer fired" is
    handled by addConn returning errRoomClosing; the manager retries
    via getOrCreate which mints a fresh Room.

- internal/collab/manager.go — RoomManager + RoomManagerConfig
  · NewRoomManager wires production defaults (DefaultGraceTTL = 60s,
    DefaultSchemaVersion = "1"). NewRoomManagerWithConfig accepts an
    explicit config so tests can drop graceTTL to a few ms without
    sleeping a minute. graceTTL is per-manager, not a package var,
    so parallel tests with different TTLs don't trip the race
    detector.
  · Join is the public entry point: getOrCreate → addConn (with
    retry on errRoomClosing) → replayTo → spawn writeLoop goroutine
    → run readLoop inline → wait for writeLoop drain → return. The
    inline read keeps the HTTP handler in scope so its
    `defer conn.Close()` doesn't fire until both loops exit.
  · Close is for graceful server shutdown — closes every active
    conn under the room mutex, then drains the manager's room map.

- internal/collab/manager_test.go — 7 tests covering: lazy create,
  op-log replay-on-connect (two seed rows arrive in order), sync
  broadcast + persist (peer B sees A's frame, originator does not
  echo, op-log gains a row), awareness broadcast WITHOUT persist,
  cross-item isolation (item-a frames don't leak to item-b
  subscribers), grace-TTL reclaim with a 50ms config TTL, grace
  cancel on reconnect within window, manager.Close shuts down
  every active conn. All tests run with -race; the bus's
  concurrent-publish test was already covered by TASK-1253.

- internal/server/handlers_collab.go — wire to RoomManager
  · Returns 503 when s.collab is nil (matches the SSE handler's
    "events bus not configured" 503 — fail loud rather than silently
    accept the upgrade).
  · Otherwise hands the upgraded conn to s.collab.Join, which
    blocks until the WS closes. Unexpected close codes get the same
    warn-log as before; normal closures stay quiet.

- internal/server/server.go — adds *collab.RoomManager field +
  SetCollabRoomManager setter (nil-safe optional, like SetEventBus).

- cmd/pad/main.go — wires NewMemoryOpBus + NewRoomManager into
  the running server alongside the event-bus wiring. Single-instance
  only today; multi-replica fanout via Redis is a deferred IDEA per
  the Plan body.

- internal/server/handlers_collab_test.go — adds
  testServerWithCollab helper (so existing collab tests get a real
  RoomManager) plus TestCollabUpgradeUnavailableWithoutRoomManager
  which asserts the 503 path for unwired servers.

Parent: PLAN-1248. Phase 1 — Backend foundation.

* fix(collab): per-room appendMu + Server.Stop closes RoomManager per Codex review (round 1)

P1 — concurrent peers raced AppendYjsUpdate, violating the
single-writer-per-item contract documented on the store call. Each
peer's readLoop runs in its own goroutine, so two peers in the same
room could call AppendYjsUpdate concurrently. On Postgres that
risks the BIGSERIAL allocation-vs-commit-order cursor gap that
TASK-1252's contract was specifically guarding against. Add an
appendMu on Room held across the persist+publish sequence; reads,
awareness frames, and OTHER rooms remain unserialised.

Regression test (TestRoomManagerSerializesSyncAppends) drives 4
peers × 10 writes concurrently and asserts the op-log gains exactly
40 rows. Without appendMu this would intermittently surface fewer
rows or out-of-order ids on Postgres; with it the count is
deterministic and the race detector stays clean.

P2 — Server.Stop did not close s.collab. Active collab WS goroutines
+ grace timers could keep using s.store after the server's other
cleanup paths winding down. Add s.collab.Close() before
rateLimiters.Stop so any Join goroutines holding rate-limiter
handles can wind down cleanly. nil-safe via the existing collab
optional-attachment pattern.

* fix(collab): start writer before replay to avoid bus-overflow drops per Codex review (round 2)

P2: a joining peer subscribed to live events BEFORE its writer
goroutine started. During a long replay, live sync events would pile
up in the 64-event bus channel; once full, MemoryOpBus.Publish
silently drops them, leaving the new peer connected but permanently
missing those updates.

Restructure runConn to spawn the writer goroutine FIRST so it drains
the bus subscription concurrently with the replay. Both replay and
writer go through rc.writeMessage, which holds the per-conn write
mutex, so we never violate gorilla's one-writer-at-a-time rule.

Yjs CRDTs are commutative — applying live op 100 before replay op 50
yields the same final Y.Doc as the reverse order — so interleaving
is correct. The trade-off is a brief "out of causal order" UX wobble
during replay, which is acceptable: the alternative would require
either an unbounded queue or losing updates the way the original
order did.

* fix(pad): call srv.Stop() in serveCmd shutdown so collab sessions close per Codex review (round 3)

P2: serveCmd's SIGINT/SIGTERM path called srv.Shutdown but never
srv.Stop. http.Server.Shutdown does NOT terminate hijacked
connections (WebSockets), so active collab sessions kept running
until process exit and could race the deferred store close. The
RoomManager.Close path added in round 1 only fires inside Stop, so
without this call the production shutdown was effectively bypassing
the new cleanup.

Add srv.Stop() after srv.Shutdown in the serveCmd shutdown
sequence. Stop also runs the existing background-loop teardowns
(orphan GC, MCP audit writer, MCP session tracker) which were
previously already part of Stop's contract — those will continue to
fire as they always have, so this commit's only behavioural change
is "now also closes the collab room manager".

* fix(collab): WaitGroup drain barrier + bigger bus buffer per Codex review (round 3)

P1 — RoomManager.Close was not a true drain barrier. closeAll
closed the WebSockets but did NOT wait for the corresponding Join
goroutines (running runConn) to exit. Server.Stop returned before
in-flight collab work finished, racing the deferred store close
on process exit. Fix: track every Join in m.activeJoins
(sync.WaitGroup); Close iterates closeAll first (waking up every
reader by closing the conn), then activeJoins.Wait — guaranteeing
no collab goroutine is still running by the time Close returns.

P2 — replay-time bus overflow could still drop sync events on a
slow drain (writeLoop blocks on the same writeMu replayTo holds,
so a long replay starves the bus drain even with the writer
goroutine started before replay). Two-part response:

(a) Bump the per-subscriber bus channel buffer from 64 to 256.
Sized for a 5x safety margin on a 1k-row replay against a
chatty 5-peer room (~50 events/sec during a ~1s replay).

(b) The architectural fix — force-close subscribers on overflow,
honoring the bus's documented slow-peer recovery contract — is
filed as TASK-1273 follow-up. That requires extending the OpBus
interface (per-subscriber drop callback or counter) and an active
health-check tick in the room manager; both are out of scope for
TASK-1255's "lazy room + grace TTL" deliverable.

For PLAN-1248's single-instance scope and typical editor load,
256 covers realistic workloads. Pathological / load-test scenarios
exposing overflow can recover via Yjs's state-vector negotiation
on reconnect, and TASK-1273 will tighten that to an active kick.

* fix(collab): closed flag gates Join + Close idempotency per Codex review (round 4)

P2: http.Server.Shutdown does NOT wait for hijacked WebSocket
handlers, so a Join() call from a freshly-upgraded conn could fire
AFTER Close() returned. The previous Add-then-Wait pattern was
correct for already-started Joins but couldn't catch a Join that
hadn't yet hit Add when Close fired. Race: Close iterates the (empty)
rooms map, Wait sees zero waiters, Close returns; THEN Join hits
Add and proceeds against a torn-down store.

Add a `closed` flag gated by the same mutex that wraps
activeJoins.Add. Three orderings, all safe:

  1. Add before Close.closed=true → Wait blocks until Done.
  2. Close.closed=true before Add → Join sees closed=true under
     the same lock and returns errManagerClosed without ever
     incrementing the WaitGroup.
  3. Close called twice → second call short-circuits (idempotent).

getOrCreate also gets a closed-flag short-circuit so a future
caller can't bypass the gate by skipping Join.

Test: TestRoomManagerJoinAfterCloseFailsFast asserts post-Close
Join returns errManagerClosed, plus a second Close() is a no-op.

All 15 collab tests pass under -race.
2026-05-08 15:38:45 -04:00
xarmian 2945ee27dd feat(server): add WebSocket handler at /api/v1/collab/{itemID} (TASK-1254) (#452)
* feat(server): add WebSocket handler at /api/v1/collab/{itemID} (TASK-1254)

WebSocket entry point for Yjs-based collaborative editing on a
single item under PLAN-1248. Bare-bones in this PR by design:
upgrade + log connect/disconnect + drain reads. Protocol logic
(forwarding to OpBus, persisting to op-log, awareness fan-out)
arrives in TASK-1255 (room manager).

Authorisation mirrors RequireWorkspaceAccess but keyed on the
item's workspace ID rather than a {slug} URL param — the WS URL
only carries itemID. Implementation re-uses the same access
ladder:

  fresh-install escape hatch (no users)
    → grant
  legacy workspace-scoped API token, no user
    → grant if token's workspace matches the item's workspace
  OAuth token allow-list (TASK-953)
    → reject when workspace not on consented list
  authenticated user
    → admin OR member OR has guest grants

User is re-fetched from the store on each upgrade (not trusted
from session-context cache) so a mid-session admin demotion or
member removal closes the upgrade path immediately. Mirrors
sseSubscriberStillHasAccess. Periodic per-connection
revalidation lives in TASK-1256.

Route registered alongside SSE (outside the jsonContentType
middleware group, but inside the auth middleware chain). Promotes
github.com/gorilla/websocket from indirect to direct dep and
bumps to v1.5.3 (latest stable; v1.5.0 was already in
go.mod transitively via another package).

Tests cover:
- fresh-install escape hatch grants the upgrade
- bootstrapped server rejects unauthenticated upgrade with 401
- non-member with valid session is rejected with 403
  (NOT 401 — confirms the access path runs after auth, not before)
- unknown item surfaces as 404 (not 401/403 leak)
- empty itemID segment doesn't match the route

Test infrastructure note: dialCollab takes an explicit User-Agent
because pad's session-binding middleware hashes the UA at
CreateSession time and re-checks on every request — the dialer
must match what was stored, otherwise the cookie is rejected
before the workspace check fires (and we'd see a misleading 401
where 403 was expected).

Parent: PLAN-1248. Phase 1 — Backend foundation.

* style: gofmt handlers_collab_test.go per Codex review (round 1)

* fix(server): SetReadLimit + nginx upgrade headers for collab WS per Codex review (round 2)

P-MEDIUM #1: handleCollab.ReadMessage had no per-message size cap, so an
authenticated client could send an arbitrarily large frame and force
unbounded server-side buffering — the HTTP body limit applied by the
auth chain doesn't apply once the connection is upgraded. Set
SetReadLimit(1 MiB), generous for keystroke-rate Yjs ops and large
enough for a typical initial-sync state. ReadMessage returns an error
when exceeded, which the existing read loop handles as a normal close.

P-MEDIUM #2: deploy/nginx.conf routed /api/v1/collab/ through the
default `location /` block, which sets `Connection ""` (cleared so HTTP
keepalive works) — that strips the Upgrade header, so WebSocket
upgrades silently fail behind the documented nginx deployment. Add a
dedicated location block with proxy_set_header Upgrade $http_upgrade /
Connection "upgrade", same 24h read/send timeouts as SSE so an idle
editor tab does not get cut off mid-session.

* fix(server): enforce per-item visibility in collab WS upgrade per Codex review (round 3)

P2: authorizeCollabAccess granted upgrade to any workspace member or
guest-with-grants without checking whether THIS specific item was
visible to that user. A restricted member (collection_access=specific)
or a guest with grants on item A could upgrade /api/v1/collab/{itemID}
for an item B in a different collection — they'd see live edits to a
document they have no right to read.

Restructure the access ladder:

1. Workspace-level gate stays as-is: "any access at all?" If no
   membership AND no grants → 403 (unchanged).
2. Item-level visibility check added on top, mirroring requireItemVisible
   without depending on middleware-set request context (the WS path
   doesn't go through RequireWorkspaceAccess):
     - VisibleCollectionIDs nil → "all" access → grant.
     - Item's collection in the visible set → grant.
     - Item-level grant on this exact item → grant (covers guests
       given access to a single item rather than a whole collection).
     - Else → 404, mirroring requireItemVisible's "don't leak
       existence" pattern.

Admin path returns nil before this check, so no change there.
Legacy workspace-scoped API tokens grant editor-equivalent access
on workspace match (predates the grants design); that branch is
untouched since legacy tokens don't have a user identity to scope
per-item grants against.

Test added: TestCollabUpgradeRejectsRestrictedMemberForeignCollection
— member with specific access to collA tries to upgrade for an item
in collB → 404. Existing 5 tests still pass.

* fix(server): strict per-item visibility check + sibling-grant test per Codex review (round 4)

P1 (round 4): VisibleCollectionIDs is broader than full-collection
access — it includes collections "anchored" by an item-level grant
(so the nav can still surface the parent collection of a granted
item). Round 3's check treated every visible collection as full
access; a guest with grant `item:A` could upgrade
/api/v1/collab/{B} for a sibling B in the same collection.

Tighten by mirroring guestResourceFilter / requireItemVisible:

  1. Coarse stage stays — collection must be in the visible set.
  2. NEW strict stage when the user has item-level grants:
     (a) full collection grant on this collection → grant
     (b) member's "specific" access list including this collection
         → grant
     (c) item grant on THIS exact item → grant
     Else → 404 (the visible-set hit was anchored by a sibling's
     grant, not by full collection access).

When the user has NO item grants, the coarse-only check is
sufficient — visibility came from full collection access (member's
"specific" list, full collection grant, or "all" access).

Test added: TestCollabUpgradeRejectsGuestWithSiblingItemGrantOnly
— guest with item:A grant tries to upgrade for sibling B in the
same collection → 404 (the bug being regression-tested) AND verifies
the granted item A still upgrades cleanly to 101 Switching Protocols.
2026-05-08 14:36:47 -04:00
xarmian 264f5b0041 feat(collab): add OpBus interface + in-process MemoryOpBus (TASK-1253) (#451)
* feat(collab): add OpBus interface + in-process MemoryOpBus (TASK-1253)

New internal/collab package for the dumb-relay collab server in
PLAN-1248. Defines the OpBus pub/sub interface and ships
MemoryOpBus, the in-process implementation used by every shipping
target today (single-binary self-host, single-replica pad-cloud).

OpBus shape mirrors internal/events.MemoryBus so a future RedisOpBus
is a drop-in for multi-replica deployments — that's filed as a
separate IDEA at PLAN-1248 close, since the dumb-relay design
intentionally keeps Redis off the self-host dependency surface.

OpEvent carries:
- ItemID    fan-out filter
- ClientID  Yjs client id, used by designated-applier election
            (TASK-1257); the bus itself does not interpret it
- Type      "sync" (Y.Doc binary update — persisted by the room
            manager) or "awareness" (cursor/presence — broadcast
            only, never persisted)
- Data      raw y-protocol message; opaque to the server
- Timestamp UnixMilli, auto-stamped on Publish

MemoryOpBus semantics:
- 64-event buffered subscriber channels (matches internal/events
  default — sized against keystroke-rate workload).
- Non-blocking Publish: a slow subscriber whose channel is full has
  events DROPPED with a warn log rather than back-pressuring the
  broadcast loop. The room manager (TASK-1255) is responsible for
  closing genuinely unhealthy peers; the bus only protects itself.
- Idempotent Unsubscribe (no panic on double-unsubscribe).
- Close clears the subscriber map and closes every channel under
  the same write lock that gates Publish, so a final inflight
  Publish can't race a Close into delivering on a closed channel.

Tests cover: subscribe/publish fan-out per item filter, unsubscribe
closes the channel, slow-consumer drop without blocking, accurate
SubscriberCount across subscribe/unsubscribe, Close cleans up every
channel regardless of itemID, and concurrent-publishers race-clean
under -race (small drop count tolerated — that's the slow-consumer
contract; an exact-delivery test would defeat its own purpose).

No external dependencies beyond stdlib + slog. RedisOpBus stub is
intentionally NOT included — separate IDEA per the Plan body.

Parent: PLAN-1248. Phase 1 — Backend foundation.

* fix(collab): clone OpEvent.Data + document recovery contract per Codex review (round 1)

P1 — Sync-op drops were undocumented as recoverable. Sync drops ARE
recoverable in the dumb-relay design: the room manager (TASK-1255)
appends to the op-log BEFORE Publish, so any peer that misses a sync
op via channel-full drop can replay since their last cursor on
reconnect (TASK-1252's LoadYjsUpdatesSince + Yjs state-vector
negotiation). The room manager is responsible for detecting slow
channels and force-closing the owning WebSocket, which kicks the
peer into a fresh reconnect + replay. The bus does not take that
action itself because it has no concept of which peer owns which
channel — that mapping is the room manager's domain. Doc comment now
spells this out explicitly.

P2 — OpEvent.Data is a []byte; the same slice header was queued to
every subscriber, so a publisher's later buffer reuse OR any
subscriber's mutation could corrupt the bytes other subscribers
observe. gorilla/websocket's ReadMessage is allowed to reuse its
read buffer between messages, so this hazard is real for the
production publisher (the WS handler in TASK-1254). Clone Data once
at the publish boundary; document the per-receiver immutability
expectation in the same comment block.

No behavioral test change — the existing slow-consumer-drop test
still passes; the clone path adds one allocation per Publish but
nothing observable to callers beyond the immutability guarantee.
2026-05-08 13:56:21 -04:00
xarmian 04514817ae feat(store): add Yjs op-log table + store methods (TASK-1252) (#450)
* feat(store): add Yjs op-log table + store methods (TASK-1252)

Persistence groundwork for the dumb-relay WebSocket server in PLAN-1248.
The item_yjs_updates table records every Yjs binary update (browser
edits, future designated-applier conversions of CLI/API content
changes) so reconnecting peers can replay updates since their last
known cursor and cold rooms can rebuild their in-memory Y.Doc.

Schema (mirrored across SQLite + Postgres):
- id              monotonic — INTEGER PRIMARY KEY AUTOINCREMENT (SQLite)
                   / BIGSERIAL (Postgres). Never reused, even after
                   deletes; serves as the cursor every reconnecting
                   client compares against.
- item_id         FK with ON DELETE CASCADE so item deletion reclaims
                   op-log space automatically.
- update_data     raw Yjs binary update — BLOB / BYTEA. Opaque to the
                   server.
- schema_version  stamped per row. Mismatch on connect drives
                   TASK-1268's snapshot-and-rebuild flow.
- created_at      ISO8601 UTC TEXT, matching pad's cross-dialect
                   timestamp convention (see migrations/047_attachments).
                   Drives PruneYjsUpdatesBefore.

Store API (internal/store/yjs_updates.go):
- AppendYjsUpdate — validates non-empty itemID/data/schemaVersion,
   inserts and returns the new monotonic id (RETURNING on Postgres,
   LastInsertId on SQLite). Empty-zero-byte updates are rejected at
   the Go layer rather than relying on NOT NULL — they're a no-op
   that would only pollute the log.
- LoadYjsUpdatesSince — strict id > sinceID filter, ordered by id
   ascending. sinceID=0 returns everything (cold-room rebuild path).
   Tolerates either RFC3339 or "YYYY-MM-DD HH:MM:SS" timestamp formats
   on read so any future operator-written / CURRENT_TIMESTAMP-style row
   doesn't blow up the load path.
- PruneYjsUpdatesBefore — created_at < cutoff, scoped to itemID.
   Returns rows-affected count. Used by the eventual GC sweeper
   (out of scope for this task).

Tests cover: append + monotonic ids, load-since-cursor filtering,
input validation, prune scoped to itemID, and ON DELETE CASCADE on
parent item removal. Pass on SQLite locally; Postgres mirror migration
+ store methods are dialect-agnostic.

Parent: PLAN-1248. First task of Phase 1 — Backend foundation.

* docs(store): document AppendYjsUpdate per-item serialization contract per Codex review (round 1)

P1: Postgres BIGSERIAL ids are allocation-ordered, not commit-order.
Concurrent appends to the same item could in theory produce a cursor
gap — a slower transaction can hold a smaller id while a faster one
commits a larger id first, and a reader that advances past the visible
larger id would later miss the smaller id when it commits.

The dumb-relay room manager (TASK-1255) is the sole writer per item by
design — there's exactly one goroutine appending per Y.Doc — so the
hazard does not manifest in practice. The fix is at the API contract
level: the doc comment now spells out the serialization requirement,
why the room manager satisfies it, and the multi-replica re-enforcement
note for the future Redis-fanout IDEA. We do not take an internal
advisory lock because that would be paid by every append even though
the caller already holds the per-room mutex.

No code change — contract is at the doc comment.
2026-05-08 13:28:55 -04:00
xarmian 00d529d325 fix(editor): add NodeView update() hook to AttachmentChip (TASK-1251) (#449)
* fix(editor): add NodeView update() hook to AttachmentChip (TASK-1251)

Audit confirmed the same gap pattern as BUG-1246 (mermaid) /
TASK-1250 (attachment-image): closure-captured uuid + filename, no
update() hook, so any attr change forces NodeView destroy+recreate.
No in-tab attr-change source today (no rotate/crop on chips), but
the upcoming Yjs collab work makes peer-driven uuid/filename swaps
the common case — destroying the NodeView on every peer keystroke
of a rename would jump the cursor and flicker the chip.

Refactor closure state to be mutable, add update() that:
- Returns false on type mismatch (defensive — should never fire for
  an attachmentChip).
- On uuid change: refresh href + data-attachment-id, reset MIME +
  size pending re-probe, re-fire the HEAD metadata fetch (with
  in-flight guard so a stale probe doesn't trample fresher state).
- On filename change: refresh data-filename, download attribute,
  visible name, and (only when MIME unknown) re-derive the
  filename-extension fallback icon.

Click handler now reads currentUuid (mutable) so a peer-swapped
chip target opens the new attachment, not the original.

Parent: PLAN-1248. Phase 0 — Production prerequisites complete.

* fix(editor): always refresh chip icon on rename per Codex review (round 1)

P3: refreshIcon() falls back to the filename-extension heuristic
whenever iconForMime() returns empty — both MIME-unknown and
MIME-known-but-unmapped (e.g. application/octet-stream) hit the same
fallback path. The previous `if (!currentMime) refreshIcon()` was too
narrow; renaming foo.csv → foo.pdf after a generic MIME resolved would
leave the stale .csv icon until the NodeView was recreated.

Drop the conditional. refreshIcon() is idempotent for MIMEs that DO
map to a specific icon (just rewrites the same emoji), so calling it
on every filename change is correct and trivially cheap.
2026-05-08 13:13:25 -04:00
xarmian 530a2c05ba fix(editor): add NodeView update() hook to AttachmentImage (TASK-1250) (#448)
* fix(editor): add NodeView update() hook to AttachmentImage (TASK-1250)

Same hazard pattern as BUG-1246 (mermaid) — the NodeView had no update()
hook, so any attribute change forced ProseMirror to destroy + recreate
the NodeView. Single-user rotate worked via that destroy + recreate path
(invisible flicker + cursor jump), but under upcoming Yjs collab a
remote peer's rotate would constantly remount the local image element.

Add the hook and refactor closure state so attr changes refresh the
live <img> in place:

- update(updatedNode) returns false on type mismatch (defensive — should
  never fire for an attachmentImage), true otherwise.
- uuid + alt promoted from const to let (currentUuid / currentAlt) so
  event handlers (click → lightbox, rotate, crop) read the latest value.
- On uuid change: invalidate metadata cache for the OLD uuid, swap
  img.src + data-attachment-id, reset toolbar MIME gating, re-probe
  metadata for the NEW uuid (with a guard so a probe in flight doesn't
  trample a fresh swap).
- On alt change: refresh img.alt.
- swapNodeUuid loses its trailing invalidateAttachmentMetadata call —
  that lives in update() now so it fires regardless of source (local
  rotate, peer Yjs op, ...).

Verifies single-user rotate still works AND lays groundwork for
zero-flicker peer rotate under Phase 2 Yjs collab.

Parent: PLAN-1248.

* fix(editor): read live node attrs in swapNodeUuid per Codex review (round 1)

P-LOW: with update() now keeping the AttachmentImage NodeView alive
across attr changes, the closure-captured `node` is no longer guaranteed
to reflect the document. Spreading its stale attrs in setNodeMarkup
would clobber any concurrent edit to a non-uuid attr — e.g. a peer
changes alt text, then a local rotate dispatches with the original alt
and overwrites the peer's update.

Resolve by reading node attrs from editor.state.doc.nodeAt(pos) at the
moment of dispatch, so we always merge the new uuid onto the freshest
known attrs. Bail if nodeAt returns null (edge: pos no longer points at
this node — e.g. node was deleted in the same tick).

* fix(editor): snapshot uuid in runRotate/runCrop per Codex review (round 2)

P2: with the NodeView now surviving attr changes, currentUuid (mutable
closure) can drift during the await on opts.transform() / openCropModal
— a peer rotating or cropping the same image mid-flight would shift
currentUuid out from under us. Crop is the worst case: the rect was
chosen against the original image, but applying it to whatever uuid is
live at completion time would crop the wrong image.

Resolve by snapshotting currentUuid (and currentAlt for the crop modal)
at the entry of each async handler, threading the snapshot through the
transform call, and bailing if currentUuid drifted during any await.

Same hazard surface in both functions; same fix shape applied to both.
2026-05-08 13:05:20 -04:00
xarmian 1ef7a21de2 fix(editor): add NodeView update() hook to MermaidCodeBlock (TASK-1249) (#447)
* fix(editor): add NodeView update() hook to MermaidCodeBlock (TASK-1249)

Mermaid diagrams previously froze on the SVG generated when the NodeView
was first created. ProseMirror only recreates a NodeView on node identity
change; in-place text edits don't trigger that, and the existing factory
had no `update()` hook to re-queue a render — so editing the source via
the hover-revealed `< >` toggle silently mutated the code while the
diagram showed stale output. Resolves BUG-1246.

Implementation matches the pattern verified in the TASK-1245 spike
(iteration 3, dev sandbox at /dev/yjs-sandbox) but with precise
ProseMirror Node typing instead of `any`:

- update(updatedNode) returns false on type mismatch or when the
  language attr flips into/out of `mermaid` — different DOM shape, so
  ProseMirror must tear down + recreate the NodeView.
- Returns true (in-place update accepted) when same-node + same-lang;
  re-queues queueMermaidRender only when textContent actually changed.
- Empty source clears the diagram element and the mermaid-error class.
- Toggle state survives because we don't recreate the wrapper.

Becomes a hard blocker once Yjs collab lands (PLAN-1248) since remote
ops will constantly mutate mermaid source text mid-view.

Parent: PLAN-1248.

* fix(editor): serialize mermaid clear + drop error class on success per Codex review (round 1)

Two issues raised in PR #447 review:

P2 — Pending queueMermaidRender() could overwrite a synchronous diagram
clear with a stale SVG, racing against a freshly-emptied source. Route
the clear through the same renderQueue (queueMermaidClear) so it executes
strictly after any in-flight render for the same target.

P3 — A valid re-render after an invalid mermaid edit kept the
.mermaid-error class on the diagram element. Drop the class in
queueMermaidRender's success path now that successful render means the
source compiled.

Both fixes preserve TASK-1249's NodeView update() contract; no other
behavior changed.
2026-05-08 12:51:02 -04:00
xarmian 07e47eba57 fix(web): subscribe item detail page to live SSE updates (TASK-1243) (#446)
* fix(web): subscribe item detail page to live SSE updates (TASK-1243)

The item detail page (+page.svelte under [collection]/[slug]) never
called sseService.onItemEvent, so live changes to the parent item's
title / fields / archive state didn't propagate from the server
until a manual refresh. Comments, reactions, timeline events, and
child-item updates all worked because their respective child
components (CommentThread, ItemTimeline, ChildItems) carry their
own SSE subscriptions — the gap was only on the parent item itself.

Discovered while manually verifying TASK-1242 (callback inversion):
two tabs on the same item showed divergent state until refresh.

Fix mirrors the existing onSync handler that lives a few lines below:

  • Subscribe to sseService.onItemEvent in onMount, store the
    unsubscriber alongside unsubscribeSync / unsubscribeBeforePrint
  • Filter to event.item_id === item.id so cross-item navigation
    inside the same component instance is handled cleanly (the
    handler reads the *current* $state value of `item` on each fire,
    no stale-closure bug)
  • Same edit-conflict guards (saveStatus === 'saving' || editingTitle)
    so SSE pushes don't clobber in-flight edits
  • Same content-preservation pattern (`content: item.content`) — the
    Tiptap editor owns the document state; replacing item.content
    while it's mounted would clobber the user's local edits. Title,
    fields, and metadata propagate; content stays put. This is a
    deliberate conservative behavior identical to onSync's behavior
    for the same reason.
  • Handle item_archived (bounce back to collection list, matches
    onSync's deletion path) and item_restored
  • Tear down in onDestroy alongside the existing unsubscribers

Lifecycle: SvelteKit reuses the +page.svelte component instance when
navigating between items in the same collection, so onMount runs
once per route entry and onDestroy runs once per route exit. The
single subscription handles cross-item navigation correctly via the
`event.item_id !== item.id` filter; closure reads of $state /
$derived values pick up the new item on each event fire. No
re-subscription per navigation needed (collection page does that
because its closure captures wsSlug/collSlug as `const`s — different
pattern, same correctness).

KNOWN LIMITATION: live content sync is intentionally NOT handled.
For Pad's current "snapshot save on debounce" model, replacing the
editor's mounted document mid-edit would either drop user keystrokes
or fight the editor's internal state machine. A proper fix needs
either editor-dirty-state integration (acceptable for the "I'm
editing my own doc on two tabs" case) or a true CRDT-based collab-
edit refactor (Yjs + @tiptap/extension-collaboration) for the
"two users typing simultaneously" case. The CRDT direction is the
forward-looking plan; tracked separately.

* fix(web): guard SSE/sync handlers against stale-resolution race

Per Codex review on PR #446. The SSE handler I added in the previous
commit checks `event.item_id === item.id` *before* `await
api.items.get(...)`, but assigns to `item` *after* the resolution
without re-checking. If the user navigates to a different item while
the fetch is in flight, the resolved old-item data clobbers the
newly-loaded current item.

Same latent bug exists in the existing `onSync` handler's full-
refresh path, and Codex correctly flagged it as the same shape. The
loadData() function already guards against this same race in its
catch-block (TASK-754 round-2 race guard, see comment at line 224).

Fix mirrors that established pattern:
  • Capture `item.id`, `wsSlug`, `itemSlug` into `reqItemId` /
    `reqWsSlug` / `reqItemSlug` before the first await
  • After every await (api.items.get, api.links.list), bail with
    `if (!item || item.id !== reqItemId) return` before assigning
  • Use the captured values for the requests themselves so cross-
    navigation can't change which item we're fetching mid-flight

Applied to:
  • SSE handler — item_updated and item_restored cases (item_archived
    has no await, so it's already safe)
  • onSync handler — incremental path (api.links.list was unguarded)
    and full-refresh path (api.items.get was unguarded)

* fix(web): exempt destructive events from edit-conflict guard

Per Codex review round 2 on PR #446. The edit-conflict guard
(saveStatus === 'saving' || editingTitle) was placed BEFORE the
event-type switch, which meant `item_archived` (SSE) and the
`changes.deleted` branch (onSync) were also gated. Result: if
another client archived the item while the user was editing,
the destructive event was dropped and the user kept editing a
non-existent item until the next event or tab-resume sync.

Fix: hoist the destructive cases above the edit-conflict guard.
The user's in-flight save will fail against the archived row
anyway, so silently keeping them on a deleted item is strictly
worse than discarding the edit and bouncing them to the
collection list.

Applied to both handlers:
  • SSE: `item_archived` runs before `saveStatus`/`editingTitle`
    guard, exits early after `goto()`
  • onSync: `result.changes.deleted.includes(item.id)` checked
    before the guard for the same reason
2026-05-08 08:47:41 -04:00
xarmian 578494dc43 fix(web): break sse↔sync circular dep via callback inversion (TASK-1242) (#445)
Rolldown's stricter import diagnostics (introduced in TASK-1238 via
Vite 8) flagged that `sync.svelte.ts` was both statically imported
from 5 routes/components AND dynamically imported from `sse.svelte.ts`.
Rolldown's warning:

  [INEFFECTIVE_DYNAMIC_IMPORT] sync.svelte.ts is dynamically imported
  by sse.svelte.ts but also statically imported by [5 files], dynamic
  import will not move module into another chunk.

The original task body's first-cut fix ("convert the dynamic import
to static") was wrong: the dynamic import wasn't there for code-
splitting — the inline comment said "to avoid circular dependency",
and indeed sync.svelte.ts statically imports `sseService`, so a
reverse static import would close the cycle.

Real fix: callback inversion. Mirror the existing `onItemEvent`
pattern by adding `onSyncRequired(callback)` to sseService. Have
syncService subscribe in its `init()` instead of sseService calling
syncService directly. Net result:

  Before: sse  →(dynamic import)→ sync   ──╮
          sync ─(static import)─→ sse  ←──╯  (circular, papered over)

  After:  sse exposes onSyncRequired()
          sync subscribes on init(), receives sync_required pings
          sse has zero imports of sync.svelte.ts (static or dynamic)

Behavior is identical:
  • sse_required server event still triggers `syncService.triggerSync()`
  • sync.svelte.ts still owns the sync coordination decision tree
  • Init order is fine — both modules are evaluated as singletons at
    module-load time; subscription happens during syncService.init()
    which workspace +layout.svelte calls in onMount, well after both
    modules have settled

Verified:
  • `npm run build` — INEFFECTIVE_DYNAMIC_IMPORT warning is gone;
    Rolldown bundle build went from 5.91s → 2.90s as a bonus
  • `make check` — golangci-lint + go test + npm run build +
    svelte-check, 0 errors, same 6 pre-existing warnings
  • Manual UI verify — SSE still works (collection page real-time
    updates, child item progress, comments/reactions, timeline)

Spawned [[TASK-1243]] for a separate pre-existing bug surfaced during
this manual verify: the item DETAIL page never subscribed to
sseService.onItemEvent, so live title/field updates from other clients
don't propagate until manual refresh. Out of scope for this PR.
2026-05-08 08:15:44 -04:00
xarmian 1dabfd02ae chore(web)(deps): bump vite-plugin-svelte 6 → 7 + vite 7 → 8 (TASK-1238) (#444)
Coordinated bump of the Vite/Svelte build-tool stack:
  • @sveltejs/vite-plugin-svelte  6.2.4 → 7.1.2
  • vite                          7.3.1 → 8.0.11
  • @sveltejs/kit                 2.59.0 → 2.59.1 (patch, free)
  • @sveltejs/adapter-auto        7.0.0  → 7.0.1  (patch, free)

Smaller cascade than the deferred-bumps task body anticipated:
SvelteKit 2.59 already declared `^8.0.0` in its vite peer-dep range, so
no Kit major bump was needed. adapter-static is unaffected. svelte
itself (5.55.5) already meets the new vite-plugin-svelte v7 peer
constraint of ^5.46.4.

vite-plugin-svelte v7 integrated the inspector into the main package,
so the @sveltejs/vite-plugin-svelte-inspector subdep is gone — net 5
fewer packages in node_modules and a ~7KB smaller package-lock.json.

Vite 8 highlights:
  • Rolldown replaces Rollup as the bundler — production build dropped
    from ~12-16s to ~5.9s on this codebase
  • Internally compiled with TypeScript 6 (matches our own TS 6 bump
    from TASK-1236)
  • npm audit moderate vulnerability count: 1 → 0 (the uuid advisory
    was in a transitive that's no longer needed)

Configs reviewed:
  • vite.config.ts is minimal (just sveltekit() plugin + dev proxy);
    none of v7's removed options (vitePlugin.hot,
    vitePlugin.ignorePluginPreprocessors, api.idFilter,
    plugin.api.sveltePreprocess) are in use.
  • svelte.config.js uses vitePlugin.dynamicCompileOptions (still
    supported in v7).

Verified:
  • Clean reinstall (rm -rf node_modules package-lock.json && npm i):
    290 packages, 0 vulnerabilities
  • npm run build: succeeds in 5.91s, output passes through
    adapter-static
  • make check (golangci-lint + go test + npm run build + svelte-check):
    0 errors, same 6 pre-existing warnings
  • Manual UI verify: dashboard, item list, item detail, editor (block
    drag-handle, content edit/save), role board, share page all render
    and function correctly

Rolldown surfaced one informational warning that's PRE-EXISTING in our
code, not introduced by the bump:
  [INEFFECTIVE_DYNAMIC_IMPORT] sync.svelte.ts is both static- and
  dynamic-imported. Filed as a follow-up task — fix is out of scope
  for this PR.

Closes dependabot/npm_and_yarn/web/sveltejs/vite-plugin-svelte-7.0.0
(PR #215). Closes PLAN-1240 Tier 3 (5/5 ships).
2026-05-08 07:52:15 -04:00
xarmian e380b4e660 chore(deps): bump Node 22 → 24 (Dockerfile + CI workflows) (TASK-1235) (#443)
Bumps all four Node version pins from "22" to "24" together so CI ↔
production stay aligned:
  • Dockerfile (production image): node:22-alpine → node:24-alpine
  • .github/workflows/ci.yml — Web job + E2E job
  • .github/workflows/release.yml — release pipeline

Going to LTS-bound 24 instead of dependabot's proposed 25-alpine,
which hits EOL on 2026-06-01 (~3 weeks from this commit). Going to
24 instead of waiting for 26-LTS (Oct 2026) because 5 months is too
long to sit on the deferred-bumps backlog and 24 is already a year
into LTS-tested production use. Worst case follow-up is one more
trivial Dockerfile bump in October.

Verified:
  • Local `docker build --target web-builder` on node:24-alpine
    (npm ci + npm run build) — clean, 24s end-to-end
  • `make check` — golangci-lint + go test + npm run build +
    svelte-check, 0 errors

Closes dependabot/docker/node-25-alpine (PR #209) — closing rather
than rebasing because we're going to 24, not 25.
2026-05-08 07:32:43 -04:00
xarmian 1ac3f76478 chore(web)(deps): bump typescript 5.9.3 → 6.0.3 (TASK-1236) (#442)
The compiler bump itself is clean — svelte-check (TS 5.9 baseline) and
svelte-check (TS 6.0.3) both report 0 errors against the same 714-file
codebase. Confirms TypeScript 6's stricter inference doesn't surface
new errors in our Svelte 5 / SvelteKit / TipTap stack.

`tsc --noEmit` directly (which traverses standalone .ts files differently
than svelte-check) flagged 9 errors in one file — block-drag-handle.ts
calls TipTap chained commands (setParagraph, setHeading, toggleBulletList,
toggleOrderedList, toggleTaskList, toggleCodeBlock, toggleBlockquote)
that are added to `ChainedCommands` via TypeScript module augmentation
in the respective extension packages. TS 6 stopped propagating those
augmentations to files that don't import the augmenting modules
themselves, so each consumer must opt in.

Fix: add side-effect imports of `@tiptap/starter-kit` and
`@tiptap/extension-task-list` at the top of block-drag-handle.ts. The
modules are already loaded at runtime by Editor.svelte, so this adds
nothing to the bundle — it just re-registers the type augmentations
in this file's compilation context.

Verified:
  • Manual: hover the drag handle, open the block context menu,
    "Turn into" each block type (paragraph / H1-H3 / bullet / ordered /
    task / code / quote), drag-reorder. All commands fire correctly.
  • `make check` clean (golangci-lint + go test + npm run build +
    svelte-check, 0 errors).
  • Both `tsc --noEmit` and `svelte-check` return 0 errors after fix.

Closes dependabot/npm_and_yarn/web/typescript-6.0.3 (PR #213).
2026-05-08 06:41:04 -04:00
xarmian 3ec3d6ec17 chore(web)(deps): bump marked 17.0.5 → 18.0.3 + drop @types/marked (TASK-1237) (#441)
v18.0.0 breaking changes are limited to (1) trimming trailing blank
lines from block tokens and (2) bumping the bundled-types compiler to
TypeScript 6. Our app calls `marked(content)` and overrides
`renderer.link` / `renderer.image` — neither path introspects the
intermediate token tree, and svelte-check (TS 5.9) consumes the
TS 6-emitted .d.ts cleanly.

Verified:
  • Render snapshot of representative content (headings, code blocks,
    tables, lists, task lists, blockquotes, links, images, autolinks,
    strikethrough, multi-paragraph, escaped HTML) is byte-identical
    between v17.0.5 and v18.0.3 — same 1847 bytes, zero diff lines.
  • `make check` (golangci-lint + go test + npm run build + svelte-check)
    passes with 0 errors.
  • Manual UI spot-check: item body, wiki-links, code blocks, tables,
    attachments, and the share-page route all render correctly.

Also drops @types/marked@5.0.2 — marked has shipped its own bundled
.d.ts since v9, so this devDep has been redundant and 12 majors stale.
Removing it eliminates type-resolution drift against the bundled types.

Mermaid retains its nested marked@16.4.2 — no transitive ripple. Closes
dependabot/npm_and_yarn/web/marked-18.0.2 (PR #212).
2026-05-08 06:14:52 -04:00
xarmian a1e7378d8e chore(web)(deps): bump diff 8.0.3 → 9.0.0 (TASK-1239) (#440)
v9.0.0 breaking changes are confined to patch parse/format functions
(parsePatch, formatPatch, reversePatch, StructuredPatch). The only
in-tree consumer is web/src/lib/components/versions/DiffView.svelte,
which uses diffLines + the Change type — both unchanged in v9.

ES5 support is dropped, which is irrelevant for our Vite/SvelteKit
target. Verified via runtime smoke-test that diffLines() in v9 returns
the same Change shape (value: string, added/removed: boolean) the
DiffView consumer expects.

Closes dependabot/npm_and_yarn/web/diff-9.0.0 (PR #214).
2026-05-08 05:32:56 -04:00
dependabot[bot] 5026aa529d chore(ci)(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0 (#207)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.0.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:53:20 -04:00
dependabot[bot] b780c2a1fe chore(ci)(deps): bump docker/login-action from 3.7.0 to 4.1.0 (#206)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...4907a6ddec9925e35a0a9e82d7399ccc52663121)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:52:14 -04:00
dependabot[bot] 90dcc3d4ce chore(ci)(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#204)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:51:15 -04:00
dependabot[bot] 8b9a4d63d5 chore(ci)(deps): bump actions/setup-go from 5.6.0 to 6.4.0 (#203)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5.6.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/40f1582b2485089dde7abd97c1529aa768e1baff...4a3601121dd01d1626a1e23e37211e3254c1c06c)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:50:08 -04:00
dependabot[bot] b1f68818f1 chore(ci)(deps): bump goreleaser/goreleaser-action from 6.4.0 to 7.2.1 (#268)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6.4.0 to 7.2.1.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/e435ccd777264be153ace6237001ef4d979d3a7a...1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:47:25 -04:00
dependabot[bot] 1c86165b77 chore(docker)(deps): bump alpine (#248)
Bumps the docker-minor-and-patch group with 1 update in the / directory: alpine.


Updates `alpine` from 3.21 to 3.23

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.23'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: docker-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:45:40 -04:00
dependabot[bot] 06f8487d6b chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 16 updates (#412)
Bumps the npm-minor-and-patch group with 15 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.20.4` | `3.22.5` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.20.4` | `3.22.5` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.20.4` | `3.22.5` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.1` | `3.4.2` |
| [mermaid](https://github.com/mermaid-js/mermaid) | `11.13.0` | `11.14.0` |
| [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) | `2.57.1` | `2.59.0` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.54.1` | `5.55.5` |
| [svelte-check](https://github.com/sveltejs/language-tools) | `4.4.5` | `4.4.7` |



Updates `@tiptap/core` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-link` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-task-list)

Updates `@tiptap/pm` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/pm)

Updates `@tiptap/starter-kit` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/suggestion)

Updates `dompurify` from 3.4.1 to 3.4.2
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.1...3.4.2)

Updates `mermaid` from 11.13.0 to 11.14.0
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.13.0...mermaid@11.14.0)

Updates `@sveltejs/kit` from 2.57.1 to 2.59.0
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.59.0/packages/kit)

Updates `svelte` from 5.54.1 to 5.55.5
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.5/packages/svelte)

Updates `svelte-check` from 4.4.5 to 4.4.7
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.4.5...svelte-check@4.4.7)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: mermaid
  dependency-version: 11.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.55.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-check
  dependency-version: 4.4.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:44:48 -04:00
dependabot[bot] 77dffac945 chore(deps)(deps): bump the go-minor-and-patch group across 1 directory with 5 updates (#439)
Bumps the go-minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) | `5.9.1` | `5.9.2` |
| [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) | `0.50.0` | `0.52.0` |
| [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) | `9.18.0` | `9.19.0` |
| [github.com/spf13/pflag](https://github.com/spf13/pflag) | `1.0.9` | `1.0.10` |
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | `1.47.0` | `1.50.0` |



Updates `github.com/jackc/pgx/v5` from 5.9.1 to 5.9.2
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.9.1...v5.9.2)

Updates `github.com/mark3labs/mcp-go` from 0.50.0 to 0.52.0
- [Release notes](https://github.com/mark3labs/mcp-go/releases)
- [Commits](https://github.com/mark3labs/mcp-go/compare/v0.50.0...v0.52.0)

Updates `github.com/redis/go-redis/v9` from 9.18.0 to 9.19.0
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.18.0...v9.19.0)

Updates `github.com/spf13/pflag` from 1.0.9 to 1.0.10
- [Release notes](https://github.com/spf13/pflag/releases)
- [Commits](https://github.com/spf13/pflag/compare/v1.0.9...v1.0.10)

Updates `modernc.org/sqlite` from 1.47.0 to 1.50.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.47.0...v1.50.0)

---
updated-dependencies:
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: github.com/mark3labs/mcp-go
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/spf13/pflag
  dependency-version: 1.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:42:02 -04:00
xarmian 14e3e41185 chore: bump go to 1.26.3 + golang.org/x/net to v0.53.0 (TASK-1232) (#438)
Clears the 4 stdlib govulncheck findings that were blocking `make check`
on the previous baseline:

- GO-2026-4982 — meta content URL escaping XSS (html/template)
- GO-2026-4980 — Escaper bypass XSS (html/template)
- GO-2026-4971 — NUL byte panic on Windows (net)
- GO-2026-4918 — HTTP/2 SETTINGS_MAX_FRAME_SIZE infinite loop
                 (net/http) + golang.org/x/net needed v0.53.0

Changes:
- go.mod: `go 1.26.0` → `go 1.26.3` (minimum required Go version).
  GOTOOLCHAIN=auto (the default) makes `go` auto-download 1.26.3 for
  contributors still on an older local binary; CI's setup-go pin
  `go-version: "1.26"` already floats to the latest 1.26.x patch and
  needs no change.
- golang.org/x/net v0.52.0 → v0.53.0 (the GO-2026-4918 fix).
- golang.org/x/crypto v0.49.0 → v0.50.0, golang.org/x/sys v0.42.0 →
  v0.43.0, golang.org/x/term v0.41.0 → v0.42.0 — pulled in as
  cross-module compat partners by `go get golang.org/x/net@v0.53.0`.

Verification:
- govulncheck ./...        → 0 vulnerabilities affecting our code
                             (1 unreached grpc finding GO-2026-4762 is
                             in the import graph only — separate task)
- golangci-lint run        → 0 issues
- go test ./...            → all pass
- cd web && npm run build  → clean
- make check               → fully green for the first time

Implements TASK-1232.
2026-05-07 23:28:12 -04:00
xarmian a829d3a06d fix(web): ItemCard polish — nudge PR badge down, wrap long titles (BUG-1233) (#437)
* fix(web): ItemCard polish — nudge PR badge down, wrap long titles (BUG-1233)

Two small fixes on the item card:

1. PR badge vertical position: top: -6px → 8px. The previous offset
   pushed the badge above the card edge; 8px sits the pill comfortably
   inside the top of the card while keeping the right: -6px overhang
   (sticker-on-edge feel). Cosmetic polish on TASK-1230.

2. Long-title overflow (BUG-1233). Item cards with unbreakable titles
   (URLs, long identifiers, code-snippet titles) used to push the card
   past its container — most visible on board view's narrow columns.

   - `.item-card`  → `min-width: 0` so it can shrink below its intrinsic
     content width when it's a flex/grid child
   - `.card-title` → `overflow-wrap: anywhere; min-width: 0` so any
     character can be a break point when whitespace is absent

   Deliberately NOT setting `overflow: hidden` on `.item-card` — the
   PR badge protrudes at `right: -6px` and would be clipped.

Verification:
- golangci-lint                    -> 0 issues
- go test ./...                    -> all pass
- cd web && npm run build          -> clean
- Manual: long-title item now wraps inside the card on board + list views;
  PR badge still renders correctly at the new position.

* fix(web): reserve top-row right padding for PR badge per Codex review (round 1)

Codex flagged that moving the PR badge from top: -6px to top: 8px
brought it inside the card and on top of the .card-top-row content
(star button, optional collection badge, item ref). On dashboard
active-items panel and role board (both pass showCollection=true) or
items with long refs, the badge could cover that text and intercept
clicks.

Fix: add `class:has-pr={!!pullRequest}` to the card and reserve
padding-right: 52px on .card-top-row whenever a PR badge is present.
Width budget covers a 5-digit PR number worst case plus the badge's
right: -6px protrusion (~50px pill - 6px overhang = 44px inner gap;
rounded up to 52px for breathing room). Non-PR cards keep their full
top-row width.
2026-05-07 23:15:24 -04:00
xarmian e932fd4fcd feat(web): show PR state badge on item cards (TASK-1230) (#436)
Render a state-colored pill badge in the top-right corner of ItemCard
whenever an item has a linked GitHub PR. The badge overlaps the card
border ("sticker on card" feel), shows the PR number, and opens the PR
URL in a new tab on click without navigating to the item.

PR data is read from `item.code_context?.pull_request` which the server
already derives from `fields.github_pr` via ExtractItemCodeContext —
no new types, helpers, or API calls. State colors:

- OPEN    -> var(--accent-green)
- MERGED  -> var(--accent-purple, #8b5cf6)
- CLOSED  -> var(--accent-red, #ef4444)
- DRAFT   -> var(--text-muted)
- default -> var(--text-muted)

Implementation matches the existing star-btn pattern: a <button> (not
nested <a>) with preventDefault + stopPropagation, then window.open
with noopener,noreferrer. Visible in both default and compact card
variants; tooltip on hover surfaces the PR title + state.

Implements [[IDEA-1214]]. Stale-state refresh (badge may show OPEN
after PR is merged) is intentionally out of scope and tracked
separately under IDEA-1214 (sibling task).

Verification:
- golangci-lint run --timeout=5m ./...  -> 0 issues
- go test ./...                          -> all pass
- cd web && npm run build                -> clean
- svelte-check                           -> 0 errors

Note: `make check` also runs govulncheck which flags 4 pre-existing
Go stdlib vulnerabilities (GO-2026-4982 / 4980 / 4971 / 4918) on the
1.26.2 baseline. Tracked under TASK-1232 (Bump Go toolchain to 1.26.3).
Unrelated to this PR.
2026-05-07 22:50:17 -04:00
xarmian d915cc3cf8 feat(cli): per-server credentials in credentials.json with v1 → v2 migration (TASK-1228) (#435)
Implements IDEA-1226. ~/.pad/credentials.json is now a map keyed by
server URL so one developer machine can stay logged in to multiple Pad
instances simultaneously — `apm/` repo on Pad Cloud, `target/` repo on
local, `testing/` repo on staging — without each `pad init --url <other>`
clobbering the previous server's credentials.

## On-disk format

v2 (new):
  {
    "version": 2,
    "credentials": {
      "https://app.getpad.dev":  {"token": "...", "user_id": "...", ...},
      "http://127.0.0.1:7777":   {"token": "...", "user_id": "...", ...}
    }
  }

v1 (legacy, read-only): {"server_url": "...", "token": "...", "user_id": "...", ...}

Reads transparently migrate v1 → v2 in memory; writes always emit v2.
Side-effect-free reads — the on-disk file stays v1 until login/logout/
setup triggers a Save, which is when migration becomes durable. This
keeps `pad <read-only-command>` from rewriting credentials.json on
every invocation just because the binary upgraded.

## API

Replaces the three top-level helpers (LoadCredentials / SaveCredentials /
DeleteCredentials) with a CredentialStore type:

  - LoadStore() (*CredentialStore, error)
  - (s).Get(serverURL) *Credentials       // nil-receiver safe
  - (s).Set(serverURL, *Credentials)
  - (s).Delete(serverURL)
  - (s).Save() error
  - WipeCredentialsFile() error           // file-level — replaces DeleteCredentials

URL canonicalization is built in: trailing slash + surrounding whitespace
are stripped before lookup/store, so http://x:7777 and http://x:7777/
hit the same bucket. Same rule cmd/pad/server_info.go was already
applying via its now-redundant normalizeURL — removed.

No top-level `default` field. The configured server (cfg.BaseURL() from
~/.pad/config.toml or --url) is always the source of truth for "which
server am I targeting" — a separate `default` would create a second
source of truth and the split-brain bugs that follow.

## Behavioral changes

- `pad init --url <other>` against a server you've authed to before now
  reuses the saved credential instead of clobbering it.
- `pad auth logout` removes only the configured server's entry. Other
  servers' tokens stay intact (pre-fix: wiped the whole file).
- `pad auth whoami` reads only the entry matching the configured server.
- Single-server users see no behavior change — one entry, identical
  shape per entry, identical UX.

## Compat shims removed

LoadCredentials / SaveCredentials / DeleteCredentials are deleted
outright (no // Deprecated lifecycle) — they're internal package
helpers with no external API contract. All 10 call sites in cmd/pad/
and internal/cli/ are migrated to the per-server API in this PR.

## Tests

internal/cli/credentials_test.go (15 tests):
- File missing / empty → empty store (callers don't need nil checks)
- v1 format reads + migrates in memory
- v1 with empty token → empty store (no phantom entries)
- v1 migration is durable on first Save (file flips to v2)
- v2 round-trip preserves multiple entries
- Set adds + replaces; mirrors URL into ServerURL field
- Delete keeps siblings (multi-server keystone behavior)
- Delete on absent key is a no-op
- Nil receiver Get/Delete don't panic (NewClientFromURL relies on this)
- URL normalization (trailing slash + whitespace)
- Save preserves all entries across the file boundary
- Save uses 0600 permissions
- WipeCredentialsFile removes the file + is idempotent
- Garbage file errors loudly (so we never silently lose data)

Existing tests unchanged. Full suite + lint + web-check green.

Closes: TASK-1228.
Implements: IDEA-1226.
2026-05-07 20:23:13 -04:00
xarmian ce0be1ed0a fix(auth): TokenAuth falls through invalid Bearer on public API paths (BUG-1227) (#434)
Prior behavior: TokenAuth middleware rejected any invalid/malformed
Bearer with 401 before dispatch — even on paths in isPublicAPIPath
(/api/v1/auth/*, /health, share links, public plan-limits). A stale
credential in ~/.pad/credentials.json (typically left over after wiping
a test DB) made every CLI invocation 401 on the very first
CheckSession() call, INCLUDING the endpoints needed to recover (login,
forgot-password). Users could only fix it by manually deleting their
credentials file.

The matching IP-change-revoked branch in the same file already had the
right pattern (middleware_auth.go:114-117): when the path is public,
fall through to the handler unauthenticated and let it decide. This
patch mirrors that across the four invalid-Bearer branches:

- Authorization header doesn't start with "Bearer "
- padsess_* token doesn't validate (stale or wiped session)
- pad_* token format wrong (length, prefix)
- pad_* token doesn't match a live API token

Extracted into a small rejectInvalidBearer helper so the policy is
visible in one place. Protected endpoints continue to 401 — the
regression guard in TestTokenAuth_ProtectedPath_StillRejectsInvalidBearer
pins that.

Pre-existing bug; not introduced by TASK-1216 / TASK-1217. The new
bootstrap flows just made it more visible because anyone testing fresh-
install scenarios is likely to wipe DBs and end up with stale creds.

Tests in middleware_auth_public_paths_test.go cover:
- /auth/session with stale padsess_* Bearer → 200 with public payload
- /auth/session with malformed Authorization → 200
- /auth/session with garbage token format → 200
- /auth/session with non-matching pad_* token → 200
- /auth/login with stale Bearer + valid creds → 200 (the actual user-
  visible recovery scenario)
- Protected /workspaces with invalid Bearer → still 401 (regression)

Closes: BUG-1227.
Related: IDEA-1226 (per-server credentials — proper design fix; this is
the safety-net fix that complements it).
2026-05-07 20:01:34 -04:00
xarmian dfb67ae64b feat(init): browser-based admin setup in pad init via /setup#token (TASK-1217) (#433)
Wire `pad init`'s admin-creation step (Step 3) to use
cli.RunBrowserBootstrap from TASK-1216 by default, with --cli-prompt
preserving the legacy in-terminal email/name/password prompts. Workspace
creation stays CLI — `pad init` is intrinsically directory-bound (.pad.toml
write, cwd link), and that's what the browser flow can't do.

Default flow on a fresh server in TTY:
  1. Configure (existing)
  2. Start server (existing)
  3. NEW: print /setup#token=<x> deep link, poll until admin is created
  4. NEW: chain doBrowserLogin so the CLI ends up authenticated
  5. Workspace creation (existing template picker, .pad.toml write)
  6. Skill files (existing)

`pad init --cli-prompt` falls back to the pre-TASK-1217 path verbatim:
promptAndBootstrap → saveCredentials → workspace creation. Same behavior
as today for users with broken browser environments (headless box no
SSH tunnel, broken X11, etc.). The flag is a zero-cost hedge per
IDEA-1179 — we don't expect users to need it, but each invocation is a
signal we should rethink.

SIGINT during the polling loop is handled by installInitCancelHandler
(top of the RunE) which calls os.Exit(130) directly — the helper doesn't
need its own signal-aware ctx, so context.Background() is fine.

Helper-call audit: promptAndBootstrap and readPassword are still reached
via the --cli-prompt paths in both `pad auth setup` and `pad init`, plus
readPassword serves doInteractiveLogin. All three keep their callers, so
no helpers are removed in this PR. Both --cli-prompt paths exist by
design as the IDEA-1179 hedge.

Implements: IDEA-1179 (pad init half).
Closes: TASK-1217.
2026-05-07 17:34:44 -04:00
xarmian 51959532ad feat(auth): browser-based pad auth setup via /setup#token deep link (TASK-1216) (#432)
* feat(auth): browser-based pad auth setup via /setup#token deep link (TASK-1216)

`pad auth setup` now hands the operator a deep link into the browser-based
/setup form by default, replacing the in-terminal email/name/password
prompts. The browser flow gives them password-manager support, HTML5
email validation, and the live strength meter at zero CLI cost — the
mechanism (logs-token bootstrap, /setup route, /api/v1/auth/session) was
already shipped by TASK-1167 / PLAN-1166 for the Unraid use case. This
just unifies the local-CLI install path onto the same flow.

New `internal/cli/bootstrap.go::RunBrowserBootstrap`:
  - Reads <DataDir>/.bootstrap-token and prints
    `<BrowserURL>/setup#token=<TOKEN>` with the token in the URL fragment
    (not query) — fragments are scrubbed from the address bar by /setup's
    onMount before paint, so the secret doesn't survive in browser
    history (TASK-1167 F10).
  - Polls /api/v1/auth/session every 2s; returns nil when
    setup_required: false. Internal 5-min timeout uses a separate timer
    (not context.WithTimeout) so caller-ctx cancellation surfaces as
    ctx.Err() instead of being misreported as the helper's own timeout.
  - Idempotent: returns early if setup is already done, without touching
    the token file.
  - Dispatches on session.setup_method — "logs_token" reads the token,
    "open" (PAD_BYPASS_SETUP_TOKEN=true) prints a bare /setup URL,
    "local_cli" / unknown returns an error directing the user to
    --cli-prompt.

`pad auth setup` is rewired to call the helper, then chain doBrowserLogin
so the user ends up authenticated on the CLI — preserving the post-
condition of the legacy --cli-prompt path. Two browser approvals (admin
creation, CLI auth) but each is one click in a browser the operator
already has open.

The legacy TTY path lives on behind --cli-prompt as a zero-cost hedge
per IDEA-1179. Existing promptAndBootstrap / readPassword helpers are
left in place — TASK-1217 will audit whether they can be removed once
pad init is on the new flow too.

Tests in internal/cli/bootstrap_test.go cover: idempotent session check,
logs_token happy path, open mode, missing/empty token error paths,
local_cli + unknown method rejection, internal timeout firing with the
friendly message, caller-ctx cancellation propagating ctx.Err() (not
timeout error). bootstrapPollInterval / bootstrapPollTimeout are vars so
the timeout-branch test can run in 100ms instead of 5min.

Implements: IDEA-1179 (auth-setup half).
Out of scope: pad init integration → TASK-1217.
Out of scope: post-/setup workspace dead-end → IDEA-1215.

* docs(cli): clarify RunBrowserBootstrap caller staging across TASK-1216 / TASK-1217

Codex review (round 1) read the docstring and flagged that `pad init`
isn't on the new helper. That wiring is TASK-1217's scope by design (one
task = one PR per CONVE-2; TASK-1217 has a hard blocked-by link to
TASK-1216). Tighten the docstring to make the staging explicit so a
reader of the diff alone doesn't conclude it's a missing wire-up.
2026-05-07 17:28:29 -04:00
xarmian 954c84d0bf refactor(e2e): extract demo data into shared module (TASK-1201) (#431)
Lift the static "realistic workspace" data out of seedRealisticContent
into web/e2e/lib/demo-data.ts so it can be consumed by pad-remotion (a
sibling repo) without dragging in Playwright as a dependency. Single
source of truth for what a real-feeling Pad demo looks like.

Wire-shape compatibility is preserved exactly:
- demoPlan: same title, status, content
- demoTasks: same 7 tasks in same order, same status/priority/effort,
  same parent-to-plan linkage (now expressed via parentToPlan: boolean
  rather than carrying the plan id inline — the seeder maps it back
  to the freshly-created plan id at post time)
- demoIdeas: same 2 ideas, same fields

Behavior verified by running the gated screenshot spec:
  PAD_SCREENSHOTS=1 npx playwright test e2e/screenshots.spec.ts \
    --project=desktop-chromium
seedRealisticContent runs to completion and the dashboard / board /
list / table screenshots regenerate identically (reverted; not part
of this PR's diff).

demoConventions is also exported (4 representative entries) for the
pad-remotion ContextScene to render ghost-cards. demo-seed.ts itself
doesn't consume it — seedConventions takes caller-supplied input — but
the shape lives here so the shared data module is complete.

The companion pad-remotion file (src/data/demoItems.ts) lands as a
separate PR in PerpetualSoftware/pad-remotion. We chose copy-with-
manual-sync over a path import / symlink because pad-remotion is a
distinct git repo; a CI drift check is a possible follow-up if this
duplication starts to bite.

Parent: PLAN-1198.
2026-05-07 10:32:52 -04:00
xarmian 518c78e512 chore(unraid): remove unraid/ directory (template moved to PerpetualSoftware/unraid-templates) (#430)
The Unraid CA template has its own home now:
https://github.com/PerpetualSoftware/unraid-templates

That repo is required for the new ca.unraid.net/submit portal flow
(needs ca_profile.xml + dedicated repo per submission). Pad has been
submitted and auto-approved pending the next CA build, so the old
pad/unraid/ files have no remaining consumers:

- Docs (pad-web /docs/self-hosting/unraid) already point at the new
  repo's raw URLs (#96, merged 2026-05-06)
- Forum support thread + ca_profile.xml + pad/pad.xml in the templates
  repo all reference the canonical new location
- No CI / GoReleaser / Docker / Make targets touch unraid/ — verified
  with rg before deletion

Refs IDEA-1184, PLAN-1185, TASK-1191.
v0.3.0
2026-05-06 19:24:55 -04:00
xarmian 40352a32e1 feat(auth): PAD_BYPASS_SETUP_TOKEN open-bootstrap escape hatch (#429)
Adds an env-var that lets self-host operators on trusted networks
(Unraid behind a firewall, Tailscale-only deployments, homelabs)
claim the first admin via the web UI without copying a bootstrap
token out of the container logs.

Behavior when PAD_BYPASS_SETUP_TOKEN=true:

- handleBootstrap accepts non-loopback first-admin POSTs without an
  X-Bootstrap-Token header. The UserCount==0 invariant is unchanged,
  so the bypass auto-closes the moment the first admin claims the
  seat (subsequent bootstrap requests get 409 regardless of bypass).
- handleSessionCheck returns setup_method=open so the /setup page
  skips the paste-token UI and renders the form directly.
- Token generation is skipped at startup (no .bootstrap-token file
  written). A distinct WARN-flavored banner makes the open-mode
  trade-off obvious in operator logs.
- Cloud mode (PAD_CLOUD/PAD_MODE=cloud) ignores the flag entirely.
  Three layers of defense: cmd/pad masks the env-var with
  !cfg.IsCloudServer(), Server.openBootstrapEnabled() checks
  !s.cloudMode, and the cloud branch in handleBootstrap never reads
  the bypass field.

Unraid template gets a new "Bypass Setup Token" field (default false,
Display="always") with a description that calls out the trust-the-
network trade-off.

Tests pin all the security-critical contracts: bypass admits non-
loopback, bypass off keeps existing 403, cloud mode hard-ignores,
loopback works either way, post-bootstrap gate stays closed, bypass
wins over logs_token in session payload, cloud mode never advertises
'open' setup method.

Codex review: CLEAN (round 1).
2026-05-06 13:27:12 -04:00
xarmian 693f03be3c fix(auth): emit first-run bootstrap banner to stderr (BUG-1182) (#428)
slog's text handler is contractually one-line-per-record and escapes
literal newlines as `\n`, so the multi-line bootstrap banner rendered
as a single wide line in `docker logs` — exactly the surface where
operators look for the token. Switches the banner to fmt.Fprint to
stderr (real newlines), with a companion slog.Info one-liner so
structured-log aggregators still record the event.

The companion log deliberately does NOT include the URL or token in
its structured fields — those would be parseable as
log-aggregator-extractable values, defeating the URL-fragment design
(TASK-1167 F10) that keeps the token off-server. Operators / agents
that want the token programmatically read the on-disk file at
token_path.

Verified locally: banner now renders the ASCII box with real
newlines, token visible, companion slog line shows token_path
without the URL.

Caught by Dave during the v0.3.0-rc.1 smoke test on a real Unraid box.
v0.3.0-rc.2
2026-05-06 10:29:38 -04:00
xarmian 4c62a27e3b fix(unraid): correct install instructions + add AI category (BUG-1181) (#427)
* fix(unraid): correct install instructions + add AI category (BUG-1181)

The "Add Repository" / "Template Repositories" feature in older Unraid
was removed in 6.10.0-rc1 — the install path documented as Route A
("Apps → Settings → Add Repository → paste URL") doesn't exist on any
modern Unraid. Caught during TASK-1171 smoke-test prep when the
operator couldn't find the option in CA's UI on a current Unraid
install.

Replaces both routes in unraid/README.md with the working sideload
paths:

- Route A → CA Private Folder
  /boot/config/plugins/community.applications/private/perpetualsoftware/pad.xml
  Template appears under CA's "Private" category. Closest experience
  to a CA-approved install — same listing UI, same install form.
- Route B → Docker tab sideload (CA-less fallback)
  /boot/config/plugins/dockerMan/templates-user/my-pad.xml
  Skips CA entirely. Works without the CA plugin installed.

Verified live: dave manually sideloaded via the new Route A; the Pad
card renders with icon + Overview in CA, install form opens cleanly.

Also updates <Category> from "Productivity: Tools:" to "Productivity:
AI:" — Tools was generic and unrelated; AI was added to CA's taxonomy
recently (114 apps in that category) and matches Pad's "agent era"
framing better. Verified syntax against the Unraid Docker Template
Schema wiki: space-separated categories within a single <Category>
element, each "Top:Sub" or "Top:" form.

Sibling pad-web PR fixes the same two routes in
/docs/self-hosting/unraid.

Source for the Template Repositories removal:
https://forums.unraid.net/topic/114809-i-want-to-make-my-own-private-template-repository-but-it-doesnt-work/

* fix(unraid): align pad.xml with dockerMan SAVE serializer (BUG-1181)

Verified against a real dockerMan SAVE round-trip on Unraid 7.x — diff
captured during TASK-1171 smoke test. Migrating structural divergences
back into the canonical template so we stay on Squid's good side per
the wiki's blacklist warning.

Element changes:
- Add <MyMAC/>, <ReadMe/>, <Requires/>, <TailscaleStateDir/> empty
  markers (recent dockerMan emits them all)
- Remove <Description> block — not part of the recognized schema;
  dockerMan strips it on SAVE. <Overview> is the canonical CA-displayed
  text and already covers the same ground.
- Switch 3 empty <Config></Config> blocks (Public URL, Maileroo API
  Key, Email From) to self-closing <Config .../> form — matches
  dockerMan's serializer output.
- Drop BBCode wrapping ([b]Logs[/b] -> Logs) in Overview — dockerMan
  strips BBCode during SAVE, so the formatting was dead weight.
- Remove all XML comments — dockerMan strips them on SAVE round-trip.
  Maintainer rationale moved to unraid/README.md's new "Template
  format conformity" section instead.

Final element order verified element-by-element to match dockerMan's
output: 33 top-level elements, position-aligned.

Cosmetic differences left as-is (XML-equivalent, won't trip Squid):
- Em dashes (literal — vs &#x2014;)
- Line endings inside <Overview> (LF-only vs CRLF as &#13;\n)

unraid/README.md gains a "Template format conformity" section
documenting the wiki guidance, the SAVE-diff verification workflow,
and the specific dockerMan behaviors that bit us (comments stripped,
<Description> dropped, BBCode stripped, self-closing empty elements).
Note: this README itself doesn't ship through CA so the maintainer
context is safe here.

Stacks on the existing BUG-1181 commit (install-route + AI category
fixes) — same theme of "Unraid template + docs correctness for CA
submission".

* fix(unraid): convert em dashes to numeric entities to match dockerMan SAVE output

Last cosmetic diff between our hand-written pad.xml and what dockerMan
emits on a SAVE round-trip. XML-equivalent (both render the same em
dash glyph), but eliminating the visual diff makes future
SAVE-roundtrip checks cleaner and removes any tail risk of CA
treating literal U+2014 differently from the numeric entity.

Three occurrences converted: Overview text + PUID/PGID Config
descriptions. After this, the only remaining diff against a
post-Apply dockerMan SAVE is <Overview> line endings (LF vs CRLF) and
<DateInstalled> (operator-stamped) — both XML-equivalent and
expected-to-differ respectively.

Last cosmetic touch on PR #427.
2026-05-06 10:25:48 -04:00
xarmian 229ba2400f feat(unraid): Community Applications template + README (TASK-1169) (#426)
* feat(unraid): add Community Applications template + README (TASK-1169)

Adds unraid/pad.xml (CA Container v2 schema) and unraid/README.md to
this repo's root. The XML lets Unraid users one-click install Pad from
Community Applications once approved (HT-1175); the README documents
the manual "Add Repository" path and the direct sideload fallback for
early adopters before CA approval lands.

Form fields surfaced (basic): WebUI Port, Appdata path. Advanced:
PUID/PGID (defaults 99/100 — Unraid nobody:users), PAD_LOG_LEVEL,
PAD_URL, PAD_MAILEROO_API_KEY (Mask=true), PAD_EMAIL_FROM,
PAD_EMAIL_FROM_NAME. All Config blocks use single-line attribute form
per CA parser fragility guidance.

README covers: install (CA approved + manual pre-CA), first-run via
docker-logs token + /setup#token=, persistent data layout, paired -C
tar backup recipe (avoids the absolute-path restore footgun), upgrade,
reverse-proxy hint, email setup, and troubleshooting.

HARD MERGE-ORDER DEPENDENCY: this PR must NOT be merged until both
TASK-1167 (PR #424, bootstrap-token flow) and TASK-1168 (PR #425,
PUID/PGID entrypoint shim) are merged. The template references
behaviors those PRs deliver; the README's first-run walkthrough,
PUID/PGID form fields, and chown-on-restart guidance all assume they
are present. CI on this branch passes (XML/Markdown only), but a smoke
test against `:latest` would fail until #424 and #425 merge.

Pinned via 3 rounds of codex pre-implementation design review (3
defects caught: missing PAD_EMAIL_FROM_NAME, multi-line Config attr
form, tar absolute-path footgun). Code-review pass on the diff: only
remaining findings are the dependency-not-yet-merged note above.

Out of scope:
- unraid/icon.png — TASK-1170.
- Forum thread — HT-1174 (template's <Support> field is a PLACEHOLDER
  slug for grep-ability).
- CA submission — HT-1175.
- Smoke test on real Unraid — TASK-1171.
- getpad.dev/docs/install/unraid page — TASK-1172.
- Postgres/Redis variant template (advanced users use docker-compose.yml).

Part of PLAN-1166.

* docs(unraid): align URL references with pad-web's actual /docs/self-hosting/unraid path

PR #93 in pad-web lands the install walkthrough at
/docs/self-hosting/unraid (not /docs/install/unraid as originally
spec'd). Updates the URL referenced in unraid/README.md and the
template's <Overview> field to match. See PR #93's body for the URL
deviation rationale.

Part of TASK-1172. Coordinates with pad-web PR #93.

* feat(unraid): add 256x256 icon for CA listing (TASK-1170)

256×256 RGBA PNG at unraid/icon.png. LANCZOS downsample from the
existing web/static/icon-512.png — Pad's app icon (clipboard +
colored-tile board view). Reused rather than designed afresh so the
CA listing matches what users already see on getpad.dev favicons and
the PWA install icon.

35 KB on disk after Pillow's optimize=True pass. Renders crisply at
the smaller sizes CA shows in the Apps grid.

Removes the TODO(TASK-1170) placeholder comment in pad.xml and the
"Note on the icon" 404-warning section in README.md — both replaced
with brief provenance notes (downsample method, source file, "update
both in lockstep" reminder).

Closes TASK-1170. Stacked on PR #426 because pad.xml's <Icon> URL
points at main, so the icon and the template need to land together
or the listing renders broken.
v0.3.0-rc.1
2026-05-06 08:41:17 -04:00
xarmian 92a4931f44 feat(docker): PUID/PGID entrypoint shim for Unraid + LinuxServer-style hosts (TASK-1168) (#425)
Tiny /bin/sh entrypoint shim that, if invoked as root, reads PUID/PGID
env vars (defaulting to 99/100 — Unraid's nobody:users), remaps the
in-image pad user, chowns /data, and execs the binary via su-exec.
If invoked as non-root (caller passed --user), it just execs directly
— caller knows what they want.

Solves the classic Unraid appdata-ownership-mismatch first-run failure
where the in-image pad user (uid 1000) couldn't write to a host volume
owned by nobody:users (uid 99, gid 100). Reusable on Synology / QNAP /
TrueNAS where the host's appdata user is similarly non-1000.

Behavior changes:
- Container starts as root (USER directive removed). Entrypoint drops
  privileges via su-exec before exec'ing pad — standard PUID/PGID
  pattern. Healthcheck adapts: root → su-exec to pad; non-root →
  direct wget.
- chown -R is always-run (warn-and-continue on per-file failures). A
  shallow stat-only check would silently break pad on a restored
  backup with mixed-ownership inner files.
- Healthcheck start-period bumped 10s → 60s to absorb slow chown -R
  on large attachment stores.
- Compose default 1000/1000 for backward compat with existing deploys
  whose volumes were created under the previous USER pad image.
- Raw `docker run` defaults to 99/100 (Unraid convention).

Validation rejects PUID=0 / PGID=0 (would defeat the unprivileged-user
invariant), empty values, and non-numeric values with clear errors.

Goes through 11 rounds of codex pre-implementation design review,
catching:
- gid bug where groupmod alone leaves /etc/passwd's primary-gid stale
- compose $-interpolation gotcha (needs $$( ) not $())
- getent missing from default alpine BusyBox
- shell ${VAR:-} silently masking explicit empty values
- healthcheck running as root after USER drop
- su-exec failing for --user non-root pass-through

Part of PLAN-1166 (Pad on Unraid — Community Apps launch). Unblocks
TASK-1169 (XML template authoring).
2026-05-06 08:40:33 -04:00
xarmian 05a9665f50 feat(auth): first-run logs-token bootstrap flow (TASK-1167) (#424)
One-time bootstrap token generated on first start with no users in self-host
mode. Token is logged in a banner the operator can grab from `docker logs`,
persists at <DataDir>/.bootstrap-token (mode 0600), and bypasses the
loopback-only gate via the X-Bootstrap-Token header — letting the user
claim the first admin from a remote browser at /setup#token=<x>.

Header-only contract + URL-fragment (browser-only, never transmitted) +
log-redaction middleware keeps the secret out of access logs, proxy logs,
and browser history. Cloud mode unchanged: token never loaded, never
honored. Validate → UserCount-check → CreateUser → consume sequence is
mutex-serialized to prevent concurrent valid-token requests from creating
multiple admins.

Part of PLAN-1166 (Pad on Unraid — Community Apps launch).
2026-05-06 08:40:11 -04:00
xarmian 6c44291e78 fix(layout): let Cmd+F fall through to browser-native find on item views (BUG-986) (#423)
The layout's global keydown handler unconditionally intercepted Cmd+F
and routed it through a `collectionSearchRequested` boolean that only
the collection list page polled. On item / document views (and any
non-collection page) nothing watched the flag, but `e.preventDefault()`
had already blocked the browser's native find — leaving users with no
way to search inside a document.

Invert the model from "always intercept, broadcast a flag" to "only
intercept when a page registers a handler":

- ui.svelte.ts: replace `collectionSearchRequested` with a
  `collectionSearchHandler` registry exposing
  `registerCollectionSearch` / `unregisterCollectionSearch` /
  `triggerCollectionSearch` / `hasCollectionSearchHandler`.
- +layout.svelte: only `e.preventDefault()` and dispatch when
  `uiStore.hasCollectionSearchHandler` is true; otherwise let Cmd+F
  pass through to the browser.
- [collection]/+page.svelte: register the existing
  filters-open + focus-search behaviour in an `$effect` and unregister
  it via the effect's cleanup so it lives only while the page is
  mounted.

Result: collection list view keeps its existing Cmd+F filter-search
shortcut; item views and any other page get the browser's native find
back. `make check` clean (Go tests, lint, web build, svelte-check 0
errors).
v0.2.0
2026-05-05 17:18:09 -04:00
xarmian 50d04944c5 feat(sweep): gate role board + child reorder + run grep verification (TASK-1108) (#422)
* feat(sweep): gate role board + child reorder + run grep verification (TASK-1108)

Final sweep across PLAN-1100 (client-side permission audit). Confirms no
remaining open-coded permission checks and gates the few remaining surfaces
not covered by tasks 1102-1107.

Code-only acceptance grep results:
- `members.find(...)` outside workspaceStore: ZERO matches
- `m.role === 'owner' / 'editor'` open-codes outside permissions.ts: ZERO matches
- `isOwner = $derived(workspaceMembers...)` open-codes: ZERO matches

Surfaces gated in this PR:
- Role board (`/{workspace}/roles`):
  - "+ New" item button gated on canEditAnyItem (owner|editor)
  - "+ Add Role" column owner-only
  - Lane edit button (✎) owner-only, lane drag handle owner-only
  - Lane-header drag (column reorder) owner-only — handlers and draggable
    attribute conditional on isOwner
  - Lane-items dndzone receives dragDisabled: !canEditAnyItem (zone-level
    library limitation, same constraint as TASK-1106)
- ChildItems component (used on item detail to render children):
  - New canEdit prop (default true). Slug page passes canEdit (= canEditItem
    of parent). dndzone receives dragDisabled: !canEdit so non-editors
    can't drag-reorder children.

BUG-984 closure remains gated on HT-1157 (manual three-role smoke test),
which will sign off on real walks as owner / editor / viewer / guest.

Parent: PLAN-1100.

* fix(sweep): empty-state role create gate + grant-aware canEditAnyItem per Codex review (round 1)

Two findings from round 1:

1. Empty-state "Create your first role" button at roles/+page.svelte:530
   wasn't gated. Now wrapped in {#if isOwner}.

2. canEditAnyItem was role-only (owner|editor). Server's reorder handler
   is grant-aware per-item, so a viewer/guest with even one
   CollectionGrant.edit or ItemGrant.edit can legitimately mutate via the
   role board. Helper now ORs in any active edit grant — matches what
   the server enforces.

Parent: PLAN-1100. Refs TASK-1108 PR #422.

* fix(sweep): align role-board create/lane-reorder gates with server per Codex review (round 2)

Two findings from round 2:

1. The role-board "+ New" item flow was gated on canEditAnyItem (any role
   or any edit grant), but server's handleCreateItem requires collection-
   level edit (collection grant or role+visibility), not item-only grant.
   Item-grant-only users would see "+ New" but get a 403 on submit.
   Filter eligibleCollections by workspaceStore.canEditCollection(coll.id)
   and gate the button on eligibleCollections.length > 0.

2. Lane reorder was owner-only, but server's handleRoleBoardLaneReorder
   uses requireMinRole "editor". Editors lost an allowed operation.
   Introduced canReorderLanes (owner | editor) for lane drag handles +
   ondrag handlers; role create/edit/delete remain owner-only.

Parent: PLAN-1100. Refs TASK-1108 PR #422.
2026-05-05 10:08:08 -04:00
xarmian 7f06a9845a feat(comments): gate composer / replies / reactions / delete on canEditItem (TASK-1107) (#421)
Comment timeline previously rendered composer, reply, reaction picker,
and per-reaction toggle for all roles. Server enforces edit per-item on
comment writes; UI now matches.

Note on file paths: TASK-1107's original spec referenced
`web/src/lib/components/comments/CommentThread.svelte`, but that file is
unused anywhere in `web/src/`. Real comment UI lives in:
- `ItemTimeline.svelte` (composer + thread layout)
- `TimelineCommentCard.svelte` (per-comment delete, replies, reactions)

Changes:
- ItemTimeline: imports workspaceStore, accepts itemId + collectionId
  props (optional with safe fallback), derives canEdit reactively. The
  composer is hidden entirely when !canEdit.
- TimelineCommentCard: accepts canEdit prop. Delete / reply / reaction
  picker / reply-comment delete / reply reaction picker all gated.
- Existing reactions still render with counts so read-only viewers see
  who reacted; the chip's onclick is gated and disabled={!canEdit} so
  toggling is blocked for them.
- Slug page passes item.id + item.collection_id so ItemTimeline can
  resolve canEditItem itself (no prop-drilling of canEdit).

Parent: PLAN-1100.
2026-05-05 09:54:07 -04:00
xarmian fe9b76ab93 feat(views): gate drag/archive in ListView/BoardView on canEditCollection (TASK-1106) (#420)
The collection page's ListView and BoardView allowed all roles to drag
items, drag-status-change, reorder groups/columns, and archive groups.
Server enforces edit per-item and per-collection on these mutations
(handlers_items.go, handlers_role_board.go) — UI now matches.

Changes:
- ListView + BoardView accept a `canEdit?: boolean` prop (default true to
  preserve behavior in existing callers).
- ListView: dndzone for groups + intra-group items receives
  `dragDisabled: !canEdit`. Group drag handle and archive-group button
  hidden when !canEdit.
- BoardView: column-cards dndzone receives `dragDisabled: isMobile || !canEdit`.
  Column-header drag (column reorder) gated via `draggable={canEdit}` and
  conditional drag handlers. Column-drag-handle indicator and
  archive-column button hidden when !canEdit.
- Collection page passes `canEdit={canEditThisCollection}` to both views.

Scope note: per-item drag gating (e.g. a guest with ItemGrant.edit on one
item dragging just that one card) is not implemented — svelte-dnd-action
only supports zone-level dragDisabled. Achieving per-item would require
switching to dragHandleZone+dragHandle and shipping an explicit handle UI
for everyone, which is a larger UX change. Server already enforces per-item
edit on the resulting mutations, so no security gap. Documented as a
follow-up if needed.

TableView: excluded from drag/archive scope — no drag handlers to gate.
Status-cell editing is already gated via FieldEditor's readonly prop from
TASK-1105.

Parent: PLAN-1100.
2026-05-05 09:47:55 -04:00