Commit Graph

80 Commits

Author SHA1 Message Date
xarmian 076fb9b2e7 feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663) (#665)
* feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663)

Foundation for comment editing (PLAN-1662). No migration — comments.user_id
already exists (012_users.sql) but was never written or exposed.

- Populate user_id on create/reply: CreateComment takes an explicit userID
  param (passed from currentUserID by the handlers, not via the request body
  so it can't be spoofed). Expose user_id on models.Comment + all comment
  SELECTs/scans. The workspace export path is left as-is — imported comments
  keep NULL user_id (admin-only edit), matching the pre-identity fallback.
- Store.UpdateComment(id, body): replaces body + bumps updated_at; the
  comments_fts_update trigger re-indexes.
- PATCH /workspaces/{ws}/comments/{commentID}: author-or-admin only
  (canEditComment), rejects empty body. Editing is an authorship op, distinct
  from delete (item editors). NULL user_id → admin-only.
- comment_updated SSE event: broadcast from the handler; added to the web
  sse allowlist + ItemTimeline refresh set.
- web: api.comments.update(), Comment.user_id type.

Tests: author edits own (200), non-author non-admin (403), admin edits
anyone (200), empty body (400), NULL-user_id comment is admin-only.

Parent: PLAN-1662.

* fix(account): detach authored comments on account deletion per Codex review (round 1)

Now that TASK-1663 populates comments.user_id (FK to users.id),
DeleteAccountAtomic would fail on the FK for any user who authored a
comment. Null comments.user_id for the user before deleting the row —
comments live on in soft-deleted/other workspaces; the display-name
author is preserved and the comment just becomes admin-only to edit.
Regression test added.
2026-05-30 12:38:57 -04:00
xarmian 1b1068537c feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653) (#658)
* feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653)

Foundation for the tags feature (PLAN-1652 / IDEA-1649). The write path and
per-collection ?tag= filter already existed; this adds tag enumeration and a
verified cross-collection read so a single tag can group items of any type.

- store: dialect.JSONArrayElements unnests a JSON text-array column
  (json_each on SQLite, jsonb_array_elements_text on Postgres);
  Store.ListWorkspaceTags returns distinct tags + item counts, ordered by
  count desc then tag asc, with the same collection/item ACL filters as
  ListItems so counts never leak hidden items.
- server: GET /workspaces/{ws}/tags (handleListTags), respecting collection
  visibility + guest item grants.
- models: TagCount{tag,count}.
- cli: client.ListTags + `pad tag list`.
- web: api.tags.list + TagCount type (items.list already forwards `tag`).
- tests: store-level (cross-collection aggregation, collection scoping,
  non-nil-empty = empty, archived excluded) and handler-level (a Task + an
  Idea sharing one tag; GET /tags counts + ordering).

Parent: PLAN-1652.

* fix(tags): count distinct items per tag, not tag occurrences per Codex review (round 1)

COUNT(DISTINCT i.id) so an item with duplicate tags (e.g. ["ux","ux"]) is
counted once — the write path doesn't enforce per-item tag uniqueness.
Adds a regression test.
2026-05-29 23:43:02 -04:00
xarmian eeff78118b feat(insights): per-user layout customization + persistence (TASK-1634) (#645)
* feat(insights): per-user layout customization + persistence (TASK-1634)

Let users personalize the Insights surface, persisted per-user per-workspace:
toggle which metric cards show, and remember the window + collection filter.

Backend:
- migrations 064/043: user_report_layouts (user_id, workspace_id, config JSON,
  PK(user_id,workspace_id), ON DELETE CASCADE) — dual-dialect.
- models.ReportLayout (hidden_cards/default_window/default_collections) +
  ReportCardIDs/ValidReportWindow validation.
- store.GetReportLayout / SaveReportLayout (ON CONFLICT upsert, both dialects).
- GET/PUT /workspaces/{ws}/report/layout — per-user; PUT sanitizes window +
  filters hidden_cards to the known card set. web client + TS type.

Frontend (Insights page):
- loads the saved layout, hydrates window/collections/hidden cards
- a "Customize" panel toggles each card (SvelteSet-backed); each section gated
  on !hiddenCards.has(id); Totals always shown
- debounced auto-save, gated on a per-workspace `hydrated` flag so it never
  saves during load or stomps another workspace's layout on switch

Single config per user (no named/multiple layouts — deliberate v1 scope).
Parent: PLAN-1628.

* fix(insights): save layout only on explicit user changes, not on load per Codex review (round 1)

The auto-save $effect ran once after hydration (loadLayout assigns reactive
state, then flips hydrated=true), firing a PUT /report/layout on mere page
view — which 401s on no-user/legacy-token sessions and bounces the user to
/login. Replace the effect with a scheduleSave() called only from explicit
handlers (toggleCard, selectWindow, toggleCollection, clearCollectionFilter);
hydration never saves. Also capture wsSlug at schedule time and drop the
pending save if the workspace changes mid-debounce, so A's edit can't land
on B.
2026-05-29 15:00:34 -04:00
xarmian 5dfc2921b2 feat(store): structured status-transition log + backfill (TASK-1637) (#637)
* feat(store): structured status-transition log + backfill (TASK-1637)

Add a status_transitions table capturing every item status change as a
structured, queryable row — written in the same tx as the item update and
never debounced — so the Reports surface (PLAN-1628) can reliably compute
the completed-throughput and cycle-time series.

- migrations/063 + pgmigrations/042: status_transitions table (dual-dialect),
  indexed on (workspace_id, created_at) and (item_id, created_at)
- write-path hook in UpdateItemWithPreCheck records from→to on status change
- BackfillStatusTransitions: one-time startup replay parsing the historical
  activities.metadata.changes blob (mirrors BackfillWikiLinks), gated on an
  empty table; wired into cmd/pad/main.go
- models.StatusTransition + tests (capture, multi-hop, no-op, parser, backfill)

Spike (TASK-1629) found the activity log records status changes only as a
human-readable, debounce-coalesced metadata string — unusable for aggregation.
This is the foundation TASK-1630 (report aggregation) builds on.

* fix(store): record status transitions on item move too per Codex review (round 1)

MoveItemWithPreCheck rewrites fields outside UpdateItemWithPreCheck, so a
status-changing move override (pad item move ... --field status=done) was
not recorded in status_transitions, making the table non-canonical. Insert
the from→to row in the move tx as well, stamped with the target collection.

Adds move-path capture tests (status override + status-preserving move).

* fix(store): make status-transition backfill idempotent per Codex review (round 2)

The empty-table gate isn't atomic, so concurrent replays (a future
multi-replica Postgres deploy; single-instance today) could double-insert
historical rows and overcount reports. Give backfilled rows a deterministic,
activity-derived primary key ("bf_" + activity id) and a dialect-aware
conflict clause (ON CONFLICT DO NOTHING / INSERT OR IGNORE) so a re-run
no-ops instead of duplicating. Count only rows that actually land.

Write-path rows keep using a random newID(), so live data never collides.

* fix(store): accurate from_status under lock + document backfill caveats per Codex review (round 3)

1. from_status was read from the pre-lock `existing` snapshot. When no
   precheck ran, a concurrent update (serialized behind the locks we hold)
   could make it stale. Capture the status from a fresh in-tx read BEFORE
   the UPDATE (reading after would see the new value and drop the hop).
   Applied to both UpdateItemWithPreCheck and MoveItemWithPreCheck.

2. Backfill stamps historical rows with the item's current collection_id;
   reconstructing the collection at each past status change would require
   replaying move history. Documented as a best-effort, historical-only
   caveat (exact for the common never-moved case; live write/move paths
   stamp the collection at transition time).

* feat(store): track collection done-field + seed create-time transitions per Codex review (round 4)

1. Generalize capture from hard-coded "status" to each collection's done
   field (DoneFieldKey: status, or BoardGroupBy field like stage/result for
   hiring/interviewing). Add a field_key column recording which field the
   row tracks (robust to later BoardGroupBy changes). Applied to update,
   move, and backfill paths.

2. Seed a create-time "entered initial status" transition on CreateItem and
   in the backfill (Pass 2), so an item created directly in a terminal value
   still counts as a completion. Initial value reconstructed from the item's
   earliest recorded change, else its current value.

Also: item_id FK is ON DELETE CASCADE so hard-deletes clean up transitions.
Tests cover non-status done-field, create-in-terminal, create-seed, and
cascade-on-delete; full store suite green.
2026-05-29 06:55:47 -04:00
xarmian 905876af04 feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) (#622)
* feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b)

Phase 2b of PLAN-1593 (TASK-1597). Completes the wiki-link reverse
index by indexing and surfacing `[[workspace::REF]]` cross-workspace
references. Builds on Phase 2a's title work (PR #621). Phase 3
(TASK-1596) owns the UI/MCP/CLI rendering changes.

What changed

- internal/store/backlinks_visibility.go (new): request-independent
  ACL helper `Store.ResolveBacklinksVisibility(userID, workspaceID,
  includeDeletedItems)`. Mirrors the role-determination + collection-
  merge logic from server.guestResourceFilterCore but doesn't depend
  on a request context, so cross-ws traversal can compute per-source-
  workspace ACLs without a `workspaceRole(r)` lookup. The Codex
  planning-round review caught the prior plan reusing the request-
  scoped helper as a hidden architectural cost; this is the resolution.

- internal/server/server.go: guestResourceFilterCore refactored to
  delegate to the new store helper. Keeps the request-scoped wrapper
  signature stable for all existing handler call sites; only the
  internals move.

- internal/links/extract.go: lift the Phase-2a workspace_ref emit
  gate. WikiLinkKindWorkspaceRef now flows through ExtractWikiLinks
  alongside ref and title kinds. parseBody recognition was already
  in place from earlier rounds.

- internal/store/wiki_links.go: WikiLinkKindWorkspaceRef branch in
  replaceWikiLinks stores (target_workspace_id, target_ref) verbatim,
  resolving the slug→ID via new resolveWorkspaceSlugTx (with per-call
  cache so repeated `[[ws::X]]` in one body don't re-query). Unknown
  slugs persist with target_workspace_id=NULL — broken-link
  semantics, identical to existing ref/title patterns.

- internal/store/wiki_links.go: new `Store.GetCrossWorkspaceBacklinks`
  enumerates accessible workspaces via Store.GetUserWorkspaces (which
  includes guest-only access — broader than membership query), then
  per-workspace computes visibility via ResolveBacklinksVisibility and
  runs the SQL backlinks query with the per-ws (FullCollectionIDs,
  GrantedItemIDs) predicate inline. Results sorted by updated_at DESC
  in Go, paginated globally. Per-workspace safety cap (offset+limit)
  prevents one workspace from dominating the global slice.

- internal/store/wiki_links.go: new `Store.CountBacklinks` for same-ws
  pagination boundary detection. Needed so the handler knows where
  the cross-ws tier begins for pages 2+.

- internal/models/backlink.go: new `SourceWorkspaceSlug string`
  (omitempty) field. Populated only by cross-ws rows; same-ws rows
  leave it empty so the existing wire shape is preserved.

- internal/server/handlers_backlinks.go: union pagination across
  same-ws and cross-ws tiers. Same-ws first (matches the renderer's
  UI mental model — your own workspace's links at the top of the
  panel). Count-based slice math handles pages 2+ correctly when
  same-ws is exhausted.

Tests

- internal/links/extract_test.go: workspace_ref forms emit correctly
  (bare, display alias, mixed case, invalid-slug fallback to title).
- internal/store/wiki_links_xws_test.go (new): six cross-ws scenarios
  plus a role-matrix test:
  - end-to-end cross-ws index + query
  - non-member sees nothing
  - guest with collection grant sees only that collection
  - guest with item grant sees only the granted item
  - unknown workspace slug → broken row, no query results
  - same-ws rows leave SourceWorkspaceSlug empty
  - ResolveBacklinksVisibility role matrix (admin/full member/guest
    with grants/non-member non-grant)

Out of scope (Phase 3 / TASK-1596)

UI rendering of cross-ws backlinks (workspace badge + workspace-
prefixed ref), MCP `pad_item.action: backlinks` cross-ws fields,
CLI display tweaks.

PLAN-1593 / TASK-1597.

* fix(backlinks): admin enumeration + cross-prefix ref fallback + unbounded perWsCap (Codex round 1)

Three P2 findings from Codex round 1 against PR #622:

Finding 1 — admin users miss cross-ws backlinks. `GetUserWorkspaces`
returns only memberships + grant-only guest workspaces, but
RequireWorkspaceAccess (middleware_auth.go:481) gives admins
implicit access to every workspace. An admin querying for backlinks
would silently miss links from workspaces they're not explicitly a
member of.

Fix: in GetCrossWorkspaceBacklinks, branch on user.Role:
  - admin → s.ListWorkspaces() (every non-deleted workspace)
  - non-admin → s.GetUserWorkspaces (memberships + grants)
Stale user IDs return empty result rather than erroring.

Finding 2 — cross-ws ref matching doesn't handle cross-prefix moves.
Same-ws is immune because target_item_id is resolved at parse time
and survives renames/moves; cross-ws resolves at query time, so a
`[[other-ws::OLD-42]]` row written before the target moved from
OLD→NEW collection wouldn't match a query under the NEW ref.

Fix: in queryCrossWorkspaceBacklinksForWorkspace, dual ref-match
clause: exact `LOWER(wl.target_ref) = LOWER(?)` OR
`LOWER(wl.target_ref) LIKE LOWER('%-N')` where N is the item_number
from the target ref. Pad prefixes are alphanumeric with no internal
`-`, so trailing `-N` uniquely identifies the number suffix — no
false positives like "TASK-142" matching "%-42" (LIKE anchors to
the trailing literal).

Finding 3 — per-workspace cap of 1000 silently broke pagination
beyond offset>=1000. The 1000 ceiling was defensive paranoia; the
correct math is offset+limit per workspace (worst case all rows
come from one workspace and the global slice still needs that
many).

Fix: drop the 1000 ceiling. perWsCap = offset+limit unconditionally.
For runaway offsets the per-workspace transfer cost is proportional;
documented as a known characteristic (callers shouldn't be paging
past offset=10000 anyway).

Regression tests:
- TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces: admin sees
  cross-ws backlink without being a workspace member.
- TestWikiLinks_CrossWorkspaceRefNumberFallback: move target to new
  collection, query under new ref, old-ref-stored row still surfaces.

PLAN-1593 / TASK-1597.

* fix(backlinks): honor OAuth/MCP token workspace allow-list (Codex round 2)

Codex round 2 P1: cross-workspace backlinks bypassed the OAuth/MCP
token's workspace allow-list (TASK-952). A token consented for
workspace A but with the underlying user having access to B would
still surface source rows from B via the cross-ws query — leaking
data outside the token's consent scope.

Fix: thread `allowedWorkspaceSlugs []string` through
GetCrossWorkspaceBacklinks. Handler populates it from
TokenAllowedWorkspacesFromContext(r.Context()):

  - nil → no token gate (PAT or pre-TASK-952 token, allow all)
  - "*" wildcard → allow all
  - explicit list → strict slug membership

Workspace enumeration skips any source workspace whose slug isn't
in the allowlist. The same-ws path is unchanged because
RequireWorkspaceAccess already gated the target workspace against
the allow-list (so we only reach this handler when the target IS in
the list).

Regression test in wiki_links_xws_test.go covers four shapes: nil,
wildcard, target-only (blocks cross-ws), explicit source-workspace
(allows cross-ws).

PLAN-1593 / TASK-1597.

* fix(backlinks): normalize limit at handler boundary (Codex round 3)

Codex round 3 P2: the backlinks handler parsed ?limit=N but didn't
normalize it before computing the same-ws/cross-ws pagination
split. GetBacklinks and GetCrossWorkspaceBacklinks each clamp >300
internally, but the handler's 'remaining := limit - len(sameWs)'
used the original (potentially huge) value. With ?limit=301 and
more than 50 same-ws backlinks, the first page would mix cross-ws
in before same-ws was exhausted, violating the documented tier
order.

Fix: clamp 'limit' to <=300 at the handler boundary, before any
pagination math runs.

PLAN-1593 / TASK-1597.

* fix(backlinks): normalize same-workspace [[ws::REF]] to ref-kind (Codex round 4)

Codex round 4 P2: `[[<current-ws>::TASK-1]]` was being indexed as a
workspace_ref row with target_workspace_id = current workspace. But
the same-ws GetBacklinks query requires target_item_id (workspace_ref
rows leave it NULL), AND GetCrossWorkspaceBacklinks explicitly skips
the target workspace — so the link rendered and navigated correctly
in the UI but no backlink ever surfaced.

The renderer's L307 short-circuits same-workspace fully-qualified
form to behave identically to `[[REF]]`; the index must follow.

Fix: in replaceWikiLinks, normalize a workspace_ref link to ref-kind
when its slug resolves to the current workspace. The promotion
canonicalizes the ref (via new links.CanonicalizeRef exported alias)
so `[[ws::task-5]]` stores the same canonical shape as `[[TASK-5]]`.

Tests:
- TestWikiLinks_CrossWorkspaceSameWorkspaceQualifiedNormalized:
  same-ws fully-qualified `[[ws::REF]]` surfaces in same-ws backlinks
  and is absent from cross-ws backlinks.

PLAN-1593 / TASK-1597.

* fix(backlinks): same-ws qualified ref miss doesn't title-fallback (Codex round 5)

Codex round 5 P2: my round-4 normalization was too aggressive. It
promoted `[[<current-ws>::REF]]` to ref-kind and let the regular
ref branch handle it — including the title-fallback path that
runs on ref miss.

But the renderer's same-ws qualified branch (markdown.ts:472-481)
does NOT title-fallback: a ref miss in that path returns the
wiki-link verbatim (broken). Only the bare `[[REF]]` path
(markdown.ts:513) falls through to title lookup.

So my normalization could create ghost backlinks for source bodies
like `[[ws::ISO-9001]]` when an item titled "ISO-9001" exists but
no ISO collection — the renderer renders broken text, but the
index would point at the title-matching item.

Fix: handle same-ws qualified refs inline at the top of the loop,
BEFORE the switch dispatches. Insert as ref-kind row (resolved or
NULL) and `continue` past the switch. Bypasses the title-fallback
path entirely, mirroring the renderer's behavior.

Regression test in wiki_links_xws_test.go pairs same-ws qualified
miss (must NOT title-fallback) with bare ref miss (SHOULD
title-fallback) to lock the asymmetry in.

PLAN-1593 / TASK-1597.
2026-05-24 13:27:40 -04:00
xarmian 8e7d4040fd feat(backlinks): server-side reverse index for [[...]] (Phase 1) (#620)
* feat(backlinks): server-side reverse index for [[...]] wiki-links (Phase 1)

First phase of PLAN-1593. Today [[REF]] is parsed only at render time
on the client and there's no way to ask "who links to TASK-5?" without
a full-text scan. This change adds a materialized reverse index
(item_wiki_links) that's written every time an item's content changes
and exposes it via REST + CLI.

Phase 1 covers ref-form links only (`[[TASK-5]]` / `[[TASK-5|Display]]`).
Phase 2 (TASK-1595) will extend to titles + cross-workspace; Phase 3
(TASK-1596) adds the web UI panel + MCP action.

What lands here:

* Migrations 061 (SQLite) and 040 (Postgres) create item_wiki_links
  with partial indexes on target_item_id, (target_workspace_id, target_ref),
  and target_title — the schema accommodates all 5 wiki-link forms
  up-front so Phase 2 doesn't ALTER.

* internal/links/extract.go is the canonical parser. It strips fenced
  and inline code regions before extracting [[...]] occurrences, so
  example refs in docs / code blocks don't pollute the index. Phase 1
  emits only WikiLinkKindRef rows; title and workspace_ref kinds parse
  successfully but are gated out until Phase 2.

* internal/store/wiki_links.go (replaceWikiLinks + GetBacklinks +
  helpers) handles write-time bookkeeping and the read query. Resolution
  to target_item_id happens at parse time inside the same transaction
  as the items INSERT/UPDATE, so partial state never lands. Broken refs
  (target_item_id IS NULL) intentionally persist — they feed a future
  broken-links report.

* internal/store/wiki_links_backfill.go + cmd/pad/main.go hook the
  idempotent backfill into server startup. Existing items get indexed
  on first boot after the migration; subsequent boots are near-no-ops
  via an EXISTS short-circuit.

* internal/store/items.go is amended in two places: tryCreateItem
  always calls replaceWikiLinks (empty content → no-op DELETE), and
  UpdateItemWithPreCheck re-parses whenever input.Content was supplied.

* internal/server/handlers_backlinks.go serves
  `GET /api/v1/workspaces/{ws}/items/{itemSlug}/backlinks` with
  visibility + guest-grant filtering on the source items.

* internal/cli/client.go adds GetBacklinks; cmd/pad/main.go adds the
  `pad item backlinks <ref>` command (registered in groups.go).

Behavior decisions (per PLAN-1593):
- code blocks excluded (fenced + inline)
- self-links filtered at query time (kept in storage)
- repeated mentions stored as separate rows by position
- ordering: source updated_at DESC, position ASC

Tests:
- internal/links/extract_test.go: 26 sub-cases covering ref/title/
  workspace-ref discrimination, code-block exclusion (fenced + inline +
  unclosed fence), position-is-byte-offset (UTF-8 safety), and edge
  inputs.
- internal/store/wiki_links_test.go: 8 integration tests covering the
  create/update/delete/self-link/broken-ref/repeated/code-block
  scenarios plus backfill idempotence.

All pass. `make check` clean (lint + go test + web build).

Refs: TASK-1594, PLAN-1593, IDEA-1577

* fix(backlinks): visibility-aware pagination + case-insensitive refs per Codex review (round 1)

Two fixes from Codex code review:

P1 — GetBacklinks now takes a visibleCollectionIDs []string argument
that's applied INSIDE the SQL WHERE clause. Previously the handler
fetched LIMIT raw rows and filtered visible ones in Go, so a
restricted user asking for limit=50 could receive an empty page even
when later visible backlinks existed. Pushing visibility into SQL
makes LIMIT/OFFSET count visible rows.

  nil  → no restriction (owners, editors, root tokens)
  []   → see nothing (returns early, no SQL)
  [..] → AND s.collection_id IN (?, ?, ...)

Item-level guest grants still apply post-fetch — they're rare enough
that the residual page shrink is acceptable and pushing them into SQL
would balloon the query.

P2 — refPattern now accepts mixed/lowercase refs and parseBody
canonicalizes the prefix to uppercase at the single chokepoint.
Previously the renderer accepted `[[task-5]]` as a real link (its
REF_PATTERN is case-insensitive) but the indexer's ^[A-Z]... pattern
silently dropped it — divergent parsing on the same input. Storage
shape is canonical uppercase so the (workspace, prefix, number)
lookup against collections.prefix (also uppercase) has one shape.

New helper: canonicalizeRef("task-5") → "TASK-5".

Regressions:

  internal/links/extract_test.go
    + TestCanonicalizeRef                 — helper unit tests
    + TestExtractWikiLinks_RefVsTitleFallback updated to assert
      mixed/lowercase parses-as-ref-and-uppercases
    + edge-case test renamed from "lowercase ref" to "number-led
      not a ref" (lowercase IS a ref now per Codex P2)

  internal/store/wiki_links_test.go
    + TestWikiLinks_MixedCaseRefIndexed   — `[[task-5]]` produces a
      backlink row whose target_ref is "TASK-5"
    + TestWikiLinks_VisibilityAwarePagination — three sub-cases:
      nil → all 3, visible-only limit=2 → 2 visible rows (not 1 with
      hidden one consuming a slot), empty → 0

All call sites updated (8 in tests + 1 in handler).

`make check` clean (lint + tests + web build).

Refs: TASK-1594, PLAN-1593

* fix(backlinks): SQL-level item-grant filter per Codex review (round 2)

Round 1 fixed pagination for collection-level visibility but Codex
round 2 correctly flagged the same class of bug at the item-grant
layer: `visibleCollectionIDs` returns the UNION (full grants ∪
collections containing granted items), and the handler then
filtered each row's item-level visibility in Go AFTER fetching —
letting hidden rows in a granted-item's collection consume LIMIT
slots.

The refactor moves the precise predicate into SQL. New shape:

  type BacklinksVisibility struct {
      Unrestricted      bool      // admin / full-access member
      FullCollectionIDs []string  // direct collection grants
      GrantedItemIDs    []string  // item-level grants
  }

  // SQL predicate when Unrestricted=false:
  //   AND (s.collection_id IN (?...)  OR  s.id IN (?...))

This matches `guestResourceFilter` (which returns the precise
primitives), so the handler now passes them straight through and
drops the post-fetch filter loop entirely. Pagination is correct
for guests, restricted members, and unrestricted users alike.

New test:

  TestWikiLinks_ItemGrantPagination — guest with item-grant on ONE
  item in an otherwise-hidden collection sees exactly that one item;
  hidden siblings in the same collection do NOT leak in, and limit=2
  returns 1 row (not silently shrunken).

Other call sites updated:
- TestWikiLinks_VisibilityAwarePagination → uses
  BacklinksVisibility{FullCollectionIDs: ...} and
  BacklinksVisibility{} for the no-access case.
- 8 existing tests → BacklinksVisibility{Unrestricted: true}.
- handlers_backlinks.go → no longer calls visibleCollectionIDs;
  uses guestResourceFilter exclusively and skips the Go-side filter.

Verification:
- make check clean
- All TestWikiLinks_* pass

Refs: TASK-1594, PLAN-1593

* fix(backlinks): scan EXISTS into bool not int for Postgres parity (Codex round 3)

`SELECT EXISTS(...)` returns boolean on Postgres but integer 0/1 on
SQLite. Scanning into `int` happened to work on SQLite (the modernc.org
driver coerces) but would fail on Postgres — silently disabling the
backfill short-circuit there and meaning upgraded Postgres installs
wouldn't populate backlinks for pre-existing content until each item
got edited.

Fix: scan into bool. Both database/sql drivers in use (modernc.org/
sqlite and lib/pq) coerce their native representation into Go's bool,
so this single shape works on both engines.

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): allow CommonMark 0-3 space indented fences per Codex (round 5)

Round 5 flagged two edge cases in the code-stripping pass:

1. Multi-backtick inline code (``see [[X]]``) — traced through the
   parser; my permissive close-on-next-backtick logic already covers
   it correctly (range = [opener-start, after-closer-run]). Added
   a regression test to lock this in:
     TestExtractWikiLinks_CodeBlocksExcluded /
       "multi-backtick inline code excludes ref"

2. Indented fenced blocks — CommonMark allows 0-3 leading spaces of
   indentation before a fence opener (4+ spaces makes it an indented
   code block, a different construct). My fencedCodeRanges only
   matched fences at column 0, so `   ```\n[[X]]\n```` ` would
   render as code in the UI but leak a false backlink. Fixed both
   fencedCodeRanges (opener) and findFenceCloser (closer) to skip
   up to 3 leading spaces, with a hard cap at 4 (which would be
   indented-code, not a fence). Regression test:
     TestExtractWikiLinks_CodeBlocksExcluded /
       "indented fenced block (CommonMark 0-3 spaces)"

Not addressed:
- Round-4 escape-body parity finding. extract.go mirrors
  renderMarkdown's regex (web/src/lib/utils/markdown.ts:300), which
  is the actual render-time link parser; wikiLinksToMarkdown's more
  permissive escape grammar is editor-serializer-side and the
  renderer can't even consume its escaped output. Indexing what the
  user actually sees as a link is the correct invariant.

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): tilde fences + strict closer lines per Codex (round 6)

Two CommonMark conformance gaps in the code-block stripping pass:

1. Tilde-fenced code blocks (~~~) were ignored. marked() treats them
   the same as backtick fences, so a [[REF]] inside a tilde block
   would render as code in the UI but leak as a false backlink.
   Fixed by parameterizing fenceChar across fencedCodeRanges and
   findFenceCloser, with separate handling for the backtick-specific
   "no backtick in info string" rule (CommonMark §4.5).

2. Closer-line strictness — CommonMark requires the closing fence
   line to contain only the fence + optional trailing spaces. The
   previous accept-any-fence-prefixed-line check would terminate
   a still-open fence prematurely on a line like ```not-closed,
   leaking later refs in the still-rendered code block.

Refs reside in 4 new sub-tests under TestExtractWikiLinks_CodeBlocksExcluded:
- tilde fence excludes refs inside
- tilde fence with language tag
- mixed fence types don't pair
- closer-line strictness — backticks plus other text is not a closer
- closer-line strictness — trailing spaces OK

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): inline code closer must match opener length per Codex (round 7)

CommonMark §6.1 requires an inline-code span opened with N backticks
to close on a run of EXACTLY N backticks. The previous "close on next
backtick run of any length" logic would prematurely end the excluded
range on a stray single backtick inside a ``...`` span, leaking any
[[REF]] in the latter half of the code text as a false backlink.

Concrete failure case:
  ``has ` inside [[X-1]] and more``
  → old: range [0, 7], [[X-1]] indexed (bug)
  → new: range [0, end-of-closer], [[X-1]] excluded (correct)

Fix: track the opener-run length and scan only for matching-length
closer runs. Wrong-length runs in between are code text.

Two new regression sub-cases under TestExtractWikiLinks_CodeBlocksExcluded:
- inline code closer matches opener length — the main case
- single-backtick span unaffected by adjacent multi-backtick run —
  asserts the opposite direction (opener=1 doesn't close on ``)

Not addressed:
- Re-flagged round-4/round-7 escape-body parity finding. extract.go
  intentionally mirrors renderMarkdown's regex (markdown.ts:300), not
  wikiLinksToMarkdown's more permissive escape grammar (markdown.ts:461).
  renderMarkdown is the actual link parser at display time; its regex
  rejects escaped-`]` bodies, so any link with an escaped `]` in its
  body is NOT shown as a clickable link in the UI. Indexing it would
  produce phantom backlinks the user can't see. The wikiLinksToMarkdown
  permissive grammar is paranoid serialization that the renderer can't
  consume — that's a pre-existing inconsistency in the editor pipeline,
  not a backlinks bug.

make check clean (lint + tests + web build).

Refs: TASK-1594, PLAN-1593

* fix(backlinks): rune-align snippet end-edge to keep UTF-8 valid (Codex round 8)

The previous snippetAround() trimmed `start` to a rune boundary (so
the leading edge of the snippet was always at a valid codepoint) but
left `end` as a raw +40-byte clamp. When that landed in the middle of
a multi-byte rune — common around emoji or accented text — the
resulting slice was invalid UTF-8 and the JSON encoder would emit
replacement characters in backlink snippets.

Fix: same forward-advance pattern at the end as at the start.
Continuation bytes (10xxxxxx) get skipped until we land on a leading
byte. Going forward keeps the snippet anchored slightly past the
match rather than slightly before it, which is a small UX win
(emoji or accented text right after the link survives intact).

Regression test:
  TestWikiLinks_SnippetIsValidUTF8 — pads body with enough 4-byte
  emoji on each side that the ±40-byte window cuts through one;
  asserts utf8.ValidString on the resulting snippet.

make check clean (lint + tests + web build).

Refs: TASK-1594, PLAN-1593

* fix(backlinks): inline code spans cross newlines, break on blank lines (Codex round 9)

CommonMark §6.1: an inline-code span can cross single newlines but
terminates at a blank line (a line containing no chars or only
whitespace, which ends the enclosing paragraph). My previous scanner
broke at every newline, so multi-line spans like

    `pre
    [[INSIDE-1]]
    post`

would treat the opener as unclosed and leak [[INSIDE-1]] as a false
backlink. Fixed by:

  1. The newline branch in the closer scan now peeks ahead via the
     new isBlankLineAt() helper. Same-paragraph newlines are
     traversed; blank-line breaks terminate the span unmatched.
  2. isBlankLineAt() treats any line with only space/tab as blank
     (mirroring CommonMark's blank-line definition).

Three new regression sub-cases under TestExtractWikiLinks_CodeBlocksExcluded:
  - inline code spans single newline (CommonMark §6.1)
  - inline code breaks at blank line (paragraph boundary)
  - inline code breaks at whitespace-only blank line

Trade-off: a truly-unclosed inline backtick now consumes from the
opener up to the next blank line instead of just the rest of the
line. False-positive on wiki-links in that span, but the surface
area is small (unclosed backticks are rare in published prose) and
matches the renderer's behavior.

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): accept escaped wiki-link bodies per editor grammar (Codex round 10)

After 3 rounds of disagreement, capitulating on the escape-body parity
finding. My position was technically correct for the CURRENT
renderMarkdown behavior (which uses [^\]]+ and can't parse escaped-
bracket bodies), but the editor's wikiLinksToMarkdown grammar at
markdown.ts:461 explicitly produces such bodies — making the
renderer's regex the inconsistent half of the pipeline, not mine.

Mirroring the editor's grammar in the extractor makes the index
forward-compatible: when the renderer eventually gets fixed, no
change here is needed. The cost is a few "phantom" rows in the
interim (indexed links the renderer doesn't currently display as
clickable), but those are harmless and aligned with author intent.

Changes:
- wikiLinkPattern now uses `\[\[((?:\\.|[^\]\\])+)\]\]` — mirrors
  markdown.ts:461 verbatim.
- New splitOnUnescapedPipe() helper — scans for the first `|`
  that isn't preceded by `\`. Mirrors splitWikiBody at
  markdown.ts:664.
- New unescapeWikiBody() helper — undoes `\]`, `\|`, `\\` escapes
  in display text and key. Mirrors unescapeWikiBody at markdown.ts:657.
- parseBody() now uses both helpers — split on unescaped `|`,
  unescape both sides.

Regression coverage:
- TestExtractWikiLinks_EscapedBodyChars (5 sub-cases): escaped `]`,
  escaped `|`, escaped `\`, non-escape backslash passes through,
  Position still points at opening `[[` despite escapes.
- TestSplitOnUnescapedPipe + TestUnescapeWikiBody: direct unit
  tests for the helpers (round-trip safety vs the editor's
  escape/unescape pair).

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): preserve display text verbatim per Codex round 11 P3

The previous parseBody trimmed the display side of [[X|Display]] but
the WikiLinkRef.Display contract promises verbatim storage and the
renderer at markdown.ts doesn't trim either. Trimming would silently
diverge on padded display text like [[TASK-1|  spaces  ]] (renderer
keeps the spaces, extractor stripped them).

Fix: drop TrimSpace from the suffix half of the split. Keep trimming
the key/ref side because refPattern is anchored — a leading or
trailing space in the key would force the body to fall through to
the title kind even though the renderer resolves it as a ref.

Regression test:
  TestExtractWikiLinks_EscapedBodyChars / "display text preserved
  verbatim (no TrimSpace)"

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): distinguish empty display override from no-pipe per Codex round 12

[[REF|]] (explicit empty display) and [[REF]] (no display) are distinct
shapes in the editor: splitWikiBody returns displayOverride="" for the
former, null for the latter, and the renderer uses `displayOverride ??
title` (nullish coalescing, NOT empty-string fallback) so "" is
preserved. The previous extractor collapsed both into display_text=NULL,
violating verbatim-display preservation for the empty-string edge case.

Fix:
- WikiLinkRef gains a HasDisplay bool. parseBody sets HasDisplay=true
  iff splitOnUnescapedPipe found a pipe; downstream uses HasDisplay
  (not Display!="") to decide whether to persist the override.
- replaceWikiLinks in store: NullString.Valid is keyed off HasDisplay.
  display_text='' for explicit empty, NULL for no override.

Regression coverage:
- internal/links/extract_test.go:
    "explicit empty display override is distinguished from no pipe"
- internal/store/wiki_links_test.go:
    TestWikiLinks_EmptyDisplayDistinct (two-source assert: NOT NULL
    for [[REF|]], NULL for [[REF]])

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): pointer-typed DisplayText to preserve empty distinction over JSON (Codex round 13)

Round 12 added HasDisplay on the parser side and made the store
preserve display_text='' vs NULL on the DB row, but the wire model
collapsed the distinction at JSON-serialization time:

    DisplayText string `json:"display_text,omitempty"`

`omitempty` drops empty strings, so [[REF|]] (empty override) and
[[REF]] (no override) serialized identically on the API and CLI JSON
output. The end-to-end goal of round 12 wasn't reached.

Fix: change DisplayText to *string. nil → no override (field omitted
from JSON via omitempty), pointer to "" → explicit empty override
(field present with empty value). The store's NullString.Valid drives
the assignment, so the SQL round-trip matches the JSON shape.

Knock-on: the CLI's `pad item backlinks` now dereferences the pointer
and prints both populated and empty overrides ("displayed as: ").

Regression coverage:
- TestWikiLinks_EmptyDisplayDistinct extended to assert
  withBL.DisplayText is non-nil-pointing-at-"" and noBL.DisplayText
  is nil after a GetBacklinks round-trip.

make check clean.

Refs: TASK-1594, PLAN-1593
2026-05-23 23:09:44 -04:00
xarmian 0c5ec04fac feat(admin): extend user list with aggregations + sort/filter (TASK-1544) (#599)
* feat(admin): extend user list with aggregations + sort/filter (TASK-1544)

GET /admin/users now returns per-user workspace_count, storage_bytes,
last_write_at, and a computed status pill (disabled / no-workspace /
inactive / active, with documented precedence). Adds sort and filter
knobs so the table can scale beyond the existing fixed offset/limit.

Store layer:

- AdminUserSearchParams gains Role, Sort, Order, ActiveWithinDays,
  HasWorkspaces, Disabled. Pointer types where tri-state ("no filter"
  vs. "filter to false") matters.

- AdminUserListEntry wraps models.User with WorkspaceCount, StorageBytes,
  Status — returned in AdminUserSearchResult.Users.

- SearchUsers SQL rewritten: LEFT JOIN against grouped subqueries so
  one user owning N workspaces with M attachments each still produces
  exactly one row (no aggregation explosion). Both subqueries filter
  deleted_at IS NULL to match WorkspaceStorageUsage's existing
  definition. Allow-listed sort clause prevents injection.

- computeAdminUserStatus exported for unit tests; precedence locked in
  by TestComputeAdminUserStatus.

- TestSearchUsersAggregations covers workspace_count + storage_bytes +
  status across a three-user fixture and each new filter/sort knob.

Model + scanner:

- models.User gains LastWriteAt. userColumns + scanUser updated; the
  legacy callers (GetUser, ListUsers, etc.) inherit the new field for
  free via the shared scanner.

Handler:

- handleAdminListUsers accepts the new params: role, disabled,
  has_workspaces, active_within_days, sort, order. Tri-state bools
  only fire when the query param is present. Response now embeds
  workspace_count / storage_bytes / last_write_at / status.

Part of PLAN-1542. Frontend consumption lands in T1548 (cheap columns)
and T1549 (sort/filter UI).

* fix: address Codex review on TASK-1544

- Tri-state bool parsing in handler now uses strconv.ParseBool — accepts
  the canonical truthy/falsy variants ("True"/"TRUE"/"t"/"1" and the
  parallel falses), and silently ignores garbage values rather than
  treating them as false. Closes the "disabled=TRUE silently means
  enabled-only" surprise.

- SearchUsers count query no longer joins the storage aggregation when
  HasWorkspaces isn't an active filter. The page query still needs both
  joins (the row carries the data), but a typical "give me a count"
  call no longer scans every live attachment. The workspace_count join
  remains conditional on HasWorkspaces filtering.

Status threshold (>30d vs >=30d): the documented spec and impl both say
">30d" — no change.
2026-05-20 16:50:16 -04:00
xarmian 905baaa010 feat(oauth): backfill session.Extra into oauth_connections + switch read path (TASK-1522) (#583)
* feat(oauth): backfill session.Extra into oauth_connections + switch read path (TASK-1522)

Phase C1 for PLAN-1519. Seeds existing OAuth grant chains into the new
connection tables (Phase A) and switches /console/connected-apps to
read from them, retiring the session.Extra parse on the read path.

Backfill (internal/store/oauth_connections_backfill.go)
- Walks oauth_access_tokens + oauth_refresh_tokens to find every
  distinct request_id chain (including refresh-only chains).
- Picks the newest token row per chain — its session.Extra drives
  the seeded shape, so a chain whose user re-scoped recently
  reflects the latest decision.
- Maps session.Extra shapes to the new tables per IDEA-1517 §2:
  no key → all_current=1; ["*"] → all_current=1; explicit slugs →
  all_current=0 + one join row per slug (added_by='user').
- Resolves slugs → workspace IDs; unresolved slugs (deleted /
  renamed workspace) are counted + logged at WARN, not fatal.
- Idempotent on every INSERT (OR IGNORE / ON CONFLICT DO NOTHING)
  so re-running on every startup is a cheap no-op once stable.
- Returns a BackfillOAuthConnectionsResult so the startup log
  reports chains_seen / connections_created / workspaces_added /
  unresolved_slugs — operators see fresh work and notice drift.

Read-path rewrite (internal/store/connected_apps.go)
- ListUserOAuthConnections projects AllowedWorkspaces from
  GetOAuthConnectionAccess (oauth_connection_workspaces JOIN
  workspaces) instead of parsing session.Extra strings.
- Hydrates Name + MayCreate + AllCurrent + IncludeFuture from
  oauth_connections so Phase D's mutation UI has them.
- Defensive fallback for chains without an oauth_connections row
  (any leftover the backfill missed): treats as legacy
  "any workspace, default-on flags" so the connection still
  renders. Backfill at startup keeps this branch unreachable in
  production.
- Retires parseAllowedWorkspacesFromSession; the new
  extractAllowedWorkspacesFromSessionExtra helper in
  oauth_connections_backfill.go is the only consumer of the
  session.Extra shape on the store side.

Model (internal/models/connected_apps.go)
- Adds Name / MayCreateWorkspaces / AllCurrentWorkspaces /
  IncludeFutureWorkspaces. AllowedWorkspaces semantics stay
  stable (nil = "any"; explicit slugs = chip list) so the
  existing DTO + frontend continue working unchanged. Phase D
  exposes the new fields on the wire.

Startup wiring (cmd/pad/main.go)
- After srv.SetOAuthServer / SetClaimSecret, run the backfill
  once. Non-fatal on error (partial state is consistent and the
  next run completes). Quiet at the Debug level on steady-state
  re-runs; INFO when fresh work landed.

Tests
- 8 BackfillOAuthConnections cases: empty DB, pre-TASK-952
  (no key), wildcard, explicit list, mixed resolvable/unresolved
  slugs, multi-row chain newest-row-wins, refresh-only chain,
  idempotent re-run (verified via post-run row count).
- TestExtractAllowedWorkspacesFromSessionExtra replaces the
  retired parseAllowedWorkspacesFromSession test — covers all
  three IDEA-1517 §2 input shapes + malformed/non-array
  defensive cases.
- TestListUserOAuthConnections_DeduplicatesChain +
  TestHandleListConnectedApps_DTOShapeAndAuditEnrichment updated
  to call BackfillOAuthConnections (the production startup
  hook) before asserting on AllowedWorkspaces — mirrors the
  real-world flow now that the read path no longer parses
  session.Extra inline.

Parent: PLAN-1519.

* fix(oauth): backfill counters reflect actual new rows per Codex review (round 1)

PR #583 Codex review round 1 flagged that the backfill counters
over-report on steady-state restarts:

- wasFreshlyInserted compared updated_at vs created_at — true for
  every untouched existing row, so every restart counted every
  pre-existing connection as "created."
- slugsAdded++ ran after AddConnectionWorkspace regardless of
  whether the INSERT OR IGNORE / ON CONFLICT DO NOTHING hit an
  existing row.

Net effect: startup logs "backfill complete" with non-zero counts
on every restart instead of the intended quiet "no-op" path —
making real fresh work indistinguishable from steady-state.

Fix: probe existence BEFORE the insert on both sides.

- backfillOneChain reads GetOAuthConnection first; only sets
  created=true and runs insertOAuthConnectionIfAbsent on a miss.
- Per-slug: IsConnectionWorkspaceAllowed pre-check; skip + don't
  increment when the row already exists.

Two cheap PK / indexed lookups per chain. Pre-Phase-C deployments
have small chain counts so the added cost is well below the scan
already running.

Removed the now-unused wasFreshlyInserted helper. Added an
assertion in TestBackfillOAuthConnections_Idempotent that both
ConnectionsCreated and WorkspacesAdded report 0 on the second
run — the regression guard for this exact finding.

Parent: PLAN-1519.

* fix(oauth): backfill skips slug re-seed on existing rows per Codex review (round 2)

PR #583 round 2 caught that the round-1 fix protected the parent
oauth_connections row from re-seed but left the join table
mutable from stale session.Extra:

When a user removes a workspace from their connection's allow-list
via Phase D's mutation UI (RemoveConnectionWorkspace), the next
server restart would re-run the backfill, find the parent row
intact, and re-INSERT the removed slug from the original
session.Extra. The user's removal would silently revert every
restart.

Fix: backfill is a one-shot seed. Once the parent row exists, the
new tables are authoritative — legacy session.Extra is frozen
reference data, not a reconciliation source. The slug loop only
runs when we just inserted a fresh parent row.

Added TestBackfillOAuthConnections_DoesNotResurrectRemovedWorkspace
as the regression guard: seeds two slugs, removes one, runs
backfill again, asserts the removed slug stays gone and the kept
slug is untouched.

Parent: PLAN-1519.

* fix(oauth): atomic per-chain backfill transaction per Codex review (round 3)

PR #583 round 3 caught that round 2's "only seed slugs on fresh
parent" gate introduced a permanent-partial-state risk: if the
process crashes (or AddConnectionWorkspace errors) between
inserting the parent row and finishing the slug loop, the next
backfill sees created=false, short-circuits the slug seeding, and
leaves the connection permanently scoped to a partial allow-list.

Fix: per-chain transaction. Parent insert + every slug insert
land in one BEGIN/COMMIT pair; any mid-loop failure rolls
everything back. The next backfill then sees the chain as un-seeded
and retries from scratch — preserving both round 2's
"no-resurrection of user-removed slugs" (existence probe inside
the tx) and round 3's "no permanent partial seed" (atomic commit).

Scope: per-chain (small tx), not whole-backfill. The original
no-transaction rationale was about lock-hold duration across
thousands of chains; that doesn't apply at chain granularity (one
parent + a handful of join rows = sub-millisecond hold).

Removed the now-unused insertOAuthConnectionIfAbsent helper; the
INSERTs live inline within the transaction.

Added TestBackfillOAuthConnections_AtomicOnMidLoopFailure as the
regression guard: forces a mid-loop INSERT failure via a duplicate
slug in session.Extra (which violates the join table's PK on the
second insert), asserts the parent row rolled back, then runs a
clean retry and verifies full seed completion.

Parent: PLAN-1519.

* fix(oauth): surface store errors from backfill + list path per Codex review (round 4)

PR #583 round 4 caught two silent-fallthrough paths that could
leak partial/incorrect state instead of failing loudly:

1. Backfill slug loop: GetWorkspaceBySlug errors were treated the
   same as "workspace not found" — both incremented slugsMissed
   and continued. A real I/O error mid-loop would commit a
   partial allow-list, and the next backfill's parent-exists
   short-circuit would make that partial scope permanent.
   Fix: distinguish (nil, nil) "not found" from (nil, err)
   "real failure" — return the error so the per-chain
   transaction rolls back and the next run retries cleanly.

2. ListUserOAuthConnections hydration: GetOAuthConnectionAccess
   and GetOAuthConnection errors collapsed into the "no
   oauth_connections row" defensive-fallback branch, returning
   the legacy "any workspace, default-on flags" shape. On a
   real store failure that silently broadens a user's scope —
   e.g. a connection the user explicitly removed a slug from
   would render as "Any workspace" until the store recovered.
   Fix: surface store errors from both calls; the defensive
   fallback path is now exclusively for HasConnection=false,
   not for error masking.

Both findings tighten the failure mode from "silently emit
broadened/partial state" to "surface the error so retries
happen against accurate data." Existing tests cover the happy
paths; the failure paths are exercised by I/O errors against
the same store interfaces (no new test added — the change is
"return err instead of swallow it" and the assertion of NOT
swallowing is the diff itself).

Parent: PLAN-1519.
2026-05-18 03:32:42 -04:00
xarmian e59d3904c9 feat(server): refuse to mark item terminal while it has open children (IDEA-1494) (#571)
* feat(server): refuse to mark item terminal while it has open children (IDEA-1494)

Server-side guard inside handleUpdateItem that rejects a non-terminal →
terminal done-field transition when the item still has at least one
non-terminal child. Returns HTTP 409 with code=open_children plus a
structured details payload listing each blocking child's
{ref, title, status, collection_slug} so MCP-driven agents can
self-recover (ship the listed children, then retry) and the CLI can
render the same list verbatim.

Escape hatch: `--force` on `pad item update` / `pad item bulk-update`
and `force: true` on the MCP pad_item.action: update / bulk-update
inputs both forward into the same ItemUpdate.Force transport field
the handler consumes before any store mutation.

Trigger conditions are tight: the PATCH must change the done-field key
(resolved via TerminalValuesForDoneField against the parent's schema +
settings) AND the new value must be terminal AND the current value
must NOT already be terminal. Terminal → terminal and no-op terminal
transitions bypass the guard; only entering the terminal set is gated.
Per-child evaluation uses the child's own collection schema so
hierarchical workspaces with custom typed collections work without
extra plumbing.

Tests cover: rejection with one open child (with mutation-safety
assertion on the parent), no children, all-terminal children, --force
override, no-op terminal → terminal, terminal → terminal,
non-terminal → non-terminal, custom collection terminal_options
honored, and a parent task (not a plan) — IDEA-1494 optional extra #3.
MCP coverage asserts --force round-trips through both ExecDispatcher
and HTTPHandlerDispatcher and is omitted when force=false.

* fix(server): open-children guard round 2 — visibility, MCP pass-through, TOCTOU (IDEA-1494)

Three Codex round-1 issues, each fixed with the recommended shape:

P1 — visibility leak. The 409 response previously listed every blocking
child by ref/title/status, including children in collections the caller
couldn't see. The INVARIANT still evaluates against ALL children (it's a
data-integrity gate — a restricted user must not be able to close a
parent whose blockers they can't see), but the response payload now
filters to caller-visible children only. Hidden blockers surface as a
new `details.hidden_blocker_count` field plus an alternate human message
when every blocker is hidden ("blocked by N open children you don't
have access to"). Mirrors the visibility helpers (`visibleCollectionIDs`
+ `isItemVisibleToGuest`) used by the per-parent progress endpoint so
the two paths can't drift.

P2 — MCP code/details pass-through. The HTTP classifier was collapsing
409 into the generic `conflict` code and dropping `details`; the stdio
classifier was matching the human "cannot " message against the
validation regex and surfacing `validation_failed`. Both now surface
`open_children` with the structured details intact:
  - HTTP: classifyHTTPStatusKind's 409 branch extracts the upstream
    code; any non-empty, non-"conflict" code is passed through with
    its `details` RawMessage. Generalizes beyond open_children — any
    future structured 409 from a handler gets the same treatment.
  - Stdio: the CLI writes a `pad-error: {json}\n` marker line on
    stderr before the human-readable block (single source of truth for
    both views), and classifyExecError detects the marker and lifts
    the envelope verbatim. Marker is duplicated as a const between
    internal/cli and internal/mcp to avoid pulling the cli package
    into the classifier just for one string.
A new ErrOpenChildren error code constant + `Details json.RawMessage`
field on ErrorPayload back the wire shape.

P2 — TOCTOU. The guard previously ran in the handler before the store
transaction began; a concurrent child insert / child status flip could
slip between the children-list read and the parent's UPDATE. Fix:
  - New `Store.UpdateItemWithPreCheck(id, input, precheck)` runs the
    caller's invariant check inside the same tx, after acquiring the
    workspace seq lock AND a new parent-children advisory lock keyed
    on the parent ID. UpdateItem is now a thin wrapper passing nil.
  - Every UpdateItem unconditionally acquires the parent-children
    advisory lock for its own parent (if any) AND for itself-as-parent,
    in a fixed order (parent first) so two updaters touching the same
    parent always grab that key before the more-specific one — no
    AB/BA deadlock.
  - New `GetChildItemsTx` reads via the caller's tx; on Postgres the
    advisory lock provides the snapshot guarantee (DISTINCT precludes
    `FOR UPDATE`), on SQLite the global BEGIN IMMEDIATE write lock
    serializes all writers.
  - Handler now passes a precheck closure into UpdateItemWithPreCheck
    at all three call sites (collab-snapshot path, applier-direct-write
    path, main path). The guard's openChildrenGuardError sentinel is
    unwrapped after each call so the 409 surfaces cleanly.

Tests:
  - TestOpenChildrenGuard_VisibilitySanitizesPayload — restricted
    editor sees parent + visible child, hidden child contributes to
    hidden_blocker_count, no leak of ref/title/slug.
  - TestOpenChildrenGuard_AllBlockersHiddenSurfaceGenericMessage —
    open_children=[], hidden_blocker_count>0, message mentions "you
    don't have access to."
  - TestOpenChildrenGuard_TOCTOURace — 8 iterations of a child-flip
    racing a parent-terminal update; asserts the forbidden outcome
    (parent=completed AND child=open) never occurs.
  - TestClassifyHTTPStatus_OpenChildrenPreservesCodeAndDetails +
    inverse generic-409 test.
  - TestClassifyExecError_OpenChildrenMarkerLiftsStructuredPayload +
    no-marker-falls-through inverse.

* fix(server): open-children guard round 3 — 7 Codex findings closed (IDEA-1494)

P1 — visibility fail-closed. The handler was swallowing
visibleCollectionIDs errors, leaving visIDs==nil which the guard
treats as unrestricted, leaking hidden-child metadata. Now surfaces
the error as 500 BEFORE installing the precheck. Test:
TestOpenChildrenGuard_VisibilityLookupErrorFailsClosed closes the
store DB and asserts no 409+children leak.

P1 — link mutations acquire the advisory lock. SetParentLink,
ClearParentLink, CreateItemLink (when link_type ∈ childLinkTypes via
new isChildLinkType helper), DeleteItemLink (same condition), and
RestoreItem now take `pad:parent-children:<id>` in canonical sorted
order via new AcquireParentChildrenLocks helper. SetParentLink locks
BOTH old and new parents (re-parenting case). Race test
TestOpenChildrenGuard_LinkMutationRace asserts the forbidden
"link-committed-before-parent-flip AND parent flip succeeded" never
occurs by comparing link.created_at to parent.updated_at. Documented
semantics: status-wins + link-after-commit is legal under the
invariant "no open children EXIST AT THE MOMENT of transition" —
the post-condition variant ("no open child may EVER attach to a
terminal parent") is intentionally deferred.

P1 — MoveItem bypass closed. New MoveItemWithPreCheck mirrors
UpdateItemWithPreCheck — acquires workspace seq lock + parent-children
locks, re-reads in tx, runs caller precheck. handleMoveItem builds
the same guard closure using the DESTINATION schema for done-field
resolution (conservative — honors the schema the item moves INTO).
CLI gains `pad item move --force`, client gains MoveItemWithForce
that appends `?force=true` to the move endpoint. MCP catalog +
mapItemMove forward `force` through the route mapper. Tests:
TestOpenChildrenGuard_MoveItem_RejectsTerminalWithOpenChildren and
…_ForceOverrides.

P2 — pre-tx field-read TOCTOU. UpdateItemWithPreCheck and
MoveItemWithPreCheck now re-read the item via new getItemTx INSIDE
the tx (after locks) and pass that fresh snapshot to the precheck
closure; the precheck classifies the transition against the in-tx
view, not the handler-side pre-tx capture. Handler precheck closure
swaps `currentFieldsJS` from the in-tx snapshot. Test:
TestOpenChildrenGuard_PrecheckReadsInTxSnapshot stages a between-load
status mutation and asserts the precheck observes the post-mutation
fields.

P2 — bulk-update carries structured errors. cmd/pad/main.go's
updateFailure struct extended with Code + Details
(json.RawMessage). When client.UpdateItem returns *cli.APIError, the
row preserves the structured envelope. Human-text output also
renders the open-children list inline. Chose JSON-envelope route
over per-row stderr markers because bulk-update already produces a
structured envelope and ExecDispatcher returns stdout verbatim on
exit-0 — no classifier change needed. Test:
TestBulkUpdateStructuredFailuresCarryOpenChildrenDetails confirms
the wire shape the CLI lifts.

P3 — marker hardening. Marker bumped to versioned form
`pad-structured-error/v1:` (was `pad-error:`). cli.StructuredErrorMarker
+ mcp.structuredErrorMarker kept in lockstep with cross-references.
mcp.allowedStructuredErrorCodes whitelists known codes (currently
just open_children); unknown codes fall back to regex classification.
Marker must start the line after whitespace trim (embedded markers
ignored). Last-marker-wins to defeat pre-emption attacks. Tests:
TestClassifyExecError_{UnknownStructuredCode,OldMarkerVersion,
MarkerEmbeddedMidLine,LastMarker}.

P3 — soft-deleted collection schemas honored. New GetCollectionAnyState
mirrors childrenDoneFiltersForParent's inclusion rule; guard uses it
so a child still attached to a soft-deleted collection is evaluated
against ITS schema (custom terminal_options) instead of the default-
status fallback (which would mis-classify and false-block). Test:
TestOpenChildrenGuard_SoftDeletedCollectionSchemaHonored seeds a
custom collection, soft-deletes it while a child remains, and
asserts the terminal status is correctly recognized.

Comprehensive store-mutation audit results recorded in the PR
description (every method touching items.fields / items.collection_id
or item_links).

* fix(server): open-children guard round 4 — multi-parent locks, enum parity, PATCH atomicity (IDEA-1494)

Four Codex round-3 (blast-radius lens) findings, each fixed with the
recommended shape.

P1 — multi-parent lock set. acquireParentChildrenLocksForUpdate and
RestoreItem previously used `LIMIT 1` against item_links, so a child
with BOTH a `parent` link to P1 AND an `implements` link to P2 only
locked one of them. The other parent's open-children precheck could
race against the child's status flip and miss it.

Fix: new listParentChildLockKeys helper runs the same query
GetChildItems' inclusion rule uses (childLinkTypes), returns ALL
distinct parent target_ids, and feeds them into the canonical
multi-lock helper. Both UpdateItemWithPreCheck and RestoreItem now
acquire locks on {self} ∪ {all-parents-via-childLinkTypes}. Test:
TestOpenChildrenGuard_MultiParentChildLocksAll races a child status
flip against terminal-updates on both parents simultaneously.

P2 — lock-order asymmetry. The pre-fix codebase had multiple lock-
acquisition shapes: parent-then-self in acquireParentChildrenLocksForUpdate,
single-key in RestoreItem / CreateItemLink / DeleteItemLink /
ClearParentLink, and a sorted multi-key in SetParentLink. Two
concurrent callers using different ad-hoc orderings could AB/BA
deadlock.

Fix: removed the per-call-site AcquireParentChildrenLock helper
entirely. Every site now goes through AcquireParentChildrenLocks
(the canonical sorted multi-lock helper) — including ones that need
only one ID (the variadic call still sorts a one-element slice).
The helper's doc comment explicitly states the contract: "Ad-hoc
single-key acquisition outside this helper is FORBIDDEN — two call
sites taking distinct keys in different orders WILL deadlock."
Test: TestOpenChildrenGuard_NoDeadlockUnderReverseOrderConcurrency
runs reverse-order re-parents with a 5-second timeout; assertion
fails on hang.

P2 — HTTP/stdio code-surface parity. Round 2's HTTP pass-through
("any non-conflict upstream code") silently widened the ErrorCode
enum beyond stdio's allow-list (`open_children` only). Agents saw
different code surfaces depending on which dispatcher delivered
the response.

Fix: HTTP 409 branch in classifyHTTPStatusKind now consults the
same allowedStructuredErrorCodes whitelist stdio does. Codes
outside the set collapse to ErrConflict (no details), matching
what stdio does for an unknown-code structured marker. Doc on
allowedStructuredErrorCodes updated to make the dual-consumer
contract explicit: "Adding a new structured code is a TWO-WAY
change." Tests:
TestClassifyHTTPStatus_UnknownConflictCodeFallsBackToErrConflict
and TestStructuredErrorCodeParityAcrossTransports.

P3 — PATCH atomicity. A combined PATCH with `parent` + `status=terminal`
on an item with open children used to commit the parent-link change
INLINE (before the guard ran) and then reject the field write.
Caller saw 409 but the parent had already moved.

Fix: parent-link mutation is now DEFERRED — captured into outer-
scope vars during fields validation, executed AFTER
UpdateItemWithPreCheck succeeds. A guard rejection returns before
the link write block, so on rejection the link is untouched.
Documented choice: "reorder, don't tx-wrap" — wrapping SetParentLink
into the same store tx would require threading a *sql.Tx through
the SetParentLink API (which is also called from the
handler_item_links path); reordering is the smaller surgery and
gives the correct outcome on the failure direction. A residual
window remains in the OTHER direction (field write commits, link
write fails) — not made worse by the reorder, and called out
inline for a future tx-wrap pass.

Test: TestOpenChildrenGuard_PatchAtomicRejectionPreservesParentLink
sets up target → oldParent → openChild, sends PATCH {parent=newParent,
status=completed}, asserts 409 AND target.parent_link still points
at oldParent.

* fix(server): open-children guard — emit details.open_children as [] not null on hidden-only rejection (IDEA-1494)
2026-05-17 00:15:52 -04:00
xarmian ec71903be7 feat(store): JSONB NOT NULL hardening on items/views + handler shape validation (IDEA-1486+1488) (#566)
* feat(store): NOT NULL hardening on items.fields/tags + views.config (IDEA-1486)

Paired ship of IDEA-1486 (sibling-table JSONB NOT NULL hardening) and
IDEA-1488 (handler-layer shape validation for ViewUpdate/CollectionUpdate).
Generalizes the IDEA-1484 / collections.settings precedent (PR #562) to the
remaining nullable JSON columns and closes the shape-validation gap that
NOT NULL alone doesn't cover.

Schema layer (IDEA-1486 floor):
- migrations/056_items_jsonb_not_null.sql: rebuild items with
  fields TEXT NOT NULL DEFAULT '{}' and tags TEXT NOT NULL DEFAULT '[]',
  preserving all 7 indexes, recreating the 3 items_fts triggers, and
  rebuilding the FTS5 index. Foreign-keys-off / on bookends are lifted
  outside the IDEA-1485 atomic-tx wrapper.
- migrations/057_views_config_not_null.sql: rebuild views with
  config TEXT NOT NULL DEFAULT '{}'.
- pgmigrations/035 + 036: SET NOT NULL + SET DEFAULT on the three JSONB
  columns. Split per-table to mirror the SQLite per-table file granularity.

Store layer (IDEA-1486 floor):
- items.go UpdateItem and views.go UpdateView normalize "" -> "{}" / "[]"
  before writing. Same boundary pattern as CreateItem and the IDEA-1484
  precedent at collections.go:248.
- export.go ImportWorkspace coerces empty-string AND malformed JSON at
  import time on items.fields, items.tags, and collections.settings.
  Malformed input is coerce-and-log via slog.Warn (length only, never raw
  value) so legacy bundles don't fail-stop on one bad row.
- remapFieldIDs early-returns "{}" on empty input so the second-pass
  UPDATE can't write "" verbatim.
- Migrated the existing fmt.Printf at export.go:329 to slog.Warn for
  consistency.

Handler layer (IDEA-1488 ceiling):
- ViewCreate / ViewUpdate UnmarshalJSON via flexJSONToString with new
  ErrInvalidConfigType sentinel.
- CollectionCreate / CollectionUpdate UnmarshalJSON with new
  ErrInvalidSettingsType sentinel.
- handlers_views.go and handlers_collections.go surface both sentinels
  as 400 with the domain-level message (mirrors the BUG-1144 precedent at
  handlers_items.go:641).

Tests:
- internal/store/items_views_jsonb_test.go: store-coercion + import
  coercion + log-and-coerce-on-malformed + SQLite schema introspection
  (7 indexes + 3 FTS triggers + items_fts virtual table survival) +
  Postgres NOT NULL enforcement + migration re-apply idempotency +
  item_links round-trip after rebuild.
- internal/server/handlers_views_collections_jsonb_test.go: PATCH/POST
  flexible-shape coverage for views.config and collections.settings,
  including domain-level 400 message assertions that the response does
  not leak Go unmarshal internals.

Refs: IDEA-1486, IDEA-1488, IDEA-1484 (precedent), IDEA-1485 (substrate).

* fix(store,models): codex R1 follow-ups for IDEA-1486 / IDEA-1488

Three concrete defects surfaced by codex R1 against the initial paired
ship. All three close holes that defeated parts of the original contract.

P1.1: migration 056 missed the playbook invocation_slug unique index.
- migrations/056_items_jsonb_not_null.sql: recreate the partial UNIQUE
  index idx_items_invocation_slug_per_collection from migration 054
  verbatim after the other 7 indexes. Without it, the application-layer
  pre-check in handlers_items.go:checkUniqueFields would be a TOCTOU
  race with no DB-level guard — the original index that 054 explicitly
  added as the actual uniqueness backstop would be silently dropped
  during the items rebuild.
- items_views_jsonb_test.go: the schema-introspection test now asserts
  8 indexes, not 7. Verified via `grep -rn "ON items(" migrations/`
  that no other items-touching indexes were missed.

P1.2: flexJSONToString didn't validate inner content of JSON-encoded
strings. Pre-fix, `{"config": "[]"}` / `{"settings": "not json"}` /
`{"fields": "[]"}` / `{"tags": "{}"}` slipped past the shape validators
because the `case '"'` branch unmarshalled the envelope and returned
the inner string verbatim — bypassing the whole point of IDEA-1488.
- models/item.go: after unmarshalling the JSON-encoded string, validate
  that the trimmed inner content's first byte matches expectedStart
  ('{' / '[') AND parses as JSON. Empty inner strings still pass
  through to the store-layer empty-string coercion (IDEA-1486 floor),
  so legacy "" → default normalization is preserved.
- The pre-existing ItemUpdate fields/tags path inherits the same
  tightening because it routes through this helper — covered by new
  test file handlers_items_jsonb_inner_shape_test.go.
- Parallel handler tests for views.config and collections.settings
  added to handlers_views_collections_jsonb_test.go.

P2: coerceJSONForImport accepted JSON null as well-formed.
- store/export.go: json.Unmarshal("null", &m) returns err=nil with m
  staying nil; the prior code returned the raw "null" string verbatim,
  which lands as JSONB null on Postgres (satisfies NOT NULL since SQL
  NULL ≠ JSONB null) or text "null" on SQLite. The non-nil check on
  the unmarshalled value routes JSON null to the existing
  log-and-coerce path with the rest of the malformed shapes.
- items_views_jsonb_test.go: extended import test with an item
  carrying fields=null / tags=null; expects both coerced to "{}" /
  "[]" and the structured slog.Warn emitted.

Verified: make test (SQLite) and the full ./... suite against the
existing port-5445 Postgres container both pass cleanly.

Refs: IDEA-1486, IDEA-1488, codex R1 review.

* fix(store,server): codex R2 follow-ups for IDEA-1486 / IDEA-1488

Two defects surfaced by codex R2. P1 is a real ship-breaker; P2 closes
a parity gap that R1 missed.

P1: migration backfill normalized only SQL NULL, not malformed/wrong-
shape JSON.

The four new migrations originally wrote `WHERE x IS NULL`. Rows with
fields = '' / 'null' / '[]' / 'not json' all survived the filter, then
violated the post-migration NOT NULL+shape contract. Concrete ship-
breaker on SQLite: 056 recreates the partial UNIQUE index on
json_extract(fields, '$.invocation_slug') from migration 054, and
json_extract errors on rows whose fields fails json_valid — a single
bad row breaks CREATE INDEX mid-migration. Toggle-verified: with the
NULL-only WHERE, the new SQLite test fails at exactly that CREATE
INDEX with "SQL logic error: malformed JSON (1)".

Widened the backfill clauses:
- migrations/056: UPDATE items WHERE fields IS NULL OR json_valid(fields)=0
  OR json_type(fields)!='object' (same trio for tags with 'array').
- migrations/057: same trio for views.config.
- pgmigrations/035: WHERE fields IS NULL OR jsonb_typeof(fields)!='object'.
  JSONB rejects invalid JSON on write so the json_valid leg isn't
  needed on Postgres; only the shape check matters.
- pgmigrations/036: same shape check on views.config.

Regression tests in internal/store/items_views_jsonb_test.go:
- TestItemsViewsJSONB_SQLiteBackfillRepairsMalformedShapes: applies
  migrations through 053 (skipping 054 which would itself error on
  malformed rows), seeds every observable shape pathology — SQL NULL,
  empty string, JSON null literal, wrong-shape JSON, non-JSON garbage —
  then applies 055/056/057. Asserts every malformed row is repaired AND
  the partial UNIQUE index actually fires on duplicate invocation_slug
  post-rebuild (proving the CREATE INDEX path executed end-to-end).
- TestItemsViewsJSONB_PostgresBackfillRepairsMalformedShapes: parallel
  Postgres coverage; seeds JSONB null / array / primitive via direct
  ::jsonb cast and asserts the widened WHERE clause repairs each.

P2: handleCreateItem didn't unwrap ErrInvalidFieldsType/ErrInvalidTagsType.

R1's flexJSONToString tightening propagated the sentinels through every
UnmarshalJSON path, but handleCreateItem (POST /items) still returned
'invalid JSON: <wrapped>' from decodeJSON. PATCH and the view/collection
POST/PATCH handlers already unwrapped — POST was the outlier.

- internal/server/handlers_items.go: mirror the PATCH-side errors.Is
  handling at the POST path. Brief, three-line diff.
- handlers_items_jsonb_inner_shape_test.go: new TestCreateItem_
  JSONEncodedStringInnerShapeValidated covers POST with fields=`[]`,
  fields=42, tags=`{}`, tags={"x":1}, plus a valid positive control.
  Asserts no "invalid JSON:" wrapper and presence of the sentinel
  message verbatim.

Backfill-pattern audit (codex R2's grep prompt): only 055 / pg-034
(collections.settings, already shipped) exhibits the same NULL-only
WHERE gap. Per the brief: NOT touched — retroactive repair belongs to
a separate IDEA. Other NULL-only backfills (043/pg-023's
oauth_providers, 044/pg-024's expires_at) handle their respective
shapes correctly or aren't JSON columns.

Verified: make test (SQLite) clean. Full ./... suite against the
existing port-5445 Postgres container clean (one unrelated flake in
internal/collab passed on rerun).

Refs: IDEA-1486, IDEA-1488, codex R2 review.
2026-05-16 10:15:59 -04:00
xarmian 438cb6180a fix(mcp): flexible JSON shapes on item create + clearer field surface (BUG-1431, BUG-1432) (#547)
* fix(mcp): flexible JSON shapes on item create + clearer field surface (BUG-1431, BUG-1432)

BUG-1432 root cause (real): models.ItemCreate.Tags is a Go string, so
the default unmarshaler rejected the natural JSON-array shape every
agent sends (`tags: ["foo","bar"]` → "cannot unmarshal array into Go
struct field ItemCreate.tags of type string", HTTP 400). On Postgres
the alternative — passing `tags: "foo,bar"` per the catalog's old
"Comma-separated tags" description — landed as a non-JSON value in
the JSONB column and surfaced as a generic HTTP 500. SQLite's TEXT
column silently accepted the corrupt value, which is why local repros
didn't show it.

Codex's independent investigation called out the asymmetry: ItemUpdate
already had a flexible UnmarshalJSON for `fields`/`tags` per BUG-1144,
but ItemCreate didn't. This PR mirrors that flexibility on the create
path and aligns the MCP surface description with reality.

BUG-1431 root cause (real, not the misdiagnosis the agent reported):
the dispatcher's `parseFieldKVP` only accepted the CLI-style array-of-
"key=value" shape, rejecting the JSON-native `field: {key: value}` map
shape with "expected array or string, got map[string]interface {}".
Agents naturally try the map shape and got a non-actionable error;
that drove the BUG-1409 agent to mis-blame status placement. Empirical
repro confirmed that `status` actually works in both top-level AND
inside-fields positions today (Tests 1, 4 in the investigation); the
real surface problem was the missing map shape on `field`.

Changes:

- internal/models/item.go: add UnmarshalJSON to ItemCreate mirroring
  ItemUpdate's BUG-1144 pattern. Accepts `fields` as object or
  JSON-encoded string; `tags` as array or JSON-encoded string; either
  field absent / null leaves Go zero value. Wrong shapes surface
  ErrInvalidFieldsType / ErrInvalidTagsType (existing sentinels) so
  agents see clean domain errors instead of "Go struct field" leaks.

- internal/mcp/dispatch_http.go: parseFieldKVP now accepts
  map[string]any in addition to the existing array/string shapes. Map
  shape preserves non-string values verbatim (e.g. number from a typed
  flag), matching the array path's existing pass-through for non-string
  entries.

- internal/mcp/catalog_item.go: update `tags` description from
  "Comma-separated tags" (wrong on both SQLite and Postgres) to
  "Tags as a JSON array of strings, e.g. [\"v1\",\"frontend\"]". Update
  `field` description to clarify it's the escape hatch for
  SCHEMA-DECLARED custom fields, name the dedicated top-level params
  agents should reach for instead (status/priority/category/parent/
  role/assign/tags), and note the new map-shape acceptance. Tool-level
  prose updated to match.

Tests:

- TestItemCreateUnmarshalFlexFields (mirror of
  TestItemUpdateUnmarshalFlexFields): 9 cases covering array/string/
  null/absent/wrong-shape tags + object/string/array fields, plus a
  smoke test that other fields decode normally alongside the new
  flex paths.

- TestParseFieldKVP_Variants: extended with 3 new map-shape cases
  (basic map, empty-key-skipped, non-string-value preserved).

End-to-end verification: 5 input shapes via curl against the live
handler. Pre-fix `tags: ["foo","bar"]` returned HTTP 400; post-fix
returns HTTP 201 with `tags="[\"foo\",\"bar\"]"` in the column.
`tags: {x:1}` (wrong shape) now returns a clean
domain-level 400 instead of leaked Go internals. Existing back-compat
paths (JSON-encoded string forms) preserved.

Related: PR #546 (BUG-1430 rate limit) addressed the original 500
cascade that drove the agent's specific misdiagnoses in BUG-1409.

* fix(mcp): forward tags array on update + drop unsupported map-shape doc per Codex review (round 1)

Codex round 1 caught two issues:

[P1] dispatch_http_advanced.go's PATCH builder filtered on `string`
only when forwarding `tags`, so a schema-conforming
`pad_item.update tags: ["a","b"]` was silently dropped. Now forwards
verbatim like mapItemCreate does — the handler's ItemUpdate
flex-unmarshaler (BUG-1144) normalizes any shape downstream.
Regression test added.

[P2] The `field` description claimed `{key: value}` map shape was
accepted, but the schema Type stays `array<string>` so schema-following
clients won't send the map shape. parseFieldKVP's map-shape handling
(added in the previous commit) stays as defensive parsing for clients
that ignore the schema, but the description no longer promises a shape
the published schema doesn't advertise. Tool-level prose updated to
match.

* fix(mcp): revert speculative parseFieldKVP map-shape support per Codex review (round 2)

Codex round 2 [P2] pointed out the map-shape parseFieldKVP support
added in the first commit is dead code in practice:

1. The advertised schema for `field` is `array<string>` — no
   schema-conforming client sends a map.
2. `BuildCLIArgs` rejects map-shaped repeatable flags before they
   reach the HTTP dispatcher.
3. Even if a map did reach the dispatcher, `hasFieldChanges`
   doesn't recognize map shapes as field changes — `pad_item.update
   field: {effort: "l"}` would skip the merge and PATCH without
   `fields`.

Either completing the support (fix hasFieldChanges + BuildCLIArgs +
ItemUpdate Unmarshal) OR reverting was the right call. Reverting
keeps the surface consistent with the schema and removes the
unreachable code; future agents who want to override fields can use
the documented `["key=value"]` array shape.

BUG-1431's functional fix lands as the catalog description tightening
(the empirical repro confirmed `status` placement already works in
both forms; the agent's misdiagnosis was rooted in unclear docs, not
broken code). BUG-1432's flexible JSON unmarshal on ItemCreate stays
— that's the real fix verified by the live-handler repro.

* fix(mcp): preserve empty-string tags no-op + table-driven test per Codex review (round 3)

Codex round 3 [P2] caught a regression introduced in round 1's fix: by
switching the tags forwarding guard from \`v.(string) && v != ""\` to
\`v != nil\` to support array shapes, the empty-string filter for tags
on update was lost. \`pad_item.update tags: ""\` would now forward an
empty string to ItemUpdate, which treats it as an explicit
empty-string write — corrupting the JSON/JSONB tags column (500 on
Postgres).

Fix: type-switch on tags. Empty string skips (matches pre-fix
behaviour); arrays (including empty array \`[]\`, the legitimate
"clear tags" case) and non-empty strings forward.

Tests: the single-shape array test is replaced with a table-driven
TestDispatchItemUpdate_TagsForwarding covering array, empty array,
empty string (no-op), and comma-separated back-compat. Each case
asserts the tags key's presence/absence and shape in the PATCH body.
2026-05-14 18:36:18 -04:00
xarmian c38b3bf5cd feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378) (#517)
* feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378)

Foundational change for PLAN-1377 — playbooks become first-class invokable
procedures. Two new optional fields land on the Playbooks collection
schema:

- `invocation_slug` (text, kebab-case, unique-per-workspace among non-null
  values): enables `/pad <slug>` direct invocation. Nullable so
  trigger-only playbooks (e.g. on-release checklists) don't need one.
- `arguments` (json, array of {name, type, required, default, description}):
  declares the playbook's argument contract; mirrors the body's
  `## Arguments` section in queryable form.

Plumbing pieces:

- `models.FieldDef` grows two general-purpose options — `Pattern` for
  regex validation and `UniqueScope` for collection-level uniqueness.
  Both are opt-in; existing schemas are unaffected.
- `items.ValidateFields` learns the `json` field type (accepts any
  JSON-decodable value) and applies `Pattern` to string-typed values.
- `handlers_items.checkUniqueFields` queries `Store.ListItems` to enforce
  `UniqueScope == "workspace_collection"` on create + update.
- Two migrations (SQLite 054, Postgres 033) JSON-patch the playbooks
  schema on existing workspaces so the new fields show up without a
  workspace re-init.
- TypeScript `FieldDef` mirrors the Go side.

Parent: PLAN-1377.

* fix(playbooks): address Codex review round 1 findings (TASK-1378)

P1 — EditCollectionModal now round-trips opaque pattern/unique_scope
metadata. EditableField carries the new keys; the load + save paths
preserve them so re-saving the playbooks collection from the UI doesn't
strip server-side validation rules the modal doesn't yet expose
dedicated controls for. fieldFromDef mirrors the change for templates.

P2 — checkUniqueFields' pre-write ListItems check is now backed by a
partial unique index (idx_items_invocation_slug_per_collection,
SQLite + Postgres) scoped to non-empty, non-deleted rows. The pre-check
still gives users a friendly error message in the common case; the
index closes the TOCTOU race between two concurrent writers. The
create-conflict error message is now generic enough to cover both the
slug constraint and the new invocation_slug index.

P2 — `json` field type now rejects raw strings, numbers, and bools. Only
objects, arrays, and null are accepted, so a generic web text input
can't silently corrupt a structured field by emitting "[]" instead of
an actual array. FieldEditor.svelte routes `json` fields to a
read-only summary in both readonly and edit modes; dedicated editors
(like TASK-1384's playbook editor that owns `arguments`) own the
structured form.

P3 — invocation_slug regex now requires a minimum of two characters
(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) in the Go const, the SQLite migration,
the Postgres migration, and the validate tests. Single-letter slugs
would shadow plausible NL tokens (e.g. `/pad a ...`) and the doc
comment already claimed the two-char floor; this aligns code with
intent.

Parent: PLAN-1377.

* fix(playbooks): address Codex review round 2 findings (TASK-1378)

P2.1 — checkUniqueFields no longer passes IncludeArchived=true. The
application-layer pre-check now matches the partial unique index's
`deleted_at IS NULL` predicate so a soft-deleted playbook releases its
slug back to the pool and reclaiming it succeeds instead of 409'ing.

P2.2 — handleUpdateItem now maps UNIQUE constraint / duplicate key
errors from UpdateItem to HTTP 409, mirroring the create path. A true
concurrent-update race that slips past checkUniqueFields and trips the
partial unique index used to surface as a misleading 500.

(Not addressed in this round: Codex's third finding — concern about the
partial unique index applying to "every collection" — is, on close
reading, not what the index does. `ON items(collection_id, json_extract(...))`
scopes uniqueness to the (collection_id, slug) pair, so two items in
different collections with the same `invocation_slug` value coexist
fine. The migration-failure risk is theoretical: `invocation_slug` is
a brand-new field key, so no pre-existing items can have it set, and
no migration-time duplicates can exist. If a future custom collection
adopts the same field name, opting into per-collection uniqueness is
exactly the intended semantic of FieldDef.UniqueScope.)

Parent: PLAN-1377.

* fix(playbooks): map restore-path UNIQUE violations to 409 (TASK-1378)

Codex round 3: restoring an archived playbook can hit the partial
unique index on invocation_slug if a replacement item already claimed
the slug. Map UNIQUE constraint / duplicate key errors from RestoreItem
to HTTP 409 with a targeted message, matching the create + update paths.

Parent: PLAN-1377.

* fix(playbooks): map collab-snapshot UNIQUE violations to 409 (TASK-1378)

Codex round 4: the collab-snapshot PATCH branch under
`s.collab.UnderItemLock` ran its own UpdateItem call and fell through
to writeInternalError on any non-stale-snapshot error. A concurrent
edit racing the invocation_slug partial unique index would surface as
500 instead of 409. Mirror the main UpdateItem error mapping.

Codex's other round-4 finding — the partial unique index applying to
"every collection" — is not addressed because the index IS already
collection-scoped: `ON items(collection_id, json_extract(fields,
'$.invocation_slug'))`. Two items in different collections with the
same slug coexist; only same-collection duplicates conflict. Migration
duplicates are impossible because `invocation_slug` is a brand-new
field key with no pre-existing items setting it. A custom collection
that later adopts the same field name opts into per-collection
uniqueness, matching the FieldDef.UniqueScope="workspace_collection"
semantic.

Parent: PLAN-1377.
2026-05-12 17:17:56 -04:00
xarmian 7456b5aed6 feat(store): add workspace-scoped monotonic seq column to items (TASK-1352) (#492)
* feat(store): add workspace-scoped monotonic seq column to items (TASK-1352)

Adds an `items.seq` column that bumps on every mutation
(create/update/soft-delete/restore) as the cursor mechanic for the
local-first read model's delta sync (PLAN-1343, DOC-1342 design
decision #1). Each mutation stamps `MAX(seq) + 1 WHERE workspace_id = ?`
inside the same transaction that performs the write, with a Postgres
advisory lock keyed on the workspace serializing concurrent
seq-bumping mutations. SQLite's single-writer rule covers the same
guarantee there.

Migration backfills existing rows with sequential per-workspace seqs
in (updated_at, id) order so every workspace has a non-zero MAX(seq)
floor immediately. Adds an idx_items_workspace_seq index supporting
both the `/items-index` cursor read and the future `/items-changes`
range scan.

The Seq field is now populated through every items SELECT helper
(GetItem, GetItemIncludeDeleted, ListItems, ListItemsIndex,
listItemsFTS, SearchItems, ItemsModifiedSince, GetChildItems,
ListStarredItems, ResolveItemIncludeDeleted, GetItemBySlugIncludeDeleted)
and the workspace import path stamps it via the same MAX+1 subquery
so imported rows don't all collapse to seq=0.

Parent: PLAN-1343. Foundation for TASK-1353 (wire seq into
/items-index cursor) and TASK-1354 (/items-changes delta endpoint).

* fix(store): bump items.seq on role reorder, MoveItem, and field migrations per Codex review (round 1)

Codex round 1 flagged that UpdateRoleSortOrder was rewriting
items.role_sort_order without bumping the new workspace-scoped
seq column — delta-sync clients would miss role-board reorders
until a full refresh. The same gap applied to MoveItem (collection
change) and MigrateItemFieldValues (bulk select-option rename),
which are also user-visible mutations the cursor must surface.

Each path now:
  - acquires the workspace seq advisory lock (no-op on SQLite)
  - stamps seq = MAX(seq)+1 inside the same transaction

The bulk rename gives all rows affected by a single statement the
same seq value (MAX+1 at statement start). That preserves the
"no overlap, no gap" cursor contract — a client at cursor < MAX
sees them all in one batch, at cursor >= MAX sees none.
2026-05-11 12:51:49 -04:00
xarmian bdcb62e902 fix(api): accept nested object/array for PATCH items fields/tags (BUG-1144) (#485)
The PATCH /api/v1/workspaces/{ws}/items/{ref} endpoint previously
demanded `fields` and `tags` arrive as JSON-encoded strings, because
models.ItemUpdate declares them as *string to mirror the storage shape.
Sending the natural nested-object shape any reasonable HTTP client
would produce returned HTTP 400 with a leaked Go unmarshal error
naming the internal struct field — confusing for anyone integrating
against Pad over HTTP (webhook reactors, custom dashboards, non-CLI
agents, third-party MCP bridges).

This is the symmetric input-side counterpart to BUG-991, which was
fixed at the MCP boundary in PR #364 with dual-emit normalization
rather than the full Plan-sized models.Item migration.

Fix: add a custom ItemUpdate.UnmarshalJSON that accepts either shape
on the wire and normalizes to the canonical string internally. The
struct field type stays *string, so the validation/storage/web/CLI
pipeline is untouched. All in-process Go callers construct ItemUpdate
literals (15 grepped call sites) and never hit UnmarshalJSON, so the
change is invisible to them.

Wrong shapes (e.g. `{"fields":42}`, `{"tags":{"x":1}}`) now return a
domain-level 400 — `"fields" must be a JSON object or a JSON-encoded
string` — surfaced via sentinel errors (ErrInvalidFieldsType /
ErrInvalidTagsType) that the handler unwraps from decodeJSON's
"invalid JSON: %w" wrapper.

Coverage:
- models/item_test.go: 10 sub-tests covering object, array, string,
  null, absent, and wrong-type cases for both fields and tags.
- server/handlers_items_test.go: 6 PATCH integration sub-tests
  asserting back-compat, the BUG-1144 repro now returns 200, and
  that error responses no longer leak Go struct field names.

Smoke-tested against the live server with the exact repro curl from
BUG-1144 (HTTP 200), plus malformed (HTTP 400 with clean message)
and stringified-string back-compat (HTTP 200).
2026-05-10 23:02:48 -04:00
xarmian 18087463ce feat(collab): op-log cursor protocol — force-refresh + watermark advance (TASK-1319) (#472)
* feat(collab): op-log cursor protocol — force-refresh + watermark advance (TASK-1319)

Closes both holes left by TASK-1309:

  1. Long-disconnected tab + external-write race. A reconnecting client
     announces its highest applied item_yjs_updates.id via `?since=<id>`.
     If that id is below MIN(id) for the item, rows it expected to
     replay have been pruned and the server sends a `force_refresh`
     control frame and closes the conn. Client recreates the Y.Doc
     and lazy-seeds from items.content. Without this, Tab A's stale
     state would silently overwrite an external CLI/MCP write on the
     next 5s flush.

  2. Browser-only-edited items never GC'd. Browser collab-snapshot
     PATCHes now carry an op_log_cursor body field. The store advances
     items.content_flushed_op_log_id only when the cursor matches the
     current MAX(op-log.id) — proving the markdown captures every
     persisted op. SQL CASE clause re-evaluates MAX at COMMIT time so
     a peer op landing between client-side cursor capture and the
     UPDATE leaves the watermark untouched (no over-advancement).

Combined cursor mechanism:

  - Server attaches op_log_cursor JSON control frames after replay,
    after every successful AppendYjsUpdate (originator), and to every
    peer's binary fan-out (so all peers stay in lockstep without a
    round trip).
  - Client persists per-tab in sessionStorage (NOT localStorage —
    avoids cross-tab cursor leakage that would force-refresh stable
    sessions).
  - Server's MIN(id) check + force_refresh fires only when a non-zero
    `since` is below MIN; `since=0` is treated as a fresh client.

New store methods: MinOpLogID, MaxOpLogID. New ItemUpdate field:
OpLogCursor *int64. New control message types: op_log_cursor,
force_refresh. New OpEvent.OpLogID for cursor piggyback. Existing
collab tests updated to drain TextMessage cursor frames.

Tests cover: initial cursor frame after replay (populated + empty
op-log), force_refresh fires when since<MIN, delta replay when
since>=MIN, cursor broadcast to originator + peers on append, and
watermark advancement gated on cursor==MAX.

Parent: PLAN-1248. Builds on TASK-1309.

* fix(collab): skip stale-Ydoc flush on force_refresh teardown per Codex review (round 1)

A force_refresh tear-down means the local Y.Doc cursor is below the
server's MIN(item_yjs_updates.id) — its derived markdown is stale.
Without this guard the collab $effect cleanup runs flushCollabNow
on the way out and silently PATCHes that stale markdown back to
items.content, overwriting the canonical content the fresh provider
is supposed to lazy-seed from. Per Codex round 1 [P1] of TASK-1319.

* fix(collab): force_refresh on empty op-log + cancel pending flush per Codex review (round 2)

Two P1 fixes:

1. Manager.Join now force_refreshes when since>0 and the op-log is
   empty (hasMin==false), not just when since<MIN. After
   PruneAndApply wipes the entire op-log, MIN is undefined; the
   original predicate would have admitted the stale tab and let its
   on-open Y.encodeStateAsUpdate write resurrect the pre-prune
   document.

2. The +page.svelte onForceRefresh handler now also clears
   collabFlushTimer. Without this a 5s timer that armed before the
   force_refresh frame arrived can still fire AFTER the cleanup
   ran, PATCHing stale Y.Doc-derived markdown to items.content.

New test: TestRoomManagerForceRefreshOnEmptyOpLogWithSince covers
the empty-op-log branch.

Per Codex round 2 [P1] of TASK-1319.

* fix(collab): include forceRefreshNonce in Editor key so it remounts on force_refresh per Codex review (round 3)

The collab $effect cleanup runs on forceRefreshNonce bump, but the
<Editor> {#key} was `${item.id}:true` — itemID doesn't change, so
the keyed Editor wasn't unmounting. The Tiptap Collaboration
extension only binds in onMount, so the editor stayed wired to the
stale (destroyed) Y.Doc while a fresh provider+doc were set up
in parallel. Edits would either be unsynced or eventually flush
stale markdown again.

Adding forceRefreshNonce to the key forces the Editor to remount
in lockstep with the doc swap. Per Codex round 3 [P1] of TASK-1319.

* fix(collab): refetch item.content before lazy-seed on force_refresh per Codex review (round 4)

After force_refresh the collab $effect rebuilds the Y.Doc and the
lazy-seed (TASK-1261) seeds it from item.content. But item.content
was the cached page-state copy — possibly stale relative to the
server (the WS force_refresh can beat the SSE/visibility refresh
that would otherwise update it). Lazy-seeding stale content into
a fresh op-log re-introduces exactly the staleness force_refresh
was supposed to clear: the next 5s flush PATCHes that stale view
back to canonical items.content.

onForceRefresh now does an api.items.get() before bumping the
nonce so the rebuild's lazy seed reads server-fresh content. A
failed fetch falls through to the bump anyway (an editor on
possibly-stale content is still better than a broken editor).

Per Codex round 4 [P1] of TASK-1319.

* fix(collab): suppress cursor during replay + move force_refresh check before getOrCreate per Codex review (round 5)

Two more findings:

1. [P1] writeLoop sends op_log_cursor frames for live ops broadcast
   during the replay window. A client disconnecting after one of
   those cursors lands but BEFORE the rest of replay completes
   would persist a cursor pointing past unreplayed rows. On
   reconnect with since=that-cursor, server replays nothing — the
   client's Y.Doc would be missing causally-required ops.

   Fix: per-roomConn replayDone atomic.Bool. writeLoop suppresses
   cursor frames while it's false. runConn flips it after the
   post-replay initial cursor is on the wire. Live binary frames
   continue to flow during replay (Yjs CRDT commutativity); only
   the cursor metadata is gated.

2. [P2] Force-refresh path leaked an empty room. getOrCreate
   inserted into m.rooms before the force_refresh bail-out left
   an orphan entry that PruneSweep would later treat as 'active'
   and skip indefinitely.

   Fix: schema-rebuild + force_refresh checks now run BEFORE
   getOrCreate. Both are store-only mutations and the per-item
   lock is held throughout, so concurrency is unchanged.

New test: TestRoomManagerCursorSuppressedDuringReplay regression-
guards the cursor-suppression behaviour.

Per Codex round 5 [P1+P2] of TASK-1319.

* fix(collab): tighten initial cursor + sync-destroy provider on force_refresh per Codex review (round 6)

Two more P1 fixes:

1. runConn's empty-replay fallback used MaxOpLogID() to anchor
   the initial cursor. A live op landing between replayTo
   returning and the cursor write would be reflected in MAX
   but its binary frame might not have flowed through this
   conn's writeLoop yet — the cursor would advertise an id
   the client hasn't received. Initial cursor is now strictly
   max(highestReplayed, since); MaxOpLogID is removed from
   the opLogStore interface.

2. Provider.handleControlMessage's force_refresh branch now
   calls this.destroy() SYNCHRONOUSLY before invoking the
   onForceRefresh callback. Previously the consumer's recovery
   path (async items.get refetch) would race the provider's
   own onClose-triggered reconnect, which would re-open with
   since=0 and push Y.encodeStateAsUpdate of the stale Y.Doc
   — recreating the corruption force_refresh was meant to
   prevent. destroy() sets destroyed=true so scheduleReconnect
   short-circuits.

Per Codex round 6 [P1] of TASK-1319.

* fix(collab): block flush scheduling during force_refresh recovery per Codex review (round 7)

Previously, after onForceRefresh fires:
  1. Provider is destroyed synchronously.
  2. Async items.get refetch is in flight.
  3. forceRefreshNonce bumps after refetch resolves.
  4. $effect cleanup runs, then rebuild.

But during steps 2-3 the editor component is still mounted with
the stale Y.Doc, and a local edit fires handleContentUpdate which
calls scheduleCollabFlush. clearTimeout earlier in onForceRefresh
only canceled the timer at THAT moment; a new edit during the
refetch window arms a fresh timer that fires before cleanup. That
PATCHes stale Y.Doc-derived markdown back to canonical content,
recreating the corruption force_refresh was meant to prevent.

Fix: forceRefreshInFlight flag set in onForceRefresh, blocks
scheduleCollabFlush, resets after the fresh provider is wired
(end of $effect run). Per Codex round 7 [P1].

* fix(collab): gate runCollabFlush itself on force_refresh in-flight per Codex review (round 8)

scheduleCollabFlush blocked the 5s timer path, but direct callers
of flushCollabNow / runCollabFlush (beforeunload handler,
rich-to-raw toggle) bypassed the guard. A page reload or raw
toggle DURING the force_refresh recovery window still PATCHed
stale Y.Doc-derived markdown to canonical items.content.

Pulling the guard into runCollabFlush covers every caller in one
spot and returns 'deduped' so the result-shape contract holds.

Per Codex round 8 [P1] of TASK-1319.

* fix(collab): distinct 'skipped' result for force_refresh path; raw-toggle aborts per Codex review (round 9)

runCollabFlush returning 'deduped' on the force_refresh-blocked
path was indistinguishable from a legitimate same-content dedupe.
The rich→raw toggle treats 'deduped' as 'server already has this
markdown' and seeds rawSeedMarkdown from it — letting the user's
next raw edit overwrite canonical items.content with content
derived from the stale Y.Doc.

Add a distinct 'skipped' result for the force_refresh path. Raw
toggle aborts on it (with a 'try again in a moment' toast); other
callers fall through unchanged because no other call site
behaviorally depends on 'deduped' vs 'skipped'.

Per Codex round 9 [P1] of TASK-1319.

* fix(collab): server-side gate + post-await client guard against stale collab-snapshot per Codex review (round 10)

A force_refresh frame can arrive WHILE a collab-snapshot PATCH is
already mid-flight to the server. The client-side
forceRefreshInFlight check at PATCH-start can't catch this race;
the request lands at the server with stale Y.Doc-derived markdown.

Two-pronged fix:

1. Server: handler now checks op_log_cursor against MIN(op-log.id)
   for collab-snapshot PATCHes and returns 409 Conflict when
   cursor < MIN. Such cursors prove the flushing tab's Y.Doc was
   built on rows that have been pruned (PruneAndApply, schema
   rebuild, dormant GC). The markdown is, by construction, stale.

2. Client: post-await check on forceRefreshInFlight returns
   'skipped' instead of 'flushed' so saveStatus / lastFlushedContent
   don't seed from a known-stale base even if the server happened
   to accept the PATCH (e.g. MIN advanced after handler validation).

New tests: TestCollabSnapshotRejectsCursorBelowMin (gate fires),
TestCollabSnapshotAcceptsCursorAtOrAboveMin (negative path).

Also de-leak an unused slice in the round-5 cursor-suppression test
so staticcheck stays clean.

Per Codex round 10 [P1] of TASK-1319.

* fix(collab): reject collab-snapshot when cursor>0 and op-log empty per Codex review (round 11)

The HTTP-layer gate I added in round 10 mirrored only PART of the
WS-upgrade force_refresh predicate. Round 5 had already taught us
that 'op-log entirely pruned' is a separate stale path from
'cursor below MIN' (PruneAndApply, schema rebuild, dormant GC all
leave hasMin=false), and the WS check now uses
`since > 0 && (!hasMin || since < minID)`. The HTTP gate had
only the second clause.

Mirror the WS predicate at the handler so a stale collab-snapshot
PATCH against an empty op-log gets a 409 too. New regression:
TestCollabSnapshotRejectsCursorOnEmptyOpLog.

Per Codex round 11 [P1] of TASK-1319.

* fix(collab): reject collab-snapshot cursor=0 on non-empty op-log per Codex review (round 12)

Round-11 gate accepted cursor=0 unconditionally. But a stateful tab
whose previous session disconnected BEFORE receiving the
post-replay cursor frame (network blip during the writeMu burst
between replay binaries and the cursor) ends up with sessionStorage
cursor=0 + a non-empty Y.Doc populated by prior replay binaries.
On reconnect with since=0 the server treats it as fresh, replays
nothing if the op-log was meanwhile pruned, and the client's
on-open Y.encodeStateAsUpdate resurrects pre-prune ops. The next
flush carries cursor=0 + stale-derived markdown.

The gate now refuses any incompatible cursor:
  - cursor>0 + empty op-log (prior rule)
  - cursor<MIN + non-empty op-log (prior rule, now naturally
    catches cursor=0 too because 0 < any positive MIN)

The WS replay path is unchanged — full replay from since=0 is
the recovery for clients that genuinely lost their cursor; the
corruption manifested through the flush PATCH which we now gate.

New test: TestCollabSnapshotRejectsCursorZeroOnNonEmptyOpLog.

Per Codex round 12 [P1] of TASK-1319.

* fix(collab): close cursor=0 client/server gaps + lock validation+write atomically per Codex review (round 13)

Four P1 issues addressed:

1. Client always sends op_log_cursor (including 0) so the server
   gate sees the field. Previously cursor=0 was omitted, which
   silently bypassed the server's stale-snapshot rejection.

2. Provider construction now resets sessionStorage cursor to 0
   when the Y.Doc is empty. The Y.Doc isn't persisted across
   page reload, so a stored cursor=N + fresh empty Y.Doc would
   announce since=N to the server and miss rows 1..N from
   replay (server only replays id > N).

3. onOpen skips Y.encodeStateAsUpdate when lastOpLogID === 0.
   A populated Y.Doc + cursor=0 is the network-blip-during-cursor-
   write failure mode; pushing that state can resurrect ops the
   server has pruned. Server replay + lazy-seed handle recovery
   without our push.

4. Server gate now runs INSIDE the per-item collab setup lock
   (new RoomManager.UnderItemLock helper) so a concurrent prune
   (PruneAndApply, schema rebuild, dormant GC) cannot land
   between the MIN check and the items.content write. Without
   this, a tight race let stale snapshots overwrite canonical
   content the prune just installed.

Per Codex round 13 [P1] of TASK-1319.

* fix(collab): gate handleDocUpdate on cursorAnchored to close stale-Ydoc edit path per Codex review (round 14)

Round 13 fix skipped on-open send for lastOpLogID===0, but local
edits via handleDocUpdate still propagated. A populated Y.Doc +
no-cursor-yet client could type, the edit would land in the
op-log with id N, server would send originator cursor=N, and
the next 5s flush would carry an 'anchored' cursor that passed
the server's MIN check — overwriting items.content with stale-
Y.Doc-derived markdown.

Add a cursorAnchored boolean. Set on first op_log_cursor frame
receipt (including cursor=0 against an empty op-log — that's a
legitimate 'server has nothing' signal). handleDocUpdate refuses
to send before this. Local edits buffer in the editor; once the
cursor arrives (or force_refresh rebuilds the provider), the
existing reconnect/edit paths catch them up.

Per Codex round 14 [P1] of TASK-1319.

* fix(collab): buffer + flush pre-anchor local updates per Codex review (round 15)

Round 14 silently dropped local Yjs updates fired before the
first op_log_cursor frame anchored the session. Yjs updates are
incremental: a dropped keystroke leaves later ops referencing
structs no peer can resolve, breaking convergence.

Buffer pre-anchor updates in a Uint8Array[] (capped at 1000 to
prevent unbounded growth in pathological 'anchor never arrives'
scenarios — overflow triggers force_refresh-style recovery).
On the first cursor frame, flush the buffer in order so the
server gets every causally-required struct before any post-
anchor updates land.

Per Codex round 15 [P1] of TASK-1319.

* fix(collab): destroy provider before force_refresh on pre-anchor buffer overflow per Codex review (round 16)

Round 15 overflow path called onForceRefresh but didn't destroy
the provider synchronously. A late op_log_cursor arriving before
the page-level rebuild (the recovery callback is async — refetches
items.content) would flip cursorAnchored=true, the partially-
populated buffer would flush, but the DROPPED prefix (the
overflowed entries) would leave server-side ops causally
incomplete — exactly the bug the buffer was supposed to prevent.

destroy() sets destroyed=true, removes message listener,
short-circuits scheduleReconnect, closes the socket. Late cursor
frames can no longer anchor a doomed provider.

Per Codex round 16 [P2] of TASK-1319.

* fix(collab): refuse rebuild on refetch fail + broaden on-open gate to cursorAnchored per Codex review (round 17)

Two findings:

[P1] force_refresh recovery bumped forceRefreshNonce in finally
even when the item.content refetch failed. The rebuild then
lazy-seeded from the cached (possibly-stale) item.content, and
the next flush would PATCH that stale view back to the server.
Move the bump into .then() so a failed refetch surfaces a
'please reload' toast and leaves the editor effectively
read-only (forceRefreshInFlight stays true, blocking flushes).

[P2] Send-on-open gate was lastOpLogID > 0, which silently
dropped local edits made during a brief offline window after a
legitimate 'cursor=0' anchor (empty op-log session). Switch to
cursorAnchored — the boolean specifically distinguishes
'unanchored' (stale Y.Doc + no server confirmation) from
'anchored at cursor=0' (legitimate empty op-log).

Per Codex round 17 [P1+P2] of TASK-1319.

* fix(collab): force_refresh on cursor=0 against non-empty Y.Doc per Codex review (round 18)

cursor=0 means the server's op-log is currently empty. A
non-empty Y.Doc at first-cursor receipt implies the ops came
from an earlier connection within this provider's life that
never reached its post-replay cursor frame, followed by a
server-side prune (PruneAndApply, schema rebuild, dormant GC)
during our disconnect. Anchoring at cursor=0 in that state
would mark a stale Y.Doc as authoritative; the next on-open
state push or flush would resurrect pre-prune state and
overwrite canonical items.content.

Detect the configuration via Y.encodeStateVector length and
invoke the same force_refresh-style recovery the explicit
server frame triggers: destroy provider, clear sessionStorage,
fire onForceRefresh so the page rebuilds from items.content.

Per Codex round 18 [P1] of TASK-1319.

* fix(collab): gate cursor=0 force_refresh on remoteSyncApplied per Codex review (round 19)

Round 18 force_refreshed the provider whenever cursor=0 arrived
against a non-empty Y.Doc. But local pre-anchor edits (user typed
before the initial cursor=0 of a legitimate empty-op-log session
arrived) ALSO populate Y.Doc — yet those edits live in
preAnchorUpdates and were supposed to flush on anchor. The
predicate spuriously triggered force_refresh, dropping the
buffered local edits.

Track remoteSyncApplied (set when readSyncMessage applies
anything to Y.Doc — replay binary or live peer op). Only force_
refresh on cursor=0 when remoteSyncApplied is true: that's the
true 'remote replay landed but server now reports empty op-log
=> mid-session prune' signature.

Per Codex round 19 [P1] of TASK-1319.

* fix(collab): repair brace mis-merge in wsProvider cursor=0 guard

The round-19 patch overlapped the round-18 inner block, producing
an extra brace + over-indented body. Collapsing into a single
clean block restores parseability without changing semantics
beyond what round 19 already documented.

* fix(collab): gate syncStep2 reply on cursorAnchored per Codex review (round 20)

readSyncMessage writes an inline syncStep2 reply when it receives
a peer's syncStep1. That reply embeds our current Y.Doc state.
If a peer's syncStep1 arrives before our first op_log_cursor
(pre-anchor window), the reply path bypasses handleDocUpdate's
cursorAnchored gate and lets potentially-stale Y.Doc state reach
the server before the cursor=0 + remoteSyncApplied force_refresh
recovery has a chance to fire.

Suppress the reply while unanchored. Peer state propagation
still works: the buffered preAnchorUpdates flush on anchor, and
the lazy-seed rebuild after a force_refresh seeds canonical
content from items.content.

Per Codex round 20 [P1] of TASK-1319.

* fix(collab): fold mid-replay live op ids into post-replay cursor + remoteSyncApplied only on apply per Codex review (round 21)

Two more findings:

[P1 server] writeLoop suppresses cursor frames during replay to
prevent the client persisting a cursor past unreplayed rows.
But binary frames for those live ops still go through
(commutativity), so the client APPLIES them to its Y.Doc. The
post-replay initial cursor only covered max(highestReplayed,
since), leaving the cursor below the highest applied op. On
empty-replay sessions this trips the client's
'cursor=0 + remoteSyncApplied' force_refresh path and discards
buffered pre-anchor edits.

Track maxLiveOpLogIDDuringReplay on the roomConn (atomic
compare-and-swap) and fold it into the post-replay cursor.

[P1 client] remoteSyncApplied was set on every MESSAGE_SYNC,
including syncStep1 (which only carries a state vector — it
doesn't apply state). A peer's syncStep1 arriving pre-anchor
would falsely flag remote-sync-applied and trip the cursor=0
force_refresh on legitimate empty-op-log sessions. Set the
flag only after readSyncMessage returns, and only for
syncStep2 / update subtypes.

Per Codex round 21 [P1] of TASK-1319.

* fix(collab): widen writeMu critical section + drop omitempty on op_log_id per Codex review (round 22)

Two more P1s:

[P1 server] writeLoop's mid-replay record-max happened OUTSIDE
writeMu, so runConn's post-replay read could race the record:
runConn loads → writeLoop's atomic store of higher value →
runConn sends cursor below the live id. Move the entire
per-event sequence (binary write + replayDone observation +
record-or-send) inside writeMu, and have runConn acquire
writeMu around its read+cursor-write+replayDone-flip. The lock
serializes the two paths cleanly: writeLoop events that ran
first have already recorded; events that arrive after replayDone
flips emit their own cursor frames.

[P1 protocol] OpLogID had `omitempty` JSON tag — a legitimate
cursor=0 (empty op-log session) serialized as
`{"type":"op_log_cursor"}` with no op_log_id field. The
client's strict-type check then rejected it as malformed,
leaving the session unanchored and local edits buffered
forever. Drop omitempty so 0 is wire-visible. Other control
types (applier_request/ack) carry an extra op_log_id:0 in
their JSON, which their client dispatches ignore.

Per Codex round 22 [P1] of TASK-1319.

* fix(collab): route originator cursor through writeLoop FIFO per Codex review (round 23)

readLoop sent the originator's op_log_cursor directly via
sendOpLogCursor right after AppendYjsUpdate, bypassing the bus/
writeLoop ordering. With a peer op already queued in rc.bus, the
sequence on the wire could be:
  1. originator cursor=N (newer local op)
  2. peer binary (older op)
  3. peer cursor=M < N (rejected by client's max-take logic)

Client persists cursor=N. If the client then disconnects before
applying the peer binary, reconnect with since=N replays nothing
(server has nothing > N) and the older peer op is lost forever
to this client's Y.Doc.

Fix: writeLoop now processes self events too — skipping the
binary echo (the originator already has Y.Doc state) but routing
the cursor frame through the same FIFO bus channel as peer ops.
The originator's cursor=N now arrives strictly AFTER all
older-id peer events on the same channel.

Per Codex round 23 [P1] of TASK-1319.
2026-05-09 21:45:46 -04:00
xarmian 191b887e23 feat(versions): VersionSource attribution + collab coexistence (TASK-1267) (#465)
The collab 5s-flush PATCH (TASK-1260) sends
`?source=collab-snapshot` with a body of just `{ content }`. Without
a handler-side stamp, `Store.UpdateItem`'s default coerced empty
input.Source to "web" on the version row and the per-(actor, source)
throttle suppressed every collab-driven snapshot following the user's
last manual web edit — version-diff effectively went silent during
co-edit sessions.

Adds:
- ItemUpdate.VersionSource: overrides per-version-row Source
  attribution WITHOUT mutating items.source. The latter feeds
  WorkspaceHasAgentActivity's `source IN ('cli', 'mcp')` filter,
  so a CLI/MCP-created item the user opens in the editor would
  otherwise silently flip out of the agent-activity tally on every
  auto-flush.
- Store.UpdateItem prefers VersionSource over Source for version
  row creation; falls back to Source then "web" if neither set.
- handlers_items.go stamps `input.VersionSource = "collab-snapshot"`
  for `?source=collab-snapshot` PATCHes (when not already set).

Tests:
- internal/store/items_collab_versions_test.go: store-level
  reverse-patch reconstruction over a CLI→web→collab-snapshot
  edit sequence; verifies IsDiff=true on at least one row.
- internal/server/handlers_items_collab_versions_test.go: full
  HTTP-level test of the route; asserts a collab-snapshot version
  row is created AND that items.source stays "cli".

Four rounds of Codex review.
2026-05-09 09:51:48 -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 f9d3244660 feat(connected-apps): user-facing OAuth connection management page (TASK-954) (#390)
* feat(connected-apps): user-facing OAuth connection management page (TASK-954)

Adds /console/connected-apps where a logged-in user can see every
OAuth grant chain they've authorized via the MCP consent flow
(Claude Desktop, Cursor, …) and revoke any of them. Joins to the
DCR client metadata for the display name + logo, and to the MCP
audit log (TASK-960) for the "last used" + "30-day calls" columns.

Pieces:

- internal/store/connected_apps.go — ListUserOAuthConnections walks
  oauth_access_tokens + oauth_refresh_tokens, dedups by request_id,
  hydrates client metadata, parses session_data for the workspace
  allow-list, classifies granted_scopes into a coarse capability
  tier. RevokeUserOAuthConnection verifies ownership (ErrConnection
  NotFound for stranger's chains — anti-enumeration; same shape as
  for unknown chains) then calls the existing RevokeRefreshTokenFamily
  + RevokeAccessTokenFamily so the next /mcp call gets 401.

- internal/models/connected_apps.go — OAuthConnection + CapabilityTier
  models.

- internal/server/handlers_connected_apps.go — REST endpoints:
  GET /api/v1/connected-apps (list) + DELETE /api/v1/connected-apps/{id}
  (revoke, idempotent, 204). Wrapped in requireCloudMode group.
  List enriches with MCPConnectionStatsForUser (audit aggregates) —
  soft-fails on the audit lookup so a broken audit table degrades
  to "no last-used data" instead of a broken page. Revoke records
  an "oauth_connection_revoked" entry in audit_trail via the
  existing CreateActivity path.

- web/src/routes/console/connected-apps/+page.svelte — list with
  per-app card (logo, name, capability badge, workspace chips with
  +N expander, connected/last-used relative times, 30-day count),
  Details expander showing scope_string + workspace list + redirect
  URIs, Revoke button → confirm modal → optimistic refresh, friendly
  empty state linking to /connect.

- web/src/routes/console/+layout.svelte — Connected Apps nav link
  (cloud-mode-gated, between Settings and Billing).

- web/src/lib/api/client.ts + types/index.ts — typed client +
  ConnectedApp interface.

Tests cover:
- Store: chain dedup across rotation siblings, subject filtering
  (Bob can't see Alice's), inactive chains excluded, ownership
  check on revoke, idempotent re-revoke, capability tier mapping,
  session-data allowed_workspaces parsing (both []string and JSON
  []interface{} round-trips).
- Handler: cloud-mode gate (404 outside), owner-only filtering,
  DTO field shape + audit enrichment populating last_used_at +
  calls_30d, revoke ownership 404 (not 403 — anti-enumeration),
  idempotent 204, audit_trail row written.

`make check` clean (lint + go test ./... + svelte-kit build).

Parent: PLAN-943.

* fix(connected-apps): point empty-state link at getpad.dev (Codex review round 1)

Codex caught: the empty-state link to /connect 404s because /connect is a
pad-web (marketing site) route, not a docapp route. From inside the
authenticated console at app.getpad.dev, the right target is the
absolute https://getpad.dev/connect URL — same pattern the +error.svelte
page uses for its "Back to getpad.dev" + "/docs" links.

* fix(console nav): exclude /console/connected-apps from Workspaces active match (Codex round 2)

Codex caught: the Workspaces nav predicate `isActive('/console') && !isActive('/console/settings') && ...` was missing the new /console/connected-apps prefix, so both Workspaces AND Connected Apps lit up when viewing the connected-apps page.

Same shape as the existing exclusions for settings / billing / admin.
2026-05-02 23:21:07 -04:00
xarmian d8b1d98e08 feat(mcp): persistent audit log for /mcp tool calls (TASK-960) (#389)
* feat(mcp): persistent audit log for /mcp tool calls (TASK-960)

Adds a 90-day-retention audit log of every MCP request. Drives the
"last used" + "30-day calls" columns the connected-apps page (TASK-954)
will read, and gives ops + on-call a forensics surface via a new
admin /console/admin/mcp-audit page.

Schema deviation from the spec, documented in migration 049:
the original task body called for `token_id REFERENCES oauth_tokens(id)`
but pad has no `oauth_tokens` table — instead an OAuth grant chain is
identified by `request_id` (preserved across refresh-token rotations,
see migration 048), and PAT-authenticated MCP requests have no OAuth
identity at all. The audit row therefore carries `(token_kind,
token_ref)` — `oauth` + request_id for OAuth, or `pat` + api_tokens.id
for PATs. The connected-apps page in TASK-954 will filter on
token_kind='oauth' to surface third-party connections only.

Pieces:
- internal/store/migrations/049_mcp_audit.sql + pgmigrations/028 — table.
- internal/models/mcp_audit.go — typed entry + 30-day stats DTO.
- internal/store/mcp_audit.go — insert / list-by-user / list-by-connection
  / list-all / per-connection-stats aggregator / 90-day retention sweeper.
- internal/server/middleware_mcp_audit.go — async writer + sweeper +
  middleware that wraps /mcp behind MCPBearerAuth. Hot path is
  non-blocking enqueue with drop-on-overflow + atomic drop counter.
- internal/server/middleware_mcp_auth.go — both PAT + OAuth branches now
  stash WithMCPTokenIdentity so the audit row attributes correctly.
- internal/server/handlers_mcp_audit.go — read endpoints:
  GET /api/v1/connected-apps/{id}/audit (owner-scoped) +
  GET /api/v1/admin/mcp-audit (admin-only).
- web/src/routes/console/admin/mcp-audit/+page.svelte + tab in admin layout.
- Tests cover required-field validation, round-trip, pagination,
  owner-only filtering, last-used + 30-day aggregates, retention sweep,
  body-sniff parser, canonical-JSON arg hashing, buffer-full drop path,
  status-to-result classification, admin gate, DTO field shape.

`make check` clean (lint + go test ./... + svelte-kit build).

Parent: PLAN-943.

* fix(mcp-audit): emit denied row on rate-limit reject per Codex review (round 1)

PR #389 round 1 caught: MCPAuditLog is mounted INSIDE MCPBearerAuth, so
when bearer auth's per-token rate-limit fires (429) it returns before
next.ServeHTTP — and the wrapping audit middleware never sees the
response. classifyMCPResult mapped 401/403/429 with no path that could
actually reach it.

Fix: emitMCPAuditDenied helper called directly from the rate-limit
deny branches of both PAT + OAuth paths. Resolved user + token
identity are already in scope at that point, so the audit row gets
attributed correctly. Pre-auth rejections (no/invalid bearer) stay
un-audited because there's no user to attribute them to and the
audit_trail table covers those auth-event signals already.

Threading: handleMCPPATAuth + handleMCPOAuthAuth now take the entry
timestamp so the denied row carries real latency.

Test: TestMCPAudit_RateLimited_RecordsDeniedRow drives a real PAT
through the rate limiter, drains to 429, and asserts the audit row
lands with status="denied" + error_kind="rate_limited" + the right
tool_name from the request body.
2026-05-02 22:56:10 -04:00
xarmian 2a00775481 feat(oauth): schema + storage layer (TASK-1023, sub-PR A of TASK-951) (#370)
* feat(oauth): schema + storage layer for OAuth 2.1 server (TASK-1023, sub-PR A of TASK-951)

First of 5 sub-PRs landing the OAuth 2.1 authorization server in
PLAN-943. This one is foundation only — no HTTP exposure, no fosite
import, no public surface change.

Schema (5 tables, parallel SQLite + Postgres migrations):
- oauth_clients         — RFC 7591 Dynamic Client Registration; public clients only for v1
- oauth_authorization_codes — short-lived codes for the auth-code grant
- oauth_access_tokens   — opaque HMAC; subject denormalized for fast user-bound queries
- oauth_refresh_tokens  — same shape; access_token_signature link + request_id chain
- oauth_pkce_requests   — PKCE session keyed by auth-code signature

Storage layer (internal/store/oauth.go):
- 12 public methods covering fosite's ClientManager + CoreStorage +
  PKCERequestStorage + TokenRevocationStorage interface shapes,
  using pad-internal types so the package stays fosite-free.
- Three sentinel errors (ErrOAuthNotFound, ErrOAuthInvalidatedCode,
  ErrOAuthInactiveToken) that sub-PR B's adapter maps to the
  matching fosite errors.
- request_id IS the chain identifier (fosite preserves it across
  rotations — handler/oauth2/flow_refresh.go:86), so family
  revocation is a single indexed UPDATE rather than a separate
  chain_id column.

14 tests covering: client CRUD + idempotent delete + empty-slice
normalization, auth-code create/get/invalidate (including the
"return payload alongside ErrInvalidatedCode" contract fosite
relies on for family revocation), access-token CRUD + delete,
refresh CRUD + RotateRefreshToken (single-row flip), refresh-token
family revocation (entire chain via request_id, leaves other
chains untouched), access-token family revocation, PKCE CRUD, and
required-field validation.

Both backends share test bodies via testStore(t); set
PAD_TEST_POSTGRES_URL=... to run the same suite against Postgres.

Out of scope for this PR (subsequent sub-PRs):
- fosite import + adapter (sub-PR B / TASK-1024)
- DCR + authorize + token endpoints (sub-PR C / TASK-1025)
- revoke + introspect endpoints (sub-PR D / TASK-1026)
- MCPBearerAuth OAuth integration (sub-PR E / TASK-1027)

* fix(oauth): always insert active=true; drop broken zero-value Active override per Codex review (round 1)

Codex round 1 caught a P1 in insertOAuthRequestRow:

    active := defaultActive
    if req.Active != defaultActive {
        active = req.Active   // <- zero-value collides
    }

When defaultActive=true and req.Active=false (the zero value), this
branch fires and the row is stored with active=FALSE — silently
producing immediately-revoked tokens. Any sub-PR B adapter that
built an OAuthRequest without explicitly setting Active=true would
ship broken.

Fix: hardcode active=TRUE on insert. Drop the defaultActive
parameter (it's always true for the three flagged tables; PKCE
has no active column). Pre-seeding inactive isn't a supported flow
— fosite never does it, and tests that need a revoked row do
Create + Invalidate / Rotate / RevokeFamily as a two-step.

Regression test TestOAuth_Insert_AlwaysActive constructs an
OAuthRequest with zero-value Active and asserts the row is
readable as active for all three table types (codes, access,
refresh). Without the fix the test fails on the first GetAccessToken
call with ErrOAuthInactiveToken.

* fix(oauth): RotateRefreshToken revokes both refresh + access families per Codex review (round 2)

Codex round 2 caught: my RotateRefreshToken only marked the named
refresh row inactive, but fosite's reference MemoryStore.RotateRefreshToken
(storage/memory.go:497-504) revokes BOTH the refresh family AND the
access family for the grant's request_id. Without this, every access
token issued before a refresh remained active until TTL — defeating
the rotation's invalidation contract.

Fix: RotateRefreshToken now delegates to RevokeRefreshTokenFamily +
RevokeAccessTokenFamily (both already existed). The signatureToRotate
parameter becomes vestigial — fosite passes it but the family revoke
catches every chain member regardless of which row triggered the
rotation. The new pair fosite immediately issues via
CreateAccessTokenSession + CreateRefreshTokenSession inherits the
same request_id (flow_refresh.go:86) and lands active=TRUE per the
round-1 hardcode, so the net post-rotation state is "all old rows
in this grant inactive, the new pair active."

Test rewrite: TestOAuth_RotateRefreshToken_FlipsActiveOnSingleRow
asserted the OPPOSITE behavior (only one row touched) — that was
the original bug. Replaced with TestOAuth_RotateRefreshToken_RevokesEntireGrant
which seeds a refresh + access pair in the same chain, plus a
distinct unrelated grant, then asserts after rotation:
- old refresh + old access both inactive
- unrelated grant untouched (request_id-scoped)

* fix(oauth): DeleteOAuthClient cascades dependent rows in a tx per Codex review (round 3)

Round 3 finding: DeleteOAuthClient errored with FK constraint
violation for any client that had ever issued a grant. The
migrations declare client_id FKs without ON DELETE CASCADE — by
design, so a stray DELETE FROM oauth_clients elsewhere fails
loudly rather than silently nuking grants — but that meant the
"officially supported" delete path was unusable.

Fix: DeleteOAuthClient now runs five sequential DELETEs inside a
single transaction:
  1. oauth_pkce_requests
  2. oauth_refresh_tokens
  3. oauth_access_tokens
  4. oauth_authorization_codes
  5. oauth_clients

Order matters (children before parent) because the FKs aren't
cascading. The tx makes it atomic — if any step fails, nothing's
deleted, so we never leave a half-deleted client. Idempotent
because every WHERE matches nothing on a non-existent client.

Test TestOAuth_DeleteOAuthClient_CascadesDependentRows seeds a row
in each of the four dependent tables, deletes the client, and
asserts ErrOAuthNotFound on every dependent row + the client itself.
Without the fix this fails on the first DELETE FROM oauth_clients
with an FK constraint violation.

* fix(oauth): SELECT FOR UPDATE row lock in DeleteOAuthClient on Postgres per Codex review (round 4)

Codex round 4 caught a Postgres race in DeleteOAuthClient: the
five-DELETE cascade is atomic, but between the child-row deletes
and the parent delete, a concurrent fosite handler can insert a
fresh grant/token referencing the same client_id. The parent
DELETE then fails with an FK violation and the whole tx rolls
back — the cascade is correct, but unreliable under concurrent
OAuth issuance.

Fix: take SELECT id FROM oauth_clients WHERE id = ? FOR UPDATE
as the very first statement in the tx (Postgres only). The
exclusive row-level lock blocks any concurrent statement that
tries to read the client row — which fosite does on FK resolution
during grant/token inserts — until our tx commits.

Skipped on SQLite because:
  (a) BEGIN IMMEDIATE serializes the entire write workload globally
      (DSN configures _txlock=immediate per store.go), so the race
      doesn't exist.
  (b) FOR UPDATE syntax isn't reliably accepted across SQLite
      drivers.

ErrNoRows on the lock query is treated as "client doesn't exist
yet" — the subsequent DELETEs match nothing and the call remains
idempotent. Tests still pass on the SQLite path; the Postgres
path's race fix will be exercised by CI's PAD_TEST_POSTGRES_URL
runs and any future concurrency test we add.
2026-05-02 10:25:53 -04:00
xarmian 0f05012169 fix(mcp): six surgical fixes for BUG-987 (bugs 6, 8, 11, 12, 13, 14) (#361)
* fix(mcp): six surgical fixes for BUG-987 (bugs 6, 8, 11, 12, 13, 14)

Hotfix follow-up to v0.1.0-rc.3's Claude Desktop dogfood. Six
surgical fixes; bigger items (5, 7, 9, 10) deferred to separate
tasks.

- Bug 6: `pad project next --format json` was emitting the entire
  dashboard, indistinguishable from `pad project dashboard --format
  json`. Now slices to suggested_next only. cmd/pad/main.go.

- Bug 8: standup blockers carried empty `ref` strings, blocking
  agent linkback to the actually-blocked items. dashboard's
  attention[].item_ref is canonical; the standup composer in
  internal/mcp/dispatch_http_slice4.go just wasn't propagating it.
  Same fix applied to suggested_next entries.

- Bug 11: cobra's auto-emitted "Usage: pad item block ..." help
  block leaked into MCP error envelopes via classifyExecError. The
  Usage text references OLD CLI verb names (pre-v0.2 catalog) that
  agents using the new surface have no business seeing, and bloats
  every error response. New stripCobraUsageBlock helper truncates
  stderr at the first line-anchored "Usage:" marker before
  classification + envelope construction.

- Bug 12: BuildCLIArgs validation errors (missing required arg, type
  mismatch) came out of env.Dispatch as bare-text NewToolResultErrorf
  results, breaking the structured envelope contract. New helper
  validationFailedFromBuildErr wraps them as ErrValidationFailed
  envelopes with the field name extracted via regex from the
  underlying message.

- Bug 13: every Task / Idea / Plan with a `priority` field got a
  phantom `convention: { enforcement: "<priority>" }` surfaced on
  its response, because ExtractItemConventionMetadata's legacy
  fallback treated `priority` as the Convention enforcement tier
  unconditionally. Restructured to track hasConventionShape
  separately from hasMetadata — only Convention-specific markers
  (structured convention field, trigger, scope, surfaces, commands,
  direct enforcement) flip the shape flag. category alone is
  insufficient (Ideas / Bugs / Roadmap items legitimately use it).
  Final guard returns nil when only category was matched.

- Bug 14: GetRoleBreakdown's unassigned row was emitted with empty
  role_name + role_slug, presenting as a "phantom" entry in the
  dashboard. Now explicitly labelled "Unassigned" / "unassigned"
  while keeping role_id null so it's still distinguishable from a
  real role.

Tests:
- internal/mcp/bug987_test.go (new) — stripCobraUsageBlock + classify
  + validation envelope wrapping + env.Dispatch integration.
- internal/models/item_test.go — three cases covering non-Convention
  items (Task, Idea, Plan with priority) returning nil metadata, and
  one preservation test for legacy Conventions with priority field.
- internal/store/agent_roles_test.go (new) — confirms unassigned row
  carries explicit "Unassigned" / "unassigned" labels.

Live verified: pad_project action=next returns just the suggestions
array; pad_item action=create with no fields returns validation_failed
with field=collection; pad_item action=link with self-target returns
without Usage-block leakage.

Deferred to separate items (per BUG-987 triage):
- Bug 5: text vs JSON returns across note, decide, star, unstar,
  delete, bulk-update — needs CLI-side handler updates per command.
- Bug 7: suggested_next algorithm — needs to consider in-progress
  items, not just open ones; behavior change needs design.
- Bug 9: fields/tags double-stringified — potentially breaking for
  web UI/CLI consumers.
- Bug 10: decision_log/notes embedded in fields blob duplicating
  top-level arrays — might require data migration.

Parent: BUG-987.

* fix(mcp): HTTP transport equivalence + ordering for BUG-987 per Codex review (round 1)

Two findings from Codex review of PR #361:

1. project.next on HTTP transport still returned the full dashboard.
   The route table mapped "project next" directly to /dashboard, so
   the CLI fix (slice to suggested_next) didn't reach OAuth-authed
   agents going through HTTPHandlerDispatcher. Catalog actions must
   produce equivalent shapes on stdio and HTTP — that's the contract
   that lets agents be transport-agnostic.

   Fix: new dispatchProjectNext method on HTTPHandlerDispatcher that
   fetches the dashboard via the existing fetchDashboardJSON helper,
   slices to suggested_next[], re-encodes, and runs through
   packageJSONResult so it gets the same {items: [...]} wrap as
   other list responses.

   Also retires the broken route-table entry — replaced with a
   comment pointing at the new method so future contributors don't
   re-add a passthrough.

   Test: TestDispatch_ProjectNext_SlicesToSuggestedNext + the empty-
   array case. Asserts dashboard-only top-level fields (summary,
   active_items) don't leak into the response — that's the whole
   point of project.next being distinct from project.dashboard.

2. ExtractItemConventionMetadata's priority→enforcement legacy
   fallback ran BEFORE surfaces/scope/commands had a chance to flip
   hasConventionShape, so a Convention with only `{scope, priority}`
   would silently drop enforcement.

   Fix: move the priority fallback to AFTER all marker checks. Direct
   `enforcement` still resolves first; the legacy priority fallback
   runs at the bottom once shape detection is complete.

   Tests: two new cases covering scope-only and commands-only legacy
   Conventions — both must resolve enforcement via the priority
   fallback.

Parent: BUG-987.
2026-05-01 20:39:10 -04:00
xarmian a0336e0248 feat(attachments): bundle attachments + manifest in workspace export (TASK-884) (#305)
* feat(attachments): bundle attachments + manifest in workspace export (TASK-884)

GET /workspaces/{ws}/export?format=tar streams a gzip'd tar bundle:

  pad-export.json              # the existing WorkspaceExport JSON
  attachments/manifest.json    # uuid → {filename, mime, size, hash, ...}
  attachments/<uuid>.<ext>     # original blobs only — no thumbnails

Default (no ?format) keeps returning JSON so existing automation
hitting the endpoint without a query param continues to work
unchanged. The CLI's pad workspace export now opts into the bundle
by default; pass --json for the legacy items-only output.

Implementation:
- store.WorkspaceAttachmentsForExport returns originals only
  (parent_id IS NULL); thumbnails are re-derived on import via the
  existing pipeline so shipping them would double the bundle size.
- handleExportWorkspaceBundle streams chunks straight into the
  response writer rather than buffering — a workspace with multi-
  GB of attachments would otherwise pin that much memory.
- AttachmentManifest is versioned (separate from WorkspaceExport
  version) so the bundle layout can evolve independently.
- bundleAttachmentPath is exported (lowercase package fn) so the
  import path in TASK-885 can resolve manifest entries to tar
  entries without duplicating the filename logic.
- CLI gates against writing binary tar.gz to a TTY and appends the
  conventional extension when -o is passed without one.

Tests:
- TestExportBundle_RoundTrip: two uploads → bundle contains
  pad-export.json + manifest + 2 blobs whose bytes match the
  uploads + manifest decodes cleanly + WorkspaceExport decodes.
- TestExportBundle_HidesThumbnails: synthetic thumbnail row, the
  manifest excludes it.
- TestExportBundle_LegacyJSONStillWorks: no ?format param returns
  application/json with a decodable WorkspaceExport (backward
  compat regression guard).

Parent: PLAN-866. TASK-885 (import path + UUID remap) consumes the
manifest produced here.

* fix(attachments): stream export bundle + revert default to JSON per Codex (round 1)

Two findings from Codex on PR #305:

1. CLI buffered the entire response in memory via RawGet → io.ReadAll,
   defeating the server-side streaming design and risking OOM on a
   multi-GB bundle. Added Client.RawStream which copies the response
   body straight into an io.Writer; export now opens the target file
   and streams directly into it.

2. Default tar.gz output broke `pad export → pad import` round trip
   because the import handler still only accepts JSON. Reverted the
   CLI default to JSON; bundle is now opt-in via --bundle. The flag
   docstring notes that TASK-885 will flip the default once import
   handles bundles.

* fix(attachments): surface tar/gzip close errors and truncation per Codex (round 2)

Codex round 2 finding: deferred tw.Close() / gzw.Close() ignored
errors. If a backend returned fewer bytes than size_bytes claimed,
io.Copy returned nil, the tar writer's "missed N bytes" trip fired
at Close, and the handler still completed a 200 OK with a corrupt
bundle that gunzip would later refuse to decompress — silently from
the operator's perspective.

Two changes:
1. The deferred close now logs both tw.Close() and gzw.Close()
   errors with structured context, so a corruption-on-finalize
   trip shows up in the operator log.
2. streamAttachmentToTar checks the bytes-copied count against
   a.SizeBytes after io.Copy and returns a per-attachment error
   when they disagree. The error is logged with attachment_id +
   storage_key so an operator can correlate the corruption with
   the row to investigate.

Regression test: TestExportBundle_TruncatedBlobLogsError forces a
size_bytes/blob desync via direct UPDATE and asserts the resulting
bundle bytes don't decode cleanly. (HTTP status stays 200 because
headers are already on the wire by the time we detect the desync;
that's an inherent limitation of mid-stream errors, but the new
logs + close-error surfacing make the failure observable.)

* fix(attachments): X-Bundle-Status trailer for export-stream success per Codex (round 3)

Codex P1 round 3: even with the per-blob truncation log + tar/gzip
close-error logs, mid-stream failures looked successful to clients.
The CLI's RawStream finished without a transport error, the file
landed on disk, and "Exported workspace" printed regardless of
whether the bundle was actually complete.

Two complementary signals now mark a clean stream:

1. HTTP trailer X-Bundle-Status. The handler declares the trailer
   in the initial Trailer header and sets it to "ok" only after
   tw.Close() and gzw.Close() both return without error. CLI checks
   the trailer after streaming and discards the file + returns
   error if it's absent or non-"ok".

2. The handler skips the deferred clean close on the error path,
   leaving the gzip footer unwritten. A client that ignores the
   trailer (curl, third-party tooling) still sees a corrupt gzip
   stream that gunzip refuses to decompress.

CLI: pad workspace export --bundle now removes any partial output
file on failure rather than leaving a corrupt one behind.
Client.RawStream signature changed to return (bytes, *http.Response,
error) so callers can inspect resp.Trailer; the only caller is the
export command.

Tests: TestExportBundle_TruncatedBlobAbortsStream now asserts both
signals (trailer absent + gzip/tar can't fully decode), and
TestExportBundle_SuccessTrailer pins the happy-path trailer.
2026-04-29 18:30:43 -04:00
xarmian d3a543db6f feat(attachments): admin per-user storage quota override UI (TASK-883) (#304)
* feat(attachments): admin per-user storage quota override UI (TASK-883)

Surfaces the storage_bytes plan_overrides key in the admin user-detail
page so operators can lift or tighten an individual user's quota
without poking at JSON via the API directly.

Frontend (console/admin/+page.svelte):
- Dedicated "Storage quota override" input below the existing
  overrides grid. Storage is byte-counted, not row-counted, so a
  number input forcing the admin to type 536870912 for 512MB
  would be hostile. Accepts:
    • "10 GB" / "500MB" / "1.5 GB" (IEC shorthand)
    • "1024" (raw bytes)
    • "-1" (unlimited)
    • "" (clear → falls back to plan default)
- Live parse preview ("= 10.0 GB (10,737,418,240 bytes)") so the
  admin can verify the unit was understood.
- "Reset to plan default" button clears the field; save commits
  the absence as a removed override key.
- Pre-fills with the current effective override formatted in the
  largest exact unit so a previously-set "10 GB" doesn't reload as
  "10737418240".

Backend:
- ActionPlanOverridesChanged audit constant.
- handleAdminUpdateUser now logs an audit event with old/new
  override JSONs whenever plan_overrides is patched. Lets operators
  correlate a mysteriously-allowed upload with the override that
  enabled it.

Tests:
- TestAdminUpdateUser_StorageOverrideRoundTrip: PATCH with
  storage_bytes:1073741824 → GET shows the new override → audit
  feed contains plan_overrides_changed event → clearing the
  override removes it.
- TestAdminUpdateUser_NonAdminForbidden: member-role user cannot
  PATCH another user's plan_overrides (regression guard for the
  audit-log path).

Parent: PLAN-866. The Settings → Storage page (TASK-882) reflects
the new effective limit immediately after save because both call
the same WorkspaceStorageInfo helper.

* fix(admin): parse plan_overrides JSON on read, clear via empty string per Codex (round 1)

Two related bugs in the admin user-detail page that Codex caught
in PR #304 round 1:

1. The save path sent JSON null when every override field was
   blank, but the Go handler uses a *string and JSON null decodes
   to a nil pointer — the handler's existing nil-vs-non-nil branch
   then skips the update, meaning "Reset to plan default" reported
   success without actually clearing the override. Fixed by
   sending "" (empty string) which routes through
   SetUserPlanOverrides("") and clears the column.

2. The form-populate path treated u.plan_overrides as an object
   while the API actually returns the raw column value as a JSON
   string. So `'storage_bytes' in ov` was checking string indices
   on a literal '{"storage_bytes":1073741824}' string, returning
   false, and any user with stored overrides loaded a blank form.
   This was a pre-existing bug in the workspaces / api_tokens /
   etc. fields too — fixed for all of them by parsing the JSON in
   parsePlanOverrides() before reading keys, with a defensive
   "future-proof" branch in case the API ever switches to a
   decoded object.

TS type for AdminUser.plan_overrides updated to `string | null`
to match the actual API contract.

Backend regression test added (TestAdminUpdateUser_OmittedOverrides
Preserved) that pins the other half of the contract: PATCH with
plan_overrides absent must NOT clear the column. The test was
straightforward to add because the existing test infrastructure
(bootstrapFirstUser, doRequestWithCookie) already covers the
admin auth path.
2026-04-29 17:56:19 -04:00
xarmian 6461aafd16 feat(store): attachments table + Attachment model (TASK-869) (#286)
Adds the schema groundwork for inline images and file uploads — see
DOC-865 (Attachments — architecture & migration design).

- migrations/047_attachments.sql — SQLite migration. Table + 4 indexes
  (workspace, item, hash, parent). Partial indexes on workspace/item/parent
  match the items table convention. The hash index is full (not partial)
  so dedupe can resurrect a soft-deleted blob if the same bytes are
  re-uploaded without writing a duplicate.
- pgmigrations/026_attachments.sql — Postgres mirror with BIGINT for
  size_bytes; same partial-index pattern.
- internal/models/attachment.go — Go model with all columns. Uses
  pointer types for nullable columns (item_id, width, height, parent_id,
  variant, deleted_at) so JSON omitempty works correctly.

No call sites yet — purely schema groundwork. Verified the migration
runs cleanly on a fresh install and on the live dev DB.

Parent: PLAN-866.
2026-04-29 11:34:35 -04:00
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00
xarmian afe721d202 feat(cli): add Cloud mode to pad init, drop Docker option (TASK-837, TASK-838) (#272)
Merging despite Go (PostgreSQL) red — those failures (TestListItems_FTS_HyphenatedSearchTerm/task-five + TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly TempDir cleanup race) are pre-existing on main and tracked in BUG-842.

Codex reviewed in 3 rounds (round 1 clean → round 2 found a real semantic bug → fix → round 3 clean). Tests, vet, and lint all green; remaining check failures are documented pre-existing.
2026-04-28 09:41:50 -04:00
xarmian dd381e1066 chore: delete 5 unwired document handlers (TASK-769) (#252)
* chore: delete 5 unwired document handlers (TASK-769)

internal/server/handlers_documents.go had 5 dead HTTP handlers that
were drafted as Documents-v1 extensions but never wired into the
router (server.go:509 already labels Documents itself as "v1, will be
replaced by items in Phase 2"):

- handleQuickSave (POST /documents/quick-save) — title-based upsert
- handleBulkRead (POST /documents/bulk-read) — multi-doc fetch by IDs
- handleGetBacklinks (GET /documents/{id}/backlinks)
- handleGetLinks (GET /documents/{id}/links)
- handleGetContext (GET /documents/context?type=)

Investigation confirmed zero consumers:

- Not registered in setupRouter (`grep -n "QuickSave\|BulkRead\|Backlinks\|GetLinks\|GetContext" server.go` → empty).
- Not used by the SvelteKit frontend (`web/src/`).
- Not used by the CLI (`internal/cli/`).
- Pre-launch repo, no fork or downstream that could be relying on them.

Delete scope is intentionally limited to the HTTP handlers. The
underlying `Store.QuickSave / BulkRead / GetBacklinks / GetLinks /
GetContext` methods stay — they're tested at the store level
(internal/store/store_test.go) and preserve optionality if Phase 2
work needs to revive any of these features. `models.QuickSave` stays
for the same reason.

After this lands, IDEA-732's lint catalog is fully cleared on main
(staticcheck SA* + U1000 returns zero). TASK-771 (flip CI
only-new-issues=false) becomes safe.

Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass
- `staticcheck -checks "SA*,U1000" ./...` clean
- All `import "strings"` etc. still used elsewhere in file

Parent: PLAN-644.

* chore: also delete now-test-only document store helpers (TASK-769)

Codex round 1 on PR #252 flagged that the document-store helpers
retained for "Phase 2 optionality" are now exclusively kept alive by
their own store tests — Store.QuickSave, BulkRead, GetBacklinks,
GetLinks, GetContext are not called by any production code path after
the handler deletions in the previous commit. Same for the
models.QuickSave struct.

Pre-launch with no external consumers, optionality preservation has a
real cost (dead code on main, ongoing test maintenance). When Phase 2
needs any of these capabilities it is cheaper to re-derive them
against the Items model than to drag dead Documents-v1 plumbing
forward. So delete them now.

Removed:
- internal/store/documents.go: QuickSave (38 lines), BulkRead (28),
  GetBacklinks (15), GetLinks (28), GetContext (41).
- internal/models/document.go: QuickSave struct.
- internal/store/store_test.go: TestQuickSave (38 lines), TestBulkRead
  (16), TestDocumentLinking (29), TestContext (23).

Kept:
- TestDocumentLinkRename — exercises UpdateDocument's internal
  link-rewriting path, not any of the deleted helpers.
- GetDocumentByTitle — still used by TestDocumentLinkRename.
- The full CRUD/restore handlers and their store methods — these are
  still wired into setupRouter and have their own coverage.

Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass (TestDocumentLinkRename and the wider doc
  CRUD/version/activity tests still cover the surviving paths).
- `staticcheck -checks "SA*,U1000" ./...` clean
- No new unused imports introduced (links package is still used by
  documents.go for ReplaceTitle in UpdateDocument).

Parent: PLAN-644.

* chore: drop GetDocumentByTitle and refactor TestDocumentLinkRename (TASK-769)

Codex round 2 caught the chain — after deleting QuickSave/BulkRead/
GetBacklinks/GetLinks/GetContext, Store.GetDocumentByTitle was kept
alive by exactly one test (TestDocumentLinkRename), which was
re-fetching by title only because the test ignored the *Document
already returned by createTestDoc.

Use the createTestDoc return value instead, then drop GetDocumentByTitle
from the store. Same idea, cleaner test, one fewer test-only API on
the store. The rename behaviour (the actual thing under test) is
unchanged.

Verified:
- `go build ./...` clean
- `go test ./internal/store` and `./internal/server` pass
- `staticcheck -checks "SA*,U1000" ./...` still clean

Parent: PLAN-644.

* chore: drop now-orphaned links.Extract (TASK-769)

Codex round 3 caught the next link in the chain: after Store.GetLinks
was deleted, links.Extract had no remaining callers — links.ReplaceTitle
is the only Extract-package function still used (by UpdateDocument's
rename rewrite). The linkPattern regex was only used by Extract.

Drop linkPattern, the regexp import, and Extract itself. Leaves
ReplaceTitle and its private string helpers (replaceAll, indexOf)
intact.

The cleanup chain ends here: ReplaceTitle is still wired into a live
production path (Documents-v1 rename), and the supporting helpers
have no other roles to inherit.

Verified:
- `go build ./...` clean
- `go test ./internal/store` and `./internal/server` pass
- `staticcheck -checks "SA*,U1000" ./...` clean

Parent: PLAN-644.
2026-04-25 12:22:53 -04:00
xarmian 157ca4e88f chore: bump Go toolchain to 1.26 (TASK-763) (#247)
* chore: bump Go toolchain to 1.26 (TASK-763)

Bump Go from 1.25 to 1.26 across all toolchain pins:

- go.mod — go 1.25.0 → go 1.26.0
- Dockerfile — golang:1.25-alpine → golang:1.26-alpine
- .github/workflows/ci.yml — three setup-go steps (Go, Go-Postgres, E2E jobs)
- .github/workflows/release.yml — release pipeline

No `toolchain` directive: the repo is pre-launch with no external
contributors yet, so we set the floor where we want it (hard requirement).

Verified locally before commit:
- golangci-lint v2.11.4 builds and runs under Go 1.26.2 (pinned in CI)
- golang:1.26-alpine and 1.26.2-alpine images present on Docker Hub
- go build ./... clean
- go vet ./... clean
- go test ./... all pass

Parent: PLAN-644 (OSS Repo Hygiene and Launch Polish).

* chore: gofmt -w under Go 1.26 (TASK-763)

Apply Go 1.26's gofmt to the codebase. ~41 files reformatted, all
struct-tag whitespace realignment — no semantic changes. Verified:

- gofmt -l ./cmd ./internal returns empty after
- go build ./... still clean
- go test ./... still passes (run before commit)

Bundling the gofmt diff with the toolchain bump in the same PR because
the formatting drift is a direct consequence of moving from 1.25 to
1.26; splitting them creates a mandatory two-PR ordering for no value.

Parent: PLAN-644.

* docs: bump documented Go floor to 1.26 (TASK-763)

Match go.mod's hard 1.26.0 requirement in the source-build instructions.
Caught by Codex review round 1 on PR #247.

- README.md:158 — "Go 1.25+" → "Go 1.26+"
- CONTRIBUTING.md:9 — "Go 1.25+" → "Go 1.26+"
2026-04-25 11:35:19 -04:00
xarmian 119e2d8aa2 feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) (#232)
* feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2)

Pairs with pad-cloud's invoice.payment_failed webhook handler (shipping
next) to give paying users a chance to update their card before dunning
exhausts and the subscription cancels. pad owns the Maileroo integration
and the user→email mapping; the sidecar forwards the invoice metadata
here.

Changes:

- email.Sender.SendPaymentFailed — new template (HTML + plain). Subject
  "Your Pad payment couldn't be processed"; body names the amount +
  next retry date when provided, falls back to generic copy when Stripe
  omits them, and CTAs to the billing portal so the user can update
  their card. Transactional (no unsubscribe link) — users who want the
  emails to stop either fix their card or cancel the subscription.

- POST /api/v1/admin/payment-failed — new cloud-secret-gated endpoint
  (handlers_cloud.go). Accepts stripe_customer_id + optional pre-
  formatted amount_display + next_retry_display. Looks up the user,
  sends the email, logs a payment_failed_email_sent audit entry.
  Returns 200 + email_sent=false with a reason string for every
  non-error skip (unknown customer, no email on file, Maileroo not
  configured) so the sidecar never rolls back the Stripe webhook over
  an email failure. Returns 200 + email_sent=false + reason=send_failed
  when Maileroo itself errors — still no rollback.

- Registered the path in cloudAdminPaths, the server router, and the
  CloudAdmin rate limiter so the sidecar's calls share the same rate
  bucket as /plan + /stripe-customer-id.

- ActionPaymentFailedEmailSent audit constant for the new entry.

- Three focused tests: cus_ prefix validation, unknown-customer 200,
  and email-not-configured 200. Added an entry to the cloud-mode gate
  table-driven test to confirm /admin/payment-failed also 404s when
  cloud mode is off.

Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 3, pad
side. pad-cloud's handlePaymentFailed wiring ships in a sibling PR.

* fix(billing): audit every outcome; target user ID; add send-path tests (Codex round 1)

Addresses PR #232 round 1 findings:

MEDIUM — payment-failed handler only wrote an audit row on the actual
send attempt, so no_customer / no_email_address / email_not_configured
skip paths left no durable trail. Consolidated the audit + response
into a single auditAndRespond closure called from every outcome
branch, so operators can always reconstruct whether (and why) a
customer was notified during dunning reconciliation.

MEDIUM — audit UserID was set to actorID, which is empty for sidecar
calls. /audit-log?user=<target-user-id> would never surface these
events. Now set UserID to targetUser.ID whenever we have one; the
no_customer branch still writes a row but with empty UserID (filtered
only by action + stripe_customer_id metadata). Moved actor identity
into an actor_is_admin metadata field instead.

LOW — test coverage was thin: no assertion on the most important
contract ("return 200 with reason=send_failed and still record the
attempt"), no test of the happy send path, no audit-log assertions.
Added email.Sender.SetEndpoint (exported, test-only — comment says
so) so tests can point the Sender at a mock Maileroo server, plus
three new tests:
  - TestPaymentFailed_HappyPath_SendsAndAudits
  - TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits
  - TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID
The first two verify audit metadata per outcome; the third proves
unknown-customer cases still leave a findable audit row.

Thread-safety fix as a side-effect: Send/SendAs were reading s.endpoint
outside the sender's RWMutex — fine before the mutable SetEndpoint
existed, now a data race. Pulled the endpoint read into the same
RLock scope as fromAddr/fromName.

* fix: capture admin actor ID + audit-log formatter for payment_failed (Codex round 2)

Addresses PR #232 round 2 findings:

MEDIUM — auditAndRespond recorded actor_is_admin=true/false but not
which admin. For manual operator-triggered calls, that meant the audit
trail could not answer "who sent the dunning email?" when multiple
admins touched the endpoint. Added admin_actor_id to the metadata
whenever the authenticated caller has role=admin. Sidecar calls with
no authenticated user still have no admin_actor_id, which correctly
distinguishes them from manual admin operations.

LOW — web/src/routes/console/admin/audit-log/+page.svelte falls back
to "first 3 metadata keys" when no formatter exists for an action,
which could hide the important reason/sent fields. Added a dedicated
case for payment_failed_email_sent that renders either "sent (cus_...)"
or "skipped: <reason> (cus_...)" depending on the outcome, matching
the terse display style of the other switch cases.

* fix(audit-log): distinguish send_failed from skip; surface admin actor (Codex round 3)

Addresses PR #232 round 3 LOWs:

- The formatter lumped every sent=false outcome under 'skipped', which
  conflates a genuine Maileroo delivery failure with a pre-send skip.
  Now: sent → 'sent (...)'; send_failed → 'send failed (...)'; other
  reasons → 'skipped (<reason>) (...)'.
- admin_actor_id was recorded in metadata but invisible in the UI: the
  User column shows the target user via a.user_id. Appended
  'by admin:<id>' to the formatted string whenever admin_actor_id is
  present, so manual operator calls are attributable at a glance.
  Sidecar calls have no admin_actor_id and render without the suffix.

* fix(audit-log): register payment_failed_email_sent in action filter dropdown (Codex round 4)

The backend emits payment_failed_email_sent and the custom formatter
knows how to render it, but the audit-log page's ACTION_TYPES /
ACTION_LABELS registry omitted the action, so admins couldn't filter
for these events from the dropdown — undercutting the dunning
reconciliation workflow this PR is adding. Added 'payment_failed_email_sent'
to the ACTION_TYPES list and 'Payment Failed Email' to ACTION_LABELS.
2026-04-24 01:50:08 -04:00
xarmian 775dd89fdc feat(cloud): add /admin/stripe-event-unmark endpoint (TASK-736 / 1 of 2) (#228)
* feat(cloud): add /admin/stripe-event-unmark endpoint (TASK-736 / 1 of 2)

Parent: PLAN-645. Pair with pad-cloud follow-up.

* fix(cloud): add processed_at race protection + audit log per Codex review (round 1)
2026-04-23 20:26:02 -04:00
xarmian 46fa72ca0f feat(server): log session IP changes, add optional PAD_IP_CHANGE_ENFORCE=strict (TASK-666) (#191)
* feat(server): log session IP changes, add optional PAD_IP_CHANGE_ENFORCE=strict (TASK-666)

Sessions stored a client IP at creation but never rechecked it. A stolen
cookie could be used from anywhere with no signal to the owner. This
change adds mid-lifetime IP-change detection without breaking legitimate
mobility (mobile roaming, VPN toggles, carrier NAT) by default.

- New audit action ActionSessionIPChanged captures {old_ip, new_ip} in
  the audit metadata. Visible via the existing /api/v1/admin/audit-log.
- handleSessionIPChange wired into both SessionAuth (cookies) and
  TokenAuth (padsess_ bearer). After UA check passes, compares stored
  session IP to clientIP(r). On mismatch:
    - log one audit row
    - update the stored session IP so we don't spam the log
    - strict mode: DeleteSession + 401 "session_ip_changed"
    - default mode: let the request through
- Store.UpdateSessionIP lets middleware refresh the recorded IP without
  tearing down the session.
- PAD_IP_CHANGE_ENFORCE=strict env var + ip_change_enforce TOML key +
  Server.SetIPChangeEnforce setter (case-insensitive, trims whitespace).
- Table-driven tests cover log-only, strict rejection with session
  destruction, and setter parsing edge cases.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): dedupe session-IP-change audit via CAS, handle browser vs API paths per Codex review

Addresses two P2 comments on PR #191:

1. Race: parallel requests after an IP change could each emit
   ActionSessionIPChanged before any of them updated the stored IP,
   producing duplicate audit rows for a single transition.
   - Replace UpdateSessionIP with UpdateSessionIPIfEquals (compare-and-set
     on ip_address). Only the request that actually rotates the stored
     value logs; concurrent siblings lose the CAS and skip logging.
   - New test TestSessionIPChange_CASDedupesRace fires 20 concurrent
     requests from the new IP and asserts exactly 1 audit row.

2. Strict-mode 401 on non-API paths:
   - In current routing the SPA is mounted on the root router outside
     the auth Group, so SessionAuth only fires for /api/* in practice.
     The original concern about JSON 401s on browser navigation doesn't
     surface today, but defense-in-depth keeps the code forward-safe:
     restructure handleSessionIPChange to return a four-state outcome
     (Continue / AllowedLogged / Revoked / Terminated) and only write
     the JSON 401 on /api/* paths. Revoked + non-API falls through
     unauthenticated so a future SPA-in-group configuration would still
     render a login screen instead of raw JSON.
   - Clear the session cookie (MaxAge=-1) in strict rejection so the
     browser stops sending the now-revoked token on the next request.
     TestSessionIPChange_StrictClearsCookies verifies the Set-Cookie.

Parent: PLAN-643 (OSS Security Hardening), TASK-666.

* fix(server): strict mode destroys session atomically, never rotate stored IP when destroying (TASK-666)

Addresses Codex P1 on PR #191: previously we rotated the session's stored
ip_address via UpdateSessionIPIfEquals BEFORE attempting DeleteSession.
If the DELETE failed (transient DB error) the row remained alive —
rebound to the attacker's new IP — so follow-up requests saw stored IP
== client IP and passed handleSessionIPChange's "match, no-op" branch.
That silently defeated strict enforcement.

- New Store.DeleteSessionIfExists returns (bool, error) to serve as the
  CAS primitive for strict mode: only the caller whose DELETE affected a
  row emits the audit entry, and a DB error fails closed (500 — "Unable
  to validate session") rather than letting the request through.
- handleSessionIPChange splits into two paths:
    * log-only mode: UpdateSessionIPIfEquals for CAS dedup (unchanged)
    * strict mode: DeleteSessionIfExists is the CAS; stored IP is NEVER
      rotated so any failure leaves the session bound to the OLD IP and
      subsequent requests from the new IP still mismatch + still reject.
- TestSessionIPChange_StrictDestroysSessionAtomically regression test
  verifies a second request from the new IP with the same token still
  fails after the first strict-mode rejection.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): exempt public API paths from strict IP-change termination (TASK-666)

Addresses Codex P2 on PR #191: SessionAuth runs for every /api/* path,
including public endpoints like /api/v1/auth/login, /api/v1/auth/register,
/api/v1/health, /api/v1/s/* (share links), and /api/v1/plan-limits. In
strict mode, a stale session cookie on those requests was rejected with
a 401 session_ip_changed BEFORE the public handler could run — the user
literally couldn't log back in because their own stale cookie blocked
the login call.

- Extract isPublicAPIPath as a shared helper between RequireAuth and
  handleSessionIPChange so they can't drift out of sync.
- handleSessionIPChange strict-mode flow now: destroy session + clear
  cookies + audit log (unchanged), then for public API paths return
  Revoked so the handler still runs. For authenticated-only API paths
  still return Terminated (401). For non-API paths return Revoked for
  the SPA fallback.
- Updated TokenAuth Revoked handler to match: pass through unauth on
  public paths, 401 on authenticated-only.
- TestSessionIPChange_StrictAllowsPublicAPIPaths regression test:
  a stale session cookie on /api/v1/auth/login must NOT produce
  session_ip_changed; /api/v1/plan-limits must still return 200.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): short-circuit SessionAuth on token auth + fix IPv6 clientIP parsing (TASK-666)

Addresses two more Codex comments on PR #191:

P1 — SessionAuth 401'd API-token-authenticated requests:
TokenAuth sets currentUser for user-owned tokens AND tokenWorkspaceID
for legacy workspace-scoped tokens. SessionAuth short-circuited only on
currentUser, so a workspace-scoped-token request that happened to carry
a stale session cookie with a mismatched IP would be rejected by the
IP-change strict path before RequireAuth could honor the token. Extend
the short-circuit to also check tokenWorkspaceID; either signal is
enough to say "token auth already succeeded, skip cookie validation".

P2 — clientIP mangled IPv6 addresses:
clientIP used strings.LastIndex(":") on RemoteAddr. For bare IPv6
addresses like "2001:db8::1" (which TrustedProxyRealIP writes verbatim
from X-Forwarded-For, no brackets/port), that strips the final hextet
to "2001:db8:" — unusable for comparison in the new IP-change audit
path and incorrect for rate-limit keys too. Switch to net.SplitHostPort
which handles both "host:port" and "[ipv6]:port", falling back to the
raw RemoteAddr when no port is present (the trusted-proxy rewrite
case).

Tests:
- TestClientIP_IPv6NotMangled covers IPv4 w/wo port, bracketed IPv6,
  bare IPv6 (no port, no brackets), and loopback forms.
- TestSessionAuth_ShortCircuitsOnAPITokenAuth exercises the worst case:
  strict mode + valid API token + stale session cookie + new client IP.
  Request must succeed (token wins) and NO new session_ip_changed audit
  row must appear.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): canonicalize IPs before session-IP-change comparison (TASK-666)

Addresses Codex P2 on PR #191: raw-string comparison of session.IPAddress
vs clientIP(r) would fire session_ip_changed spuriously when the same
IPv6 address arrived in different valid textual representations (the
trusted-proxy path writes X-Forwarded-For verbatim, and different hops
normalize differently — "2001:0db8::1" vs "2001:db8::1" etc.).

- canonicalIP helper: net.ParseIP + stringify to collapse equivalent
  IPv6 forms (compressed vs expanded, case, leading zeros) and IPv4-in-
  IPv6 into a single canonical string. Non-parseable inputs pass through
  unchanged so debug/malformed values behave predictably.
- handleSessionIPChange compares and logs the canonical forms. The CAS
  still passes session.IPAddress (the raw stored value) to the DB — the
  compare-and-set is about row identity — but the new IP written in is
  the canonical form so future comparisons are stable.
- TestCanonicalIP covers empty, IPv4, shorthand "::1", expanded 8-group
  equivalent, mixed-case 2001:DB8::1, fully expanded 2001:0db8:…:0001,
  and non-IP fallback.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-22 10:43:22 -04:00
xarmian 33b3f21a2c feat(auth): expire workspace invitations after 14 days (TASK-649) (#176)
* feat(auth): expire workspace invitations after 14 days (TASK-649)

A workspace invite code lives forever until accepted. A leaked code —
email forwarding, stale screenshot, git history — lets any attacker who
registers the invitee's email claim the workspace seat months or years
later.

Introduce a 14-day default expiry:

- New migration (SQLite 044 + Postgres 024) adds expires_at TEXT to
  workspace_invitations with an index, backfilling existing rows to
  created_at + 14 days so old codes also age out.
- Store CreateInvitation sets expires_at = now + InvitationTTL;
  GetInvitation/GetInvitationByCode/ListWorkspaceInvitations read and
  populate ExpiresAt. Legacy rows with NULL expires_at are treated as
  non-expiring (backward compat for codes created before the migration).
- Model gains ExpiresAt *time.Time and an IsExpired() helper, nil-safe.
- handleAcceptInvitation returns 410 Gone "expired" for expired codes.
- handleRegister (invitation path) returns 410 Gone with the same
  message so the signup flow surfaces expiry distinctly from "invalid
  code".

Tests: models.TestWorkspaceInvitation_IsExpired covers nil/past/future
plus a nil-receiver safety check.

Parent: PLAN-643 (OSS Security Hardening).

* fix(store): backfill invitation expires_at in RFC3339 per Codex P1

Codex caught that the first cut of migration 044 (SQLite) and 024 (Postgres)
emitted space-separated timestamp strings, which parseTime silently rejects —
legacy invitations would all show up as zero-time ExpiresAt and be treated
as already-expired right after upgrade.

- SQLite: switch to strftime('%Y-%m-%dT%H:%M:%SZ', created_at, '+14 days').
- Postgres: use to_char(..., 'YYYY-MM-DD"T"HH24:MI:SS"Z"').

Add regression tests:
- TestCreateInvitation_SetsExpiresAt — fresh invitations get expiry ~14d out.
- TestMigration044_BackfillProducesRFC3339 — inserts a legacy row with NULL
  expires_at, applies the same backfill expression as the migration, and
  verifies the round-tripped ExpiresAt is non-zero, parses correctly, and
  is ~InvitationTTL after created_at.

* fix(store): drop AT TIME ZONE cast in PG backfill per Codex P2

Codex flagged that '(timestamp + INTERVAL) AT TIME ZONE UTC' yields a
timestamptz, and to_char(timestamptz, ...) renders using the session's
TimeZone — on a non-UTC Postgres instance, legacy invitations get
offset-shifted values mislabeled with a 'Z' suffix.

created_at is already stored as UTC text, so casting it to a naive
timestamp and doing the interval math without further conversion is
both correct and tz-independent. to_char on a plain timestamp uses the
stored value as-is and the hardcoded 'Z' suffix labels it accurately.
2026-04-21 20:08:05 -04:00
xarmian 9e7daa779f feat: tie done-detection to the board group-by field (TASK-604) (#140)
* feat: tie done-detection to the board group-by field

Closes TASK-604. Make "is this item done?" follow the collection's
settings.board_group_by rather than the hardcoded `status` key. If a
collection's board is grouped by `resolution`, then resolution's
terminal options drive dashboard counts, progress bars, changelog,
and starred-items filtering. Collections without an explicit
board_group_by (every collection today) continue to behave exactly
as before because the fallback resolves to `"status"`.

Why this shape
- No ambiguity: one field per collection wins. No reconciling
  "status says in-progress, resolution says fixed."
- One JSON path to swap: every $.status query becomes
  $.<done_field>. No dynamic OR across schema-discovered fields.
- Matches the mental model: the field you organize the board by is
  the field that represents the item's current state. The old
  mismatch (board grouped by X, "done" count from status) is a
  latent bug this resolves.
- Non-breaking: board_group_by defaults to nil → DoneFieldKey
  returns "status" → behavior identical to pre-TASK-604.

Model layer (internal/models/terminal.go)
- DoneFieldKey(schema, settings) resolves the done-field key with a
  fallback chain: valid select on schema → that field, else "status".
- TerminalValuesForDoneField(schema, settings) returns (fieldKey,
  values) honoring the done field, falling back to
  DefaultTerminalStatuses when the resolved field has no
  terminal_options.
- TerminalPlaceholdersForDoneField(schema, settings) is the SQL
  convenience returning (fieldKey, placeholders, args).
- IsTerminalItem(fields, schema, settings) is the canonical
  Go-side membership check.
- Legacy API (TerminalStatusesFromSchema, IsTerminalStatus,
  TerminalStatusPlaceholders) kept as back-compat wrappers that
  delegate with empty settings — resolve to "status" for callers
  that don't have settings in scope yet.

SQL callers migrated to the new helpers
- internal/store/collections.go ListCollections active-count query
- internal/store/items.go GetItemProgress + GetAllItemProgress:
  - New collectionDoneFilter type + childrenDoneFiltersFor{Parent,
    Collection} + doneFiltersForWorkspace helpers load each
    candidate collection's (schema, settings) and resolve per-
    collection done keys + terminals.
  - buildChildrenDoneExpr(filters, alias) compiles filters into a
    single SQL boolean expression using per-collection OR clauses:
      ((alias.collection_id=? AND LOWER(...)
        IN (?,?)) OR (alias.collection_id=? AND LOWER(...)
        IN (?,?)) ...)
  - Each child item is evaluated against its own collection's
    done rules, so mixed-collection child progress is correct
    without a global union hack.
- internal/store/agent_roles.go GetRoleBreakdown + Go-side filter
- internal/store/item_stars.go starred-items filtering now uses a
  collectionDoneContext map (schema + settings) and IsTerminalItem.

Go-side callers migrated
- internal/server/handlers_dashboard.go: buildSchemaMap →
  buildDoneContextMap (carries settings), isItemTerminal →
  isItemDone (evaluates against the done field). 7 call sites
  updated.
- internal/server/handlers_items.go: plan-progress recompute and
  per-item /progress endpoint now use the done-context approach.

Left status-specific (per task scope)
- Link-payload $.status extracts in items.go getItemLink /
  GetItemLinks / GetParentForItem — these populate
  link.SourceStatus / link.TargetStatus, which are status-specific
  by design.
- cmd/pad reconcile paths — no schema in scope, default-list
  fallback is the right call.
- search.go facet "status breakdown" — a different UX concept
  (bucket search results by status values) than done-detection.

Web UI reactivity
- FieldEditor: new activeDoneField prop. Each modal derives it from
  boardGroupBy with the same fallback rule as the Go DoneFieldKey.
- Fields tab: the "Done?" column header on each select field renders
  an "Active" green pill when that field is the board group-by, or a
  muted "Saved" pill + inline hint otherwise ("Switch the board
  group-by to <key> to make them drive done-detection"). Reactive to
  boardGroupBy changes in the Display tab.
- DisplaySettingsEditor: "Board group by" label gets a helper line
  explaining the new responsibility.

Tests
- internal/models/terminal_test.go: 13 unit tests covering fallback
  resolution, placeholder args, membership (case-insensitive), and
  back-compat shim semantics.
- internal/store/done_field_test.go: 3 integration tests:
  1. Bugs collection grouped by resolution → items with terminal
     resolution values count as done; items with status=fixed but
     resolution=open do NOT count as done (proves status is no
     longer consulted when it isn't the done field).
  2. Collection without board_group_by still uses status terminals.
  3. Mixed-collection children: each child evaluated against its
     own done rules.
All pass alongside the full existing suite.

* fix: restrict done field to select (reject multi_select)

Two linked Codex P1 findings on PR #140, both rooted in the same
gap: multi_select fields store their values as JSON arrays, but both
the Go-side membership check (IsTerminalItem) and the SQL done
expression (buildChildrenDoneExpr) assume a scalar string. Naively
accepting multi_select as a done field would silently miss items
whose terminal value is one of several in the array — dashboards
and progress would report wrong counts.

Rather than implement array-containment semantics across both
paths (which would require deciding "any terminal value → done" vs
"all terminal values → done", SQL-dialect-aware JSON-contains, and
new tests for both shapes), close the gap with a constraint: only
select fields qualify as a done field. If array semantics become
a requirement later, that's a focused follow-up that can update
both paths together with a clear definition.

Changes
- DoneFieldKey and TerminalValuesForDoneField: loop bodies now
  match only `select`, not `select || multi_select`. A
  board_group_by pointing at a multi_select field falls back to
  'status' — matching the rule for non-existent or non-select
  fields.
- IsTerminalItem: docstring made the scalar contract explicit;
  non-string values (which would be the multi_select array shape)
  already returned false, which is now the deliberate behavior.
- buildChildrenDoneExpr: added a doc note that the scalar
  JSON_EXTRACT path is correct because the upstream resolution
  only hands us select fields.
- Web UI: EditCollectionModal + CreateCollectionModal derive
  activeDoneField matching the backend rule (select only), and
  FieldEditor.isActiveDoneField gates on field.type === 'select'.
  A multi_select field never lights up the green "Active" pill now,
  even if a user somehow pointed board_group_by at one.

Tests
- Replaced TestDoneFieldKey_AcceptsMultiSelect with
  TestDoneFieldKey_RejectsMultiSelect. Asserts that a multi_select
  board_group_by falls back to 'status' instead of being honored.
- Existing 12 unit tests + 3 integration tests all still pass.

* fix: include soft-deleted collections in done-filter loaders

Two related Codex P2s on PR #140. The done-filter loaders were
limiting their SELECT to collections with deleted_at IS NULL, but
the outer callers (GetItemProgress, GetAllItemProgress,
GetRoleBreakdown) count items regardless of their collection's
deleted_at. Net effect: after a collection was soft-deleted, its
items lost their per-collection clause in buildChildrenDoneExpr and
were always evaluated as non-terminal — undercounting done in plan
progress and inflating active counts in the role breakdown.

Fix
Drop the `c.deleted_at IS NULL` guard from all three filter
loaders:
- childrenDoneFiltersForParent
- childrenDoneFiltersForCollection
- doneFiltersForWorkspace

Soft-deleted collections still have valid schema + settings rows in
the DB, so the done rules remain applicable until a hard delete
cascades. This also matches what the outer queries count: if they
include items from a soft-deleted collection, the filter loaders
must too.

Regression test
TestGetItemProgress_HonorsSoftDeletedChildCollections:
  1. Create a parent + two children in a child collection where one
     child is done and one is open — assert done=1.
  2. DeleteCollection on the child collection (soft-delete).
  3. Re-run GetItemProgress — assert done is still 1, not 0.
Fails before the filter-loader fix, passes after.

* fix: avoid N+1 in plans progress + preserve done fallback on bad schemas

Two Codex P2s on PR #140.

P2: Avoid N+1 list-collection queries in plans progress
handlePlansProgress's restricted path was calling s.store.
ListCollections solely to build a ctxMap, but ListCollections runs a
separate active-item COUNT query per collection (collections.go),
burning O(number of collections) round-trips on every call. In
larger workspaces this materially inflates latency and can cause
timeouts. Add a lightweight Store.ListCollectionsMinimal that
returns only the ID / Schema / Settings needed for done-context
construction and skips the count queries entirely. Handler switches
to it.

P2: Preserve done fallback for unparseable collection schemas
scanCollectionDoneFilters was `continue`-ing past collections whose
schema failed to parse. Because buildChildrenDoneExpr composes a
per-collection OR clause and only applies the default-list fallback
when NO filters are constructed overall, a single malformed
collection could leave its items without a matching clause —
silently marking them as perpetually active in progress / role /
starred queries. Emit a fallback filter (status + DefaultTerminal-
Statuses) for that collection instead of skipping it, matching
pre-TASK-604 behavior for its items while still honoring the
configured rules for every other collection.

* fix: sanitize done-field keys + cover granted-item collections

Two more Codex findings on PR #140.

P1: Sanitize done-field keys before embedding SQL JSON paths
buildChildrenDoneExpr passes the resolved done-field key straight
into JSONExtractText, whose dialect implementations interpolate it
as a string literal inside `json_extract(..., '$.<key>')` /
`-->>'<key>'`. Schema / settings rows are persisted without backend-
side key validation, so a crafted board_group_by (e.g. a key with
quotes, semicolons, or SQL metacharacters) could break the
resulting query or inject. Since TASK-604 made done-field
resolution dynamic, this needs a chokepoint.

Fix: DoneFieldKey now refuses to resolve to any candidate that
doesn't match ^[a-zA-Z][a-zA-Z0-9_]*$ and falls back to the literal
"status" (which is always safe). The pattern matches the convention
already in use for search-field filtering in internal/server/
handlers_search.go.

Added TestDoneFieldKey_RejectsUnsafeKeys covering injection-shaped
strings, dots, dashes, leading digits, empty strings, and spaces.

P2: Include granted-item collections in dashboard done context
The dashboard was filtering `collections` by visibility BEFORE
building ctxMap, but allItems can still include items from
collections outside the visibility set via item-level grants
(dashItemIDs). Those items missed their own done-rules and
fell back to the status-default, misclassifying them for guests
with item-level grants in collections that use a non-status done
field.

Fix: build ctxMap from ListCollectionsMinimal(workspaceID) first —
always covering every collection in the workspace — then apply
visibility filtering to `collections` for the summary section only.
isItemDone now sees the real done rules for every item the
dashboard iterates, regardless of how visibility surfaced it.

* fix(web): mirror backend safe-key check in activeDoneField derivation

Codex P2 on PR #140. The previous commit added a safe-key regex on
the backend (DoneFieldKey rejects keys outside ^[a-zA-Z][a-zA-Z0-9_]*$
and falls back to "status"), but the Web activeDoneField derivation
in both modals only checked type === 'select'. For legacy / API-
created schemas carrying keys like `resolution-v2` or `foo.bar`, the
Fields tab would display an "Active" green pill on that field even
though the server silently ignores it and falls back to status. Users
could configure terminal options on the wrong field and never see
them take effect.

Fix: export isSafeDoneFieldKey from field-editor-types.ts (a tiny
helper wrapping the same regex the backend uses) and gate both
modals' activeDoneField derivations on it. Unsafe keys fall back to
'status' in the UI, matching the backend's behavior exactly —
Active/Saved pills are now truthful.
2026-04-17 21:45:55 -04:00
xarmian e328844a1b fix: resolve five open bugs (BUG-585, BUG-586, BUG-588, BUG-589, BUG-590)
BUG-585 — Code-block copy no longer includes ``` fences
  Editor.svelte: ProseMirror plugin overrides copy/cut when the selection
  is inside a code_block node and writes raw textBetween to the clipboard.
  NodeView for non-mermaid code blocks now shows a hover "Copy" button that
  uses the existing copyToClipboard() util (with execCommand fallback).

BUG-586 — Wiki-link picker matches on item ref
  Editor.svelte: getFilteredLinks() now also matches formatItemRef(item),
  so typing [[DOC-535]] finds items by their issue ID. Picker dropdown
  shows the ref as a badge; {#each} key switched to doc.id so duplicate
  titles across collections don't collide.

BUG-588 — Can unlink OAuth provider when password is configured
  Adds a password_set column to track whether a user has a usable
  password vs. the random placeholder hash given to OAuth users.
  CreateUser sets it true, UpdateUser sets it true when a password is
  provided, and ValidatePassword auto-upgrades it on any successful
  email/password login (which transparently upgrades pre-existing users
  who linked OAuth after signing up with a real password — the OAuth
  placeholder hash cannot match user-supplied plaintext, so this is safe).
  handleOAuthUnlink now permits removing the last provider when
  user.HasPassword() is true.

BUG-589 — Pre-auth pages render standalone
  +layout.svelte: isAuthPage now also matches /forgot-password and
  /reset-password/* so those pages don't inherit the authenticated
  sidebar/topbar layout.

BUG-590 — Search no longer crashes with null results
  store.Search() returned a nil Results slice on no-match queries, which
  Go marshals as JSON null; CommandPalette then crashed on results.length.
  Backend now normalizes nil to []SearchResult{} before returning.
  CommandPalette also coalesces resp.results ?? [] on the initial search
  and loadMore paths as belt-and-suspenders hardening.
2026-04-17 03:03:49 +00:00
xarmian b3af1acd07 feat: add last active tracking for users (#105)
* feat: add last active tracking for users

Track when users were last active via a throttled update (once per 5
minutes) in the auth middleware. Adds last_active_at column, displays
relative time in admin user list with full timestamp on hover.

* fix: bound last-active goroutine with 3s context timeout

Use a short-lived context for the background TouchUserActivity write
so it gets cancelled under DB pressure, preventing goroutine/connection
buildup from unbounded background work.
2026-04-13 22:19:41 -04:00
xarmian d968b551b7 feat: add account disable/deactivation (#104)
* feat: add account disable/deactivation for admin users

Allow admins to soft-disable user accounts without deleting data.
Disabled users get a 403 on all authenticated requests, their sessions
are invalidated on disable, and they show as visually dimmed with a
red "disabled" badge in the admin console. Includes migration for
disabled_at column, auth middleware check, disable/enable endpoints
with audit logging, and frontend toggle with confirmation dialog.

* refactor: auto-discover migrations from embedded filesystem

Replace hardcoded migration lists with fs.ReadDir on the embedded FS
directories. New migrations are now picked up automatically by filename
sort order — no need to manually register them in store.go.

* fix: block disabled users at login and capture IDs before async calls

Reject disabled accounts in the login handler before session creation,
not just in RequireAuth middleware (which exempts auth routes). Also
capture selectedId into a local const in all async admin panel functions
to prevent stale updates if the selection changes during a request.

* fix: enforce disabled check in OAuth and password reset flows, always invalidate sessions

Block disabled users in all session-minting paths (OAuth login, password
reset) not just password login. Also remove early return for
already-disabled users in the disable endpoint so session invalidation
always runs, handling retry after partial failure.
2026-04-13 21:56:40 -04:00
xarmian 79d7d26a00 feat: add admin password reset for other users (#103)
* feat: add admin password reset for other users

New POST /api/v1/admin/users/{id}/reset-password endpoint. When email is
configured, sends a password reset link. Otherwise generates a temporary
password and invalidates existing sessions. Includes audit logging via
new password_reset_by_admin action and frontend UI with confirmation.

* fix: treat session revocation and email send as hard failures

Make session invalidation failure abort the reset instead of silently
continuing, and send the reset email synchronously so delivery failures
are surfaced to the admin caller.
2026-04-13 20:59:35 -04:00
xarmian f276745478 fix: sidebar collection counts ignore terminal status settings (#100)
When all items in a collection had terminal statuses (e.g. all bugs
"fixed"), the sidebar showed the total item count instead of 0.

Root cause: ActiveItemCount used `json:"omitempty"`, so a zero value
was omitted from the API response. The sidebar fallback logic then
displayed item_count (total) instead. Additionally, ListCollections
used a hardcoded global terminal status list instead of respecting
each collection's configured terminal_options.

- Remove omitempty from ItemCount/ActiveItemCount so 0 serializes
- Compute active counts per-collection using schema terminal_options
- Show count of 0 in sidebar when collection has items but all are done
2026-04-13 16:47:44 -04:00
xarmian ac24fb742c fix: breadcrumbs show parent item path for child items (#94)
When viewing a child item (e.g. TASK-101 under PLAN-10), the breadcrumb
now shows "Home / Plans / PLAN-10 / TASK-101" instead of the flat
"Home / Tasks / TASK-101".

- Add parent_slug and parent_collection_slug fields to Go Item model
- Populate them in both single-item and bulk enrichment paths
- Add corresponding TypeScript types
- Update breadcrumb nav to show parent collection and parent item
  when the item has a parent, falling back to the item's own collection

Fixes BUG-516.
2026-04-12 23:53:58 -04:00
xarmian 92580905bb feat: cloud hardening and security follow-ups (PLAN-503)
Address 11 issues identified during the PLAN-427 security review:

Critical/High:
- Stripe customer-to-user mapping with indexed lookup (TASK-505)
- OAuth provider linking with explicit consent model (TASK-504)
- CSRF tokens on admin console mutations (TASK-506)
- Rate limiting on cloud admin and OAuth endpoints (TASK-507)

Medium:
- __Host- cookie prefix for subdomain protection (TASK-510)
- Billing portal verifies customer ownership server-side (TASK-515)
- Transactional account deletion with rollback (TASK-509)
- Streaming data export with 60s timeout (TASK-508)
- Migration registration for new columns (TASK-514)

Low:
- Billing page fetches actual plan limits from API (TASK-511)
- Admin user search/filter pushed into SQL with pagination (TASK-512)
2026-04-13 01:03:12 +00:00
xarmian d0518216c5 feat: add cloud infrastructure for hosted Pad (PLAN-427)
Add the foundation for running Pad as a hosted service at app.getpad.dev.
Same binary in cloud mode with a thin sidecar for OAuth and Stripe.

Cloud mode (PAD_CLOUD=true):
- PAD_CLOUD flag with cloud secret for sidecar communication
- Account-level billing: plan field on users, CheckLimit enforcement
- Free/Pro tiers with configurable limits stored in platform_settings
- Three-tier limit resolution: user overrides → DB defaults → hardcoded fallback
- Plan enforcement on workspace, item, member, webhook, and token creation

Authentication & security:
- OAuth login endpoint (POST /api/v1/auth/oauth-login) with cloud secret gate
- Verified email requirement for OAuth, 2FA bypass protection
- Cloud secret rotation support (comma-separated keys)
- TOTP secret encryption at rest (AES-256-GCM via PAD_ENCRYPTION_KEY)
- Rate limiting on OAuth login endpoint
- Bootstrap disabled in cloud mode
- Password max length enforcement (128 chars)
- Config file written with 0600 permissions

Admin & billing:
- Admin user management API (list, detail, update plan/overrides)
- Configurable plan limits API (GET/PATCH /api/v1/admin/limits)
- Platform stats endpoint
- Admin plan endpoint for sidecar to set user plans
- GDPR: account deletion and data export endpoints

Console UI (cloud mode only):
- /console — workspace list with owned/shared sections
- /console/new — create workspace wizard with slug preview
- /console/settings — profile, password, API tokens
- /console/billing — plan status, upgrade/manage links
- /console/admin — user management, plan overrides, limits editor
- OAuth buttons (GitHub/Google) on login page in cloud mode

Auto-create default workspace on signup in cloud mode.
Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users.
2026-04-12 17:43:40 +00:00
xarmian 94d35509a4 feat: share links with hardened security, anonymous access, and analytics (#88)
* feat: share links with hashed tokens and /s/{token} route

Add share_links and share_link_views tables with CRUD API and
anonymous resolution route (TASK-421).

Data model:
- share_links: token_hash (SHA-256), target_type/id, permission,
  password_hash, expires_at, max_views, require_auth, view tracking
- share_link_views: per-view records with fingerprint/user tracking

Token security:
- 192-bit entropy (crypto/rand), URL-safe base64 encoding
- SHA-256 hashed at rest, raw token returned only once on creation
- Generic 404 for invalid tokens (no info leakage)
- /api/v1/s/ exempt from auth middleware for anonymous access

API endpoints:
- POST /items/{slug}/share-links — create item share link
- POST /collections/{coll}/share-links — create collection share link
- GET /items/{slug}/share-links — list share links for item
- GET /collections/{coll}/share-links — list for collection
- DELETE /share-links/{id} — revoke share link
- GET /s/{token} — resolve share link, return shared content

D8: Anonymous users are ALWAYS read-only. View count and unique
viewers tracked on each resolution.

* feat: anonymous share page + share link management UI

Add minimal-chrome share link viewer page and share link CRUD in
the share dialog (TASK-422 + TASK-425).

Share page (/s/{token}):
- New SvelteKit route at /s/[token] for anonymous viewing
- Renders item (title, fields, markdown content) or collection
  (name, item list) with no app chrome (no sidebar/topbar)
- Handles require_auth links with "Sign in to view" prompt
- Root layout bypasses auth checks for /s/ routes
- "Powered by Pad" footer

Share dialog updates:
- "Share links" section below existing grants
- Create/list/revoke share links for items and collections
- Copy-to-clipboard for share URLs
- Newly created links highlighted with "only shown once" notice
- View count and auth-required badges

API client:
- ShareLink type added
- shareLinks.* methods for CRUD
- share.get(token) for anonymous resolution

* feat: share link constraints + view analytics

Add password protection, expiry, max views, and view history
endpoints for share links (TASK-423 + TASK-424).

Constraints (TASK-423):
- CreateShareLink accepts ShareLinkOptions: password, expires_at,
  max_views, require_auth, restrict_to_email
- Password hashed with bcrypt, verified on /s/{token} resolution
- Password-protected links return {require_password: true} prompt
- Expiry and max_views already validated by ValidateShareLink

Analytics (TASK-424):
- GET /share-links/{id}/views returns view history with fingerprint,
  user ID, and timestamp
- Response includes total_views, unique_viewers, last_viewed_at
- View history stored per-view in share_link_views table

* fix: harden share links — XSS, access control, data leakage, and UX gaps

- Sanitize rendered markdown with DOMPurify before {@html} injection (XSS)
- Force require_auth=true when restrict_to_email is set (access bypass)
- Reject malformed non-empty JSON bodies with 400 instead of failing open
- Return public DTOs on share endpoints to prevent leaking internal IDs,
  creator info, assignees, schemas, and other sensitive fields
- Enforce max_views atomically via conditional UPDATE to prevent races
- Fix collection share rendering: read items from top-level response key
  and map ref/status fields correctly
- Add password prompt UI and X-Share-Password header support so
  password-protected links can actually be unlocked by the frontend

* fix: follow-up hardening for share links

- Sanitize catch fallback in rendered markdown (XSS edge case if marked throws)
- Remove query-string password fallback; accept only X-Share-Password header
  to avoid leaking passwords in logs, browser history, and referrers
- Return 500 on ListItems DB failure instead of swallowing as empty collection
- Normalize restrict_to_email with ToLower/TrimSpace on create and compare
- Fix malformed JSON check for chunked bodies (ContentLength == -1)
  by checking for io.EOF instead of ContentLength > 0
- Remove internal share_link.id from public DTO responses
- Use clientIP(r) helper for consistent fingerprinting instead of raw
  X-Forwarded-For which is spoofable and includes port in RemoteAddr
- Distinguish DB errors from not-found in share link delete handler

* fix: final hardening pass for share links

- Move auth/email gate before password check to prevent unauthenticated
  callers from probing passwords and burning bcrypt CPU
- Wrap view recording (counter increment, unique-viewer accounting, view
  insert) in a single transaction so a failed insert rolls back the
  consumed view count instead of silently losing it
- Add X-Share-Password to CORS AllowedHeaders so cross-origin
  deployments can send the custom header without preflight rejection
- Validate expires_at (RFC3339) and max_views (> 0) on share link
  creation; return 400 for invalid constraints instead of creating
  immediately-unusable links
- Cap view-history endpoint limit to 1000 to prevent unbounded queries
2026-04-11 16:40:18 -04:00
xarmian c6d19837c8 feat: collection & item grants, guest access, share dialog (PLAN-407 Phase 3) (#87)
* feat: collection and item grants tables + permission resolution

Add grant tables, CRUD operations, and permission resolution for
guest access and member overrides (TASK-417).

Data model:
- collection_grants table (id, collection_id, workspace_id, user_id,
  permission, granted_by) with CASCADE on collection/user delete
- item_grants table (same structure, references items)
- Indexes for user/collection/item lookups

Store methods:
- Create/Get/List/Delete for both collection and item grants
- ListUserGrants: all grants for a user across a workspace
- RevokeAllUserGrants: bulk delete for member removal
- ResolveUserPermission: full 5-step resolution per DOC-406
  (owner → item grant → collection grant → membership → deny)

API endpoints:
- GET/POST/DELETE /collections/{coll}/grants — collection grant CRUD
- GET/POST/DELETE /items/{slug}/grants — item grant CRUD
- GET /users/{userID}/grants — all grants for a user in workspace

All grant endpoints are owner-only for creation/deletion.

* feat: grant revocation + member removal with grant choice

Update member removal to support D4: owner chooses whether to revoke
all grants when removing a member (TASK-489).

- DELETE /members/{userID}?revoke_grants=true → remove membership AND
  all collection/item grants (full removal)
- DELETE /members/{userID} (or revoke_grants=false) → remove membership
  but keep grants (user becomes a guest with existing access)
- Audit log records whether grants were revoked
- CASCADE DELETE on collection/item deletion already handles cleanup
  (via ON DELETE CASCADE in the grants migration)

* feat: share dialog UI for items and collections + grant types

Add a share dialog component for managing grants on items and
collections, plus TypeScript types and API client methods (TASK-419).

Frontend:
- ShareDialog.svelte: reusable modal for listing/creating/revoking
  grants, with email input, permission select, and revoke buttons
- Item detail page: "Share" button in meta-actions (owner-only)
- Collection page: "Share" button in header actions (owner-only)

TypeScript:
- CollectionGrant and ItemGrant types added
- API client: grants.listCollectionGrants, createCollectionGrant,
  deleteCollectionGrant, listItemGrants, createItemGrant,
  deleteItemGrant, listUserGrants

Guest home screen (TASK-418) deferred — requires layout-level guest
detection which will be implemented when guest routing is built.

* feat: guest access — grants-based workspace access for non-members

Allow authenticated users with grants (but no workspace membership)
to access workspaces as guests (TASK-418).

Backend:
- UserHasGrantsInWorkspace: checks if user has any collection/item
  grants in a workspace
- GuestVisibleCollectionIDs: returns collections visible to a guest
  via collection grants + collections containing granted items
- RequireWorkspaceAccess: after member-nil check, falls through to
  grant check; sets role to "guest" if grants exist
- VisibleCollectionIDs: non-members now checked for guest grants
  instead of returning empty
- GetUserWorkspaces: includes guest workspaces (is_guest flag)
- GetWorkspacesBySlugForUser: JOINs on grants tables so workspaces
  resolve for guests
- roleLevel: "guest" = 0 (below viewer, blocks role-gated actions)

Frontend:
- Workspace.is_guest field in TypeScript type
- Sidebar: hides Dashboard, Roles, Activity, Settings, and "New
  collection" button for guests; shows "Shared with you" header

* feat: wiki-link rendering with locked icon for hidden items

Update wiki-link rendering to show a 🔒 locked icon when the linked
item is in a collection the user can't see (TASK-420).

- renderMarkdown accepts optional visibleCollectionSlugs parameter
- Items in hidden collections render as "🔒 Title" with tooltip
- Unresolved links still render as broken (no change)
- Username param added to renderMarkdown for correct URL construction
- TimelineCommentCard and CommentThread accept username prop

* fix: harden grant security — 9 findings from Codex review

- Item grants no longer leak collection-wide read access; guests with
  item-level grants see only their granted items, not the full collection
  (GuestVisibleResources two-level filter + ItemIDs in ListItems SQL).
- Edit grants are now enforced: mutating handlers (create/update/delete
  items, comments, reactions, links, versions) resolve grant-based
  permissions for guests via requireEditPermission + ResolveUserPermission.
- Grant list endpoints restricted to owners (collection/item grants) or
  owner-or-self (user grants) to prevent metadata/email enumeration.
- Guests blocked from listing workspace members; invitation details
  restricted to owners only.
- Grant deletion scoped to workspace_id to prevent cross-workspace
  deletion by guessing grant IDs.
- Member removal now revokes grants by default (opt-out with
  ?revoke_grants=false) and propagates revocation errors instead of
  silently discarding them.
- Guest workspace listing properly propagates DB errors instead of
  swallowing them.
- PostgreSQL subquery alias added to UserHasGrantsInWorkspace to fix
  silent guest-access failures on Postgres deployments.

* fix: harden item-level grant isolation — 7 findings from Codex re-review

- /changes endpoint now filters by item-level grants so guests with one
  item grant no longer receive updates for every item in that collection.
- Search results filtered by item-level grants (new ItemIDs field in
  SearchParams) so guests can't discover other items via search.
- Relationship/summary endpoints (item links, children, progress,
  activity, dashboard) all apply item-level visibility checks via
  isItemVisibleToGuest(), preventing metadata leakage through related
  item titles, statuses, and counts.
- Grants now work as member overrides: a viewer with an edit grant can
  edit the granted item (requireEditPermission falls back to
  ResolveUserPermission for members below editor role).
- handleMoveItem now requires edit permission on the target collection,
  not just visibility, preventing guests from moving items into
  view-only collections.
- Member removal + grant revocation is now atomic via
  RemoveWorkspaceMemberAndRevokeGrants() which wraps both operations
  in a single database transaction.
- Guest-access DB errors in middleware now return 500 with slog.Error
  instead of being silently collapsed into a 403 forbidden response.

* fix: close remaining grant isolation gaps — 10 findings from Codex round 3

- Workspace token endpoints (create/list/delete) now require owner role,
  preventing guests from enumerating or revoking API tokens.
- Legacy document endpoints (list, get, context, bulk-read, backlinks,
  links) now require at least viewer role, blocking guests entirely
  since documents are outside the grants model.
- Global search no longer relies on workspaceRole() (which is unset
  outside RequireWorkspaceAccess); detects guests via IsWorkspaceMember
  and applies item-level filtering. Multi-workspace search now uses
  GuestVisibleResources for guest workspaces.
- SSE event filtering now checks item IDs for guests with item-level
  grants, not just collection slugs, preventing live event leaks.
- Role board passes ItemIDs through RoleBoardParams so guests only
  see items they have grants on, not the entire collection.
- VisibleCollectionIDs for members with "specific" collection access
  now merges direct grants (collection + item grants), so grant
  overrides work for restricted members.
- Plans-progress endpoint filters plan items and children by item-level
  grants for guests, preventing one plan grant from exposing all plans.
- Webhook listing now requires owner role since URLs may contain secrets.
- Agent role item counts use item-level filtering for guests.
- Link deletion checks item-level visibility on both endpoints, not
  just collection-level.

* fix: close member grant escalation and remaining edge cases — round 4

- Item grants for restricted members no longer escalate to collection-
  wide visibility. VisibleCollectionIDs now merges only direct collection
  grants (not item-derived collections) into member access. Item-level
  filtering (guestResourceFilter, isItemVisibleToGuest, requireItemVisible)
  now applies to both guests AND restricted members with item grants,
  closing the gap where a member with specific collection access plus
  one item grant could see/edit all items in that collection.
- Guests blocked from workspace-level activity feed (/activity) which
  exposed audit events (member invites, role changes) with operational
  metadata. Requires at least viewer role.
- Global search no longer returns zero results for item-only guests.
  Store.Search early-return now checks both CollectionIDs and ItemIDs
  are empty before short-circuiting, so item-level grants work in
  global (multi-workspace) search.
- UserHasGrantsInWorkspace now excludes item grants on soft-deleted
  items, preventing phantom guest access to a workspace shell with
  no visible content when the only granted item is archived.

* fix: prevent grant filter from overriding member access, close SSE/dashboard/collection leaks — round 5

- guestResourceFilter now returns nil/nil for members with "all"
  collection access, preventing item grants from accidentally replacing
  their full visibility. Only guests and members with "specific"
  collection access get item-level filtering applied. This fixes a
  regression where a normal member receiving one item grant would lose
  access to all other items.
- requireItemVisible uses guestResourceFilter (with the same scoping)
  instead of raw GuestVisibleResources, so the member-access check is
  consistent throughout all code paths.
- SSE event filtering now denies collection-less events (workspace
  updates, legacy document events) for guests, preventing metadata
  leakage through realtime event payloads.
- Dashboard recent activity filters out workspace-level entries (no
  DocumentID) for guests, preventing audit metadata leakage.
- All grant visibility queries (UserHasGrantsInWorkspace,
  GuestVisibleCollectionIDs, GuestVisibleResources) now join the
  collections table and require deleted_at IS NULL, so grants on
  soft-deleted collections no longer provide phantom access.

* fix: make item grants additive for restricted members, close write/search/SSE gaps — round 6

- guestResourceFilter now merges member_collection_access + system
  collections + collection grants into fullCollIDs for restricted members,
  making item grants additive to existing access. Previously, item grants
  replaced the member's normal collections, causing members with one item
  grant to lose all their other collection visibility.
- Added ListSystemCollectionIDs store method for system collection lookup.
- Search (both global and workspace-scoped) now applies item-level
  filtering for restricted members with item grants, not just guests.
  Previously VisibleCollectionIDs included item-granted collections as
  full-access, leaking all items in those collections via search.
- SSE event filtering now builds item-level filters for restricted
  members with item grants (previously only for non-members/guests),
  and merges member collections into the full-access set.
- Role board reorder now uses requireItemVisible + requireEditPermission
  per item instead of collection-only visibility check, preventing
  restricted editors from reordering items in item-granted collections.
- View create/update/delete now check requireEditPermission on the
  collection (via requireViewEditable), not just collection visibility.
- GetUserWorkspaces guest query now joins collections/items tables to
  exclude grants on soft-deleted resources, matching the behavior of
  UserHasGrantsInWorkspace.

* fix: block guests from legacy doc versions/activity, fix ListItems early return, SSE fail-closed — round 7

- Legacy document version handlers (handleListVersions, handleGetVersion)
  and document activity handler (handleListDocumentActivity) now require
  at least viewer role, blocking guests from reading version history and
  activity for unrelated legacy documents.
- ListItems early return now checks both CollectionIDs and ItemIDs are
  empty before short-circuiting, matching the fix already applied to
  Search. This fixes item-only guests seeing zero results from /items,
  dashboard, role board, and agent-role counts.
- SSE item-grant filtering now fails closed on GuestVisibleResources
  errors: installs empty item/collection filter sets instead of falling
  through with nil (which would pass all events through).
- Role board reorder removed top-level requireMinRole("editor") so the
  per-item grant-aware requireEditPermission checks can run for guests
  and viewers with edit grants, consistent with other mutating handlers.
2026-04-11 14:46:24 -04:00
xarmian 0fb8042ad2 feat: permission-filtered aggregates for all data endpoints
Wire collection visibility filtering into all data endpoints so
members with "specific" access only see items in their visible
collections (TASK-414).

Core:
- ItemListParams.CollectionIDs: SQL-level IN() filter on item queries
- SearchParams.CollectionIDs: same for FTS search queries
- visibleCollectionIDs() server helper computes once per request
- isCollectionVisible() for single-item gating checks

Filtered endpoints:
- ListItems / ListCollectionItems: SQL-level collection ID filter
- ListCollections: post-filter by visible set
- Search: collection ID filter on both ref-lookup and FTS branches
- Dashboard: all ListItems calls scoped, activity post-filtered
- Activity feed: post-filtered by collection slug visibility
- Collection items: gate check before listing

Admins and "all access" members see everything (nil = no filter).
The filtering is a no-op until a member's collection_access is set
to "specific" via the management UI (TASK-416).
2026-04-11 01:20:05 +00:00
xarmian d74431fbb3 feat: collection-level visibility + system collections
Add per-member collection visibility controls and mark conventions/
playbooks as system collections (TASK-413 + TASK-415).

Data model:
- workspace_members: new collection_access column ('all' or 'specific')
- New member_collection_access table (workspace_id, user_id, collection_id)
- collections: new is_system column, set for conventions and playbooks

Store methods:
- VisibleCollectionIDs(workspaceID, userID) — returns nil for "all"
  access, or specific IDs (including system collections) for "specific"
- SetMemberCollectionAccess/GetMemberCollectionAccess for CRUD
- All collection queries include is_system in SELECT/scan
- Export/import handles is_system field

Default collection definitions:
- conventionsCollection and playbooksCollection set IsSystem=true
- New workspaces get system flag on seed

D7: default collection_access is "all" — absence of restrictions
means full access. System collections always visible to members.
2026-04-11 01:02:06 +00:00
xarmian 38e6b4e5b1 feat: rewrite web UI routing to /{username}/{workspace}/... pattern
Restructure all workspace-scoped web URLs to include the owner's
username as a prefix (TASK-411).

Route structure:
- Moved web/src/routes/[workspace]/ → [username]/[workspace]/
- All workspace pages extract both username and workspace from URL
- Auth routes (/login, /register, /join, etc.) unchanged

Backend:
- Workspace model adds OwnerUsername field (populated by JOIN)
- All workspace queries JOIN users table for owner_username
- TypeScript Workspace type updated with owner_username

Frontend (24 files updated):
- All route pages: added username derived, updated URL constructions
- Sidebar, TopBar, WorkspaceSwitcher: use owner_username for links
- ItemCard, TableView, ChildItems, NestedChildren: username in links
- CommandPalette, OnboardingChecklist, CreateWorkspaceModal: updated
- Root page redirect includes owner_username
- Wiki-link markdown utility accepts username parameter

What did NOT change:
- API client (client.ts) — still uses workspace slug for API calls
- Go API routes — unchanged
- CLI — unchanged
2026-04-10 23:47:25 +00:00
xarmian 520a7ca27b feat: add owner_id to workspaces with backfill
Add owner_id column to workspaces table and backfill existing
workspaces from membership data (TASK-410 + TASK-480).

- SQLite migration 030 and Postgres migration 010 add owner_id column
  with indexes on (owner_id) and (owner_id, slug)
- Workspace, WorkspaceCreate models updated with OwnerID field
- All workspace SELECT queries include owner_id
- CreateWorkspace INSERT includes owner_id
- handleCreateWorkspace sets owner_id from authenticated user
- backfillWorkspaceOwners extended: sets owner_id using D3 logic
  (earliest owner member → earliest member → first admin)
- TypeScript Workspace type updated

Global UNIQUE(slug) constraint preserved for now; will be replaced
with UNIQUE(owner_id, slug) in TASK-412 when auth-scoped resolution
needs it.
2026-04-10 22:56:32 +00:00
xarmian f80876a52e feat: add username column to users table
Add username field to the user data model as the foundation for
the multi-user permissions system (PLAN-407, TASK-408).

- SQLite migration 029 and Postgres migration 009 add username column
  with partial unique index (WHERE username != '')
- User, UserCreate, UserUpdate Go structs updated
- Store: CreateUser, UpdateUser, scanUser, userColumns updated
- New GetUserByUsername store method (case-insensitive lookup)
- All auth handler JSON payloads include username field
- WorkspaceMember struct and ListWorkspaceMembers query include username
- TypeScript User type and API client inline types updated

Column is empty string by default; TASK-482 will backfill existing
users and TASK-409 will add validation/registration flow support.
2026-04-10 22:40:07 +00:00
xarmian 1d26c2b542 feat: add workspace top bar with drag-to-reorder (#80)
* feat: add workspace top bar with drag-to-reorder

Replace the sidebar WorkspaceSwitcher dropdown with a dedicated top bar
that provides fast workspace switching and a user menu.

Desktop:
- Horizontal bar above sidebar + content with workspace icons (colored
  first-letter circles) and names as real <a> links
- Drag-and-drop reorder via svelte-dnd-action
- User avatar on right with dropdown (settings, theme toggle, sign out)
- "+" button to create new workspaces

Mobile:
- Full-width fixed bar at top when sidebar opens (above sidebar/backdrop)
- Tap workspace to navigate and close sidebar
- Reorder button opens full-screen vertical list with drag handles
- Sidebar starts below the top bar with adjusted positioning

Backend:
- Migration 028: add sort_order to workspace_members (per-user ordering)
- GET /workspaces now returns workspaces in user's sort order
- PUT /workspaces/reorder endpoint for persisting order

Sidebar simplified:
- Removed WorkspaceSwitcher component, user section, theme toggle
- Theme initialization moved to root layout
- Cleaner footer with search, settings, and notification bell

Implements IDEA-129, relates to IDEA-126.

* fix: address codex review findings (P1+P2)

- Remove unsupported `direction` option from svelte-dnd-action dndzone
- Add Postgres migration 008 for workspace_members.sort_order
- Handle sql.ErrNoRows gracefully in reorder endpoint for admins who
  aren't members of all workspaces
- Restore mobile sign-out: add user name + logout button to sidebar
  footer on mobile (was only in desktop TopBar user menu)
2026-04-10 01:22:49 -04:00
xarmian 0e32645bb5 feat: add TOTP two-factor authentication
Backend support for optional TOTP-based 2FA on user accounts:

- POST /auth/2fa/setup — generate TOTP secret, return QR code URI
- POST /auth/2fa/verify — verify code and enable 2FA with recovery codes
- POST /auth/2fa/disable — disable 2FA (requires password confirmation)
- POST /auth/2fa/login-verify — complete login with TOTP or recovery code
- Login returns {requires_2fa: true, user_id} when 2FA is enabled,
  requiring a second step via /auth/2fa/login-verify
- 8 recovery codes generated on setup for account recovery
- User model extended with totp_secret, totp_enabled, recovery_codes
- Refactored user queries with shared scanUser/userColumns for DRYness

Implements TASK-169 under PLAN-15 (Pad Cloud: Hardening).
2026-04-08 20:30:39 +00:00