Commit Graph

410 Commits

Author SHA1 Message Date
xarmian de4d28d576 feat(attachments): AttachmentStore interface + FSStore (TASK-870) (#287)
* feat(attachments): AttachmentStore interface + FSStore (TASK-870)

Introduces the storage backend abstraction described in DOC-865 and
ships its first concrete implementation. No call sites yet —
TASK-871 (upload API) wires it in.

internal/attachments/store.go
  AttachmentStore interface (Put/Get/Stat/Delete) and ErrNotFound
  sentinel. Put is documented as idempotent — concurrent Puts of the
  same hash converge — and required to verify that the streamed bytes
  actually hash to the supplied value.

internal/attachments/registry.go
  Registry routes "<prefix>:<rest>" keys to the store registered for
  that prefix (Phase 1 = "fs"; Phase 2 will register "s3" alongside).
  Convenience Get/Stat/Delete helpers resolve + forward in one call so
  callers don't have to spell out the two-step pattern everywhere.
  Register panics if the prefix contains ':' since that would make the
  store unreachable.

internal/attachments/fs_store.go
  FSStore writes to <baseDir>/<aa>/<bb>/<full-hash> with the first 4
  hex chars sharding the directory tree two levels deep. Atomic writes:
  stream + hash to a randomized .tmp in the destination dir, fsync,
  then intra-directory rename. The streaming sha256 is verified against
  the supplied hash before the rename, so a mismatch never leaves a
  visible file. Idempotent fast path: if the canonical file already
  exists Put short-circuits (and drains the reader so callers don't get
  a stuck stream). Get returns wrapped ErrNotFound on missing keys;
  Delete on a missing key is a no-op (matches what the orphan GC needs).

Tests cover put/get/stat/delete, hash mismatch, invalid hash format,
idempotency, 16-goroutine concurrent Put of the same hash converging
to one on-disk file with no orphan tmp files, registry routing,
forward-error semantics, and the prefix-with-colon panic.

Parent: PLAN-866.

* fix(attachments): validate hash on every FSStore key + verify on fast path per Codex review (round 1)

Round 1 raised two issues — both real, both fixed.

1. Path traversal in Get/Stat/Delete. extractHash only checked that the
   key began with "fs:" and the suffix was non-empty before passing it
   to pathFor(), which used the suffix as a path component. A key like
   "fs:../../etc/passwd" would escape baseDir for reads/stats/deletes.
   Fix: extractHash now requires the canonical 64-char lowercase-hex
   sha256 form via validHash. Same gate that Put already used; now it
   covers every public method.

2. Idempotent Put fast path skipped hash verification. If the canonical
   target file already existed, Put returned the key without checking
   that the supplied reader's bytes hashed to the supplied hash —
   violating the AttachmentStore.Put contract that implementations MUST
   verify on every call. A buggy upload path could associate the wrong
   bytes with an existing hash and silently succeed. Fix: stream r
   through a hasher when the target exists (no disk I/O), compare
   against the supplied hash, and reject on mismatch.

Also dropped the dead "_short" branch in pathFor — every caller now
goes through validHash.

Tests added:
- TestFSStore_GetStatDeleteRejectBadKeys covers empty/wrong-prefix/empty-
  hash/non-hex/wrong-length/path-traversal/path-separator/uppercase keys
  across all three read methods.
- TestFSStore_PutFastPathStillVerifiesHash confirms the contract holds
  on the fast path: a second Put that lies about the hash is rejected
  with no corruption of the existing file.
2026-04-29 11:44:58 -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 2f58193f22 chore(web/connect-modal): point footer + install links at getpad.dev/docs (#285)
Last piece of PLAN-859. The ConnectWorkspaceModal's three footer/install
links were placeholders pointing at GitHub README anchors while
TASK-863's docs page didn't exist yet. That page is now live at
getpad.dev/docs/connect-workspace (pad-web#30 / e472586), so swap the
three URLs to the real docs:

- "Other install options →" → https://getpad.dev/docs#installation
  (broader install matrix: Homebrew + Binary + Docker + Source)
- "Documentation"          → https://getpad.dev/docs/connect-workspace
- "Troubleshooting"        → https://getpad.dev/docs/connect-workspace#troubleshooting

Updated the in-source comment to reflect that the URLs are now the
canonical ones, not placeholders.

This closes out PLAN-859 (web-first onboarding on-ramp): a user who
creates a workspace in the web UI now has a complete in-app + docs
path to connecting that workspace to their local project.
2026-04-29 10:26:15 -04:00
xarmian e5eae5e94e feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862) (#284)
* feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862)

Final web piece of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
A slim banner now nudges users to connect their workspace to the CLI on
every workspace page, until they either dismiss it or actually do it.

Server:

- New store method WorkspaceHasCLISource(workspaceID) — backed by
  EXISTS(... WHERE source='cli' AND deleted_at IS NULL), so it's a
  cheap O(1) check that short-circuits on the first match.
- Dashboard payload (GET /workspaces/{ws}/dashboard) gains
  HasCLISource bool (json: has_cli_source).
- Unit tests cover empty workspace, web/skill items don't trip it,
  one cli item flips it on, soft-delete flips it back off, and
  cross-workspace isolation.

Web:

- New <ConnectBanner> Svelte 5 component
  (web/src/lib/components/ConnectBanner.svelte). Self-contained:
  reads dismissed state from localStorage, fetches has_cli_source
  itself, mounts <ConnectWorkspaceModal> internally. Two split
  $effect blocks per CONVE-606 — one for the localStorage sync, one
  for the dashboard fetch — so a workspace change doesn't entangle
  the two reactive lifecycles.
- Banner is hidden while loading (hasCliSource === null) to avoid a
  flash-then-auto-hide on workspaces that already have CLI items.
- Storage key pad-cli-banner-dismissed-${wsSlug} matches the existing
  onboarding-dismissed pattern. Per-browser only; TODO comment in
  source about backing it with a workspace_user_state row if cross-
  device persistence is wanted later.
- Mounted in web/src/routes/[username]/[workspace]/+layout.svelte
  above {@render children()} so it appears on every workspace page
  (dashboard, collection lists, item detail, search, activity, etc.)
  and NOT on console/auth pages (the layout is workspace-scoped).
- DashboardResponse type in web/src/lib/types/index.ts gains
  has_cli_source: boolean.

Smoke-tested against the running server: the field is live in the
dashboard payload and reflects reality (this workspace returns
has_cli_source: true since it has many CLI-sourced items, so the
banner is correctly auto-hidden here).

Test plan:
- go build ./... && go test ./... — all green (incl. new
  TestWorkspaceHasCLISource with 5 sub-cases).
- cd web && npm run build — clean.
- make install — clean, server restarted.
- Svelte MCP autofixer ran on ConnectBanner.svelte — no issues.

Parent: PLAN-859. Driving idea: IDEA-750.

* fix(web/connect-banner): stale-response guard + refetch on modal close (Codex round 1)

Two findings from Codex review on PR #284:

1. Stale-response race: rapid workspace switches could let a slow
   dashboard fetch from workspace A overwrite hasCliSource for
   workspace B after the user navigated. Capture the requested slug
   at fetch time, ignore the response if wsSlug has changed since.

2. Auto-hide didn't work in-session: if a user opened the banner
   modal, copied the command, ran it elsewhere, and closed the modal,
   the banner stayed visible because hasCliSource was stale. Refetch
   when the modal transitions from open → closed (the natural moment
   the user has just connected). Uses $effect.pre with a tracked
   previous value, matching the transition pattern in ShareDialog.

The 'someone ran the CLI from another terminal without ever opening
the modal' edge case is left for a follow-up — would require SSE
item-created subscription, which is heavier than this PR's scope.

* fix(server/items): persist source from auth context on create (Codex round 2)

Codex caught an architectural bug while reviewing the TASK-862 banner
work: items created via the CLI were persisting with source='web'
(the column default) instead of 'cli', because handleCreateItem decoded
ItemCreate from the body — which the CLI doesn't set Source on — and
only consulted actorFromRequest AFTER persisting (for SSE / activity
log emission). Result: TASK-862's has_cli_source dashboard signal
would never flip on for normal CLI usage, so the connect-CLI banner
would never auto-hide for users who actually wired up the CLI.

Fix: in handleCreateItem, backfill input.Source from actorFromRequest
before calling store.CreateItem, but only when the client didn't
explicitly set it (so agents marking themselves as 'skill' still
pass through unchanged).

Test: TestCreateItemSourcePersistedFromAuth covers all three branches
- bearer Authorization header → source=cli (uses bootstrap + a real
  session token in the header since the auth middleware validates
  token format and rejects fake values with 401 before the handler
  runs)
- cookie session, no Authorization → source=web
- explicit source in body wins over auth-derived (e.g. 'skill')

* fix(web/connect-banner): seq counter for same-workspace race (Codex round 3)

Round 3 caught a same-workspace race the slug guard didn't cover: an
in-flight workspace-change fetch that resolves AFTER the modal-close
refetch could overwrite the newer 'true' with the older 'false',
making the banner reappear after the user actually wired up the CLI.

Add a monotonic fetchSeq counter — captured at call time, rechecked
before applying the response. Only the LATEST request's result wins,
regardless of arrival order. The slug guard stays as a second-layer
defense for cross-workspace races.

* fix(web/connect-banner): guard banner keydown to currentTarget (Codex round 4)

Round 4 caught a keyboard-event bubble: pressing Enter or Space on
the dismiss X button also fired the banner-level keydown handler,
so the user would dismiss AND open the modal in one stroke.

Guard the parent handler with `e.target !== e.currentTarget` so it
only reacts to keydown that originated on the banner itself. Tabbing
to the dismiss button + Enter now ONLY dismisses.

* fix(store): visibility-filter has_cli_source query (Codex round 5)

Round 5 caught a P2 information leak: WorkspaceHasCLISource scanned
the entire workspace regardless of caller visibility, so a guest
with grants only on web-sourced items could still see has_cli_source
return true (revealing that CLI items exist somewhere they can't see).
That also produced wrong UX — the banner could auto-hide for guests
who couldn't actually use the CLI.

Extend the query to take optional collectionIDs/itemIDs filters
matching the dashboard's existing visibility model: an item counts
when its collection is in collectionIDs OR its id is in itemIDs
(union — guest item-level grants can expose items in otherwise-
hidden collections). Mirrors ListItems' filtering pattern incl. the
"non-nil empty CollectionIDs = no visibility = short-circuit false"
semantics.

Handler now passes dashCollIDs and dashItemIDs to match the rest of
the dashboard payload's filtering. New TestWorkspaceHasCLISourceVisibility
covers the four cases: unfiltered sees all, visible-coll-only hides
CLI items in hidden collections, item-level grant surfaces a hidden
CLI item, and empty visibility short-circuits to false.
2026-04-29 10:05:34 -04:00
xarmian a28767d323 feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) (#283)
* feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861)

Web side of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
Gives a user who created a workspace via the web UI a one-line copy-paste
to connect that workspace to their local repo, exposed in the two
zero-state surfaces where they'd look for it.

Changes:

- New `<ConnectWorkspaceModal>` Svelte 5 component
  (web/src/lib/components/ConnectWorkspaceModal.svelte). Reusable, no
  host-page coupling. Matches ShareDialog's modal pattern (overlay +
  centered modal, native, open = $bindable(), Escape closes). Props:
  serverUrl, workspaceSlug, workspaceName?. Renders Step 1 (OS-tabbed
  install — macOS/Linux/Windows/Docker, default tab from detected
  platform) and Step 2 (pad init --url ... --workspace ... snippet
  with a copy button on the full snippet). Footer links to docs +
  troubleshooting.
- New web/src/lib/utils/platform.ts — tiny dependency-free OS detection
  helper. SSR-safe (defaults to "macos" with no navigator).
- Mounted in the workspace landing page as a "Connect your local
  project" card directly under <OnboardingChecklist> in the empty-
  workspace .onboarding-wrapper. Modal itself is mounted unconditionally
  at the page root so it survives re-renders of the conditional empty
  state.
- Mounted in TopBar.svelte's user menu (both desktop and mobile
  branches): "Connect a project..." entry between Theme/Cloud-support
  links and the Sign-out divider. Modal lives outside the dropdown so
  it doesn't unmount when the dropdown closes. Both gated on
  workspaceStore.current?.slug since the modal needs a workspace to
  interpolate.

Docs URLs in the modal footer (getpad.dev/docs/install,
getpad.dev/docs/connect-local-project) are placeholders; TASK-863 in
PLAN-859 will publish those pages and we'll wire the final URLs then.

Test plan:
- go build ./... && go test ./... clean
- cd web && npm run build clean
- make install clean, server restarted
- Svelte MCP autofixer ran on all four touched files — no findings

Parent: PLAN-859. Driving idea: IDEA-750.

* fix(web/connect-modal): correct brew tap + point placeholder docs links to README (Codex round 1)

Two findings from Codex review on PR #283:

1. macOS install command was `brew install xarmian/pad/pad`, but the
   actual tap is `PerpetualSoftware/tap/pad` (per README.md and
   skills/INSTALL.md). Users would have hit a failing install.

2. Footer links pointed at `getpad.dev/docs/install` and
   `getpad.dev/docs/connect-local-project` — pages TASK-863 will
   publish but don't exist yet. Until they do, point at the GitHub
   README's #installation and #getting-started anchors so clicks at
   least land somewhere useful instead of 404.

The TASK-863 follow-up will swap these back to the dedicated docs URLs
once the pages ship.

* fix(web/connect-modal): use real install commands from README (Codex round 2)

Round 2 caught that Linux/Windows/Docker commands were fabricated:
- Linux/Windows pointed at a getpad.dev/install.sh that doesn't exist
- Docker used wrong volume mount (/root/.pad vs the image's /data) and
  didn't publish ports

All four tabs now mirror the README's Installation section exactly:
- macOS + Linux: brew install PerpetualSoftware/tap/pad
- Windows: pointer to the GitHub releases page (no first-party one-liner)
- Docker: docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad
2026-04-29 09:20:29 -04:00
xarmian a03c96f9b0 feat(cli): pad init --url X --workspace <slug> as web-first cold-start (TASK-860) (#282)
Make `pad init --url <server> --workspace <slug>` a reliable
non-interactive cold-start so the web UI can hand users a single
copy-paste command to connect a workspace they created on the web to
their local project. Keystone CLI work for the web-first onboarding
on-ramp under PLAN-859 (driven by IDEA-750).

Changes:

- `ensureWorkspace` gains a `wsSlug` parameter. When set, it ONLY
  attaches by slug — looks up the workspace via GetWorkspace, links
  the CWD if found, and surfaces a clear "not found on <server>"
  error otherwise. Critically, it never silently falls through to
  creating a new workspace named after the slug.
- Refuses to clobber a CWD that's already linked to a different
  workspace; idempotent re-run when the existing link matches.
- `pad init --url X` on a fresh machine (no config.toml on disk) now
  persists the config so subsequent commands don't need --url.
- When both a positional name and --workspace are supplied, the slug
  wins and we print a Note: line so the override is visible.
- Same wiring applied to `pad workspace init` for consistency.

Tests: 5 new unit tests in cmd/pad/init_test.go cover slug-attach,
not-found error, clobber refusal, idempotent re-run, and that the
legacy name-driven path still works. Smoke-tested end-to-end against
the local server: happy path links, missing slug errors cleanly with
no `.pad.toml` written, clobber blocked, idempotent re-run silent.
2026-04-29 08:58:58 -04:00
xarmian 86a2f3c55b fix(web/editor): copy from table puts plain text only on clipboard (TASK-858) (#281)
* fix(web/editor): copy from table puts plain text only on clipboard (TASK-858)

ProseMirror's default copy serialization for selections inside a table
included the wrapping <table>...</table> in the text/html clipboard
payload. Pasting into rich-text apps (or anywhere that prefers HTML over
plain text) reproduced the table styling when the user just wanted the
cell text.

Add a tableCopyPlugin mirroring the existing codeBlockCopyPlugin
pattern: when the selection lives entirely inside a table, write a
plain-text representation to text/plain and clear text/html. Cut also
deletes the range, same as the code-block plugin.

Behavior:
- Text selection inside a single cell: cell text on text/plain.
- CellSelection (multi-cell drag): tab between cells, newline between
  rows. Pastes correctly into Excel/Sheets/Numbers.
- Selection that spans into/out of the table: falls through to default.

Trade-off (accepted): re-pasting a multi-cell copy into our own editor
yields TSV text, not a reconstructed table. Matches Linear/Notion/Slack.

Fixes BUG-855.

* fix(web/editor): preserve parent Table plugins + selection-aware cut per Codex review (round 1)

Two findings from Codex review of PR #281:

1. Table.extend's addProseMirrorPlugins was returning only [tableCopyPlugin],
   replacing the parent extension's plugins and silently dropping
   columnResizing (negating resizable: true) and tableEditing (cell
   selection / table editing). Now spreads ...(this.parent?.() ?? []) and
   appends tableCopyPlugin.

2. Cut path used tr.delete(from, to) which is unsafe for CellSelection —
   a contiguous document range can include unrelated cells (or row
   structure) between the rectangular cell-selection's endpoints. Switched
   to tr.deleteSelection(), which routes through prosemirror-tables'
   CellSelection.replace override and clears each selected cell's content.
   Still correct for the TextSelection-inside-one-cell case (deletes the
   text range as before).

The codeBlockCopyPlugin's tr.delete(from, to) is intentionally left alone —
that path validates the selection sits inside a single code_block, where
from/to is a flat text range and no structural risk exists.
2026-04-29 00:57:14 -04:00
xarmian cc4f1c16b6 feat(web): let users switch collection inside the Quick Add modal (TASK-857) (#280)
The Quick Add modal previously locked users into the collection they
launched it from. Replace the static `{icon} New {Singular}` header with
a clickable pill that opens a small popover listing every regular
collection in the workspace; selecting one swaps the target collection
without losing the typed title.

Behavior preserved:
- Default collection still comes from the launch entry point (sidebar
  `+`, dashboard buttons, Cmd-N).
- Picker excludes agent collections (conventions, playbooks) via the
  existing `regularCollections` filter.
- If only one regular collection exists, the pill renders as a non-
  interactive label (no caret, no popover).
- `submitQuickAdd` already re-derives default fields and content
  template from the current `quickAddCollection`, so swapping mid-flow
  Just Works.

Keyboard:
- Enter / Space / ArrowDown on the pill opens the picker.
- ArrowUp/Down/Home/End navigate; Enter selects; Esc closes the picker
  only (textarea Esc still closes the modal).

The outside-click handler is kept as its own `$effect` per CONVE-606
(don't combine reactive triggers in a single effect).

Implements IDEA-749.
2026-04-29 00:06:49 -04:00
xarmian eaae76f667 feat(auth): link to /console from CLI auth success state (TASK-856) (#279)
After approving a CLI session at /auth/cli/{code}, the success state
previously dead-ended with "you can close this tab" and no link out.
Adds a primary "Go to your workspaces" CTA linking to /console — the
same destination that / redirects to and that pad-cloud's OAuth flow
lands users on post-login. Universal across self-hosted, Docker, Remote,
and Pad Cloud (which proxies /auth/cli/ to the upstream pad backend
via nginx, no pad-cloud change needed).

The existing "you can close this tab" message stays — some users
(CI runs, headless approvals, teammate's laptop) genuinely just want
to close the tab.

Source: IDEA-848.
Parent: PLAN-833.
2026-04-28 23:33:01 -04:00
xarmian 2b752ba194 feat(release): sign + notarize macOS binaries (IDEA-830) (#278)
* feat(release): sign + notarize macOS binaries (IDEA-830)

Adds Developer ID code-signing and Apple notarization to the release
pipeline so users installing via `brew install perpetualsoftware/tap/pad`
or downloading binaries directly no longer hit Gatekeeper's "cannot
verify the developer" warning.

Uses GoReleaser v2's built-in `notarize:` block (Anchore/Quill backend),
which signs and notarizes in-process from the existing ubuntu-latest
runner — no rcodesign install, no macOS runner needed.

Both the .p12 cert and the .p8 App Store Connect notary key are stored
as base64-encoded repo secrets; Quill decodes them in-process. The
notarize block is gated on `MACOS_CERT_P12` being set, so snapshot
builds, fork PRs, and any context where the cert isn't available skip
cleanly without failing.

Verification plan: tag v0.0.1-rc.3, confirm Action goes green end-to-end,
then on a clean Mac run `brew install perpetualsoftware/tap/pad` and
verify `codesign -dv $(which pad)` shows the Developer ID signature and
`spctl -a -t exec -vv $(which pad)` reports "accepted" with the notary
ticket stapled.

* harden(release): isolate npm build from macOS secrets + pin goreleaser

Addresses Codex review findings on PR #278:

MEDIUM — Apple signing secrets were exposed to the npm web build.
The previous `before.hooks` block ran `npm ci && npm run build` inside
the GoReleaser process, which means npm lifecycle scripts and the
SvelteKit build inherited the Developer ID .p12 cert, cert password,
and .p8 notary key from the goreleaser-action's env. Adding a 5-year
signing cert to that environment meaningfully widened the blast radius
of any npm supply-chain compromise.

Fix: move the web build into a dedicated workflow step before the
goreleaser-action invocation. The MACOS_* secrets are scoped only to
the goreleaser env block, so the npm phase no longer sees them.

LOW — GoReleaser binary version was floated as `~> v2`, while every
third-party Action in this workflow is SHA-pinned per the policy at
the top of the file. With Apple signing credentials now in the env, a
compromised or regressed GoReleaser release would carry meaningful
blast radius. Pinned to v2.15.4 (current latest) so bumps go through
explicit review like the rest of the action pins.

No functional change to the signing/notarization itself — same schema,
same gating, same secrets.

* docs(release): document web/build prereq for local snapshot builds

Addresses second-pass Codex review finding on PR #278.

Removing the goreleaser `before.hooks` web build (done in 6f4a52c to
isolate npm from MACOS_* secrets) means `goreleaser release --snapshot`
from a clean checkout now fails with "embed: no matching files found"
because embed.go declares `//go:embed all:web/build` and there's no
longer a hook that auto-populates that directory.

CI is unaffected (the workflow has a dedicated `Build web UI` step), but
contributors testing goreleaser config locally would hit a confusing
error before reaching the part they wanted to validate.

Doc-only fix: extend the existing comment block at the top of
.goreleaser.yaml to point contributors at `make web` (or the explicit
npm commands) as the prerequisite for local snapshot builds. Note that
`make build` and `make install` already handle this automatically.

* harden(release): add concurrency guard + bump goreleaser timeout

Addresses third-pass Codex review findings on PR #278.

Both findings were LOW (no ship blockers) and pre-existing concerns
that the macOS notarize block makes slightly more visible. Folded into
this PR rather than deferring because both relate directly to the
goreleaser invocation we already touched.

LOW #1 — GoReleaser overall timeout was the default 1h, while the
notarize block now allows up to 20m of Apple notary wait time on top
of build + cosign blob-sign + SBOM + multi-arch docker. On a slow
notary day (or first-cert-use latency), that could come close to or
hit the default ceiling. Bumped `release --clean` to
`release --clean --timeout=2h` for comfortable headroom without
burning Action minutes on the happy path (worker exits as soon as
Apple replies).

LOW #2 — No workflow-level concurrency guard. If two `v*` tags landed
close together (rc.3 then rc.4 within a minute), runs would race on
shared mutable outputs: GHCR `:latest`, the homebrew cask in the
separate tap repo, the GitHub Releases page. Added a top-level
concurrency block that serializes all release runs.

Group is intentionally NOT keyed by `github.ref` — different tag names
share the same mutable infrastructure, so we want all release tags to
serialize, not just repeat pushes of the same tag. cancel-in-progress
is false so a queued tag never aborts a release mid-publish, which
could leave GHCR and the brew tap in inconsistent states.

No functional change to signing/notarization itself.
v0.0.1-rc.3
2026-04-28 20:24:38 -04:00
xarmian 53b5add4e9 fix(store): bump SQLite busy_timeout from 5s to 30s (BUG-853) (#277)
TestSQLiteConcurrentWritersNoBusy intermittently fails on the GitHub-
hosted Go (SQLite) CI job with `database is locked (5) (SQLITE_BUSY)`
under 25 concurrent writers × 5 ops. The test asserts ZERO errors so
that BUG-748's `_txlock=immediate` regression stays pinned — but with
the DSN's busy_timeout at 5s, the unluckiest writer on a slow shared
runner can exceed the timeout: 125 serialized inserts under heavy
contention from sibling test packages add up.

Bumping busy_timeout to 30s gives a 6× margin over the worst observed
CI run and ~50× the normal local p95. Genuine deadlocks don't happen
with WAL + BEGIN IMMEDIATE, so the only thing the higher value costs
is "how long we wait before declaring lock contention pathological".
For Pad's workload, 30s is fine.

Surfaced once BUG-851 (PR #276) cleared the rate-limiter goroutine
leak that had been masking everything else on main.

Verified locally:

    $ go test -count=20 -run TestSQLiteConcurrentWritersNoBusy \
        ./internal/store/
    ok  github.com/PerpetualSoftware/pad/internal/store  6.102s

Note: this is a single-character DSN change plus a doc-comment update;
no code path or contract is altered. Read concurrency (WAL) is
unchanged — we're not touching SetMaxOpenConns.
2026-04-28 17:59:21 -04:00
xarmian 715ec70e94 fix(server): drain ipRateLimiter cleanup goroutines on Stop() (BUG-851) (#276)
NewRateLimiters spawned 9 ipRateLimiter cleanup goroutines per Server,
each in an unbounded `for { time.Sleep(5*time.Minute); ... }` loop with
no exit signal (middleware_ratelimit.go:78-89). Every testServer(t)
call leaked all 9, accumulating across the 210-test internal/server
suite. Under -race the goroutine count + sync overhead pushed the run
past the default 10m timeout, which is why the `Run tests with race
detector` step (gated to main pushes) has been failing on every main
run since the step was added on 2026-04-13.

This is the same flavor as BUG-842 part 2 (request-handler
fire-and-forget goroutines drained via Server.bg WaitGroup). The
rate-limiter case wasn't in BUG-842's scope: those goroutines are
spawned at construction time, not at request time, so they need a
different drain primitive.

Changes:

  - ipRateLimiter gains stopCh + stopOnce + stopWg. cleanup() rewrites
    its loop as a select over stopCh and a 5-minute ticker, deferring
    stopWg.Done(). New Stop() closes stopCh once and waits for the
    cleanup goroutine to return.
  - RateLimiters gains a Stop() that walks all 9 limiters (nil-safe
    via the (*ipRateLimiter).Stop receiver guard).
  - Server.Stop() now also calls s.rateLimiters.Stop() after
    s.bg.Wait(). Test cleanups already call Server.Stop() (added in
    BUG-842), so no test-helper changes needed.
  - New TestServer_Stop_DrainsRateLimiterCleanup pins the contract:
    construct + Stop N servers, assert runtime.NumGoroutine() returns
    to baseline ±3.
  - .github/workflows/ci.yml: bump the -race timeout from the default
    10m to 20m. The full server suite under -race takes ~13m on a dev
    laptop after the leak fix; 20m gives margin without papering over
    an actual hang. Both `Run tests with race detector` (SQLite) and
    `Run tests with race detector against PostgreSQL` are bumped.

Verified locally: go test -race -timeout=1500s ./internal/server/
finishes ok in 776s (12m57s). Without the leak fix, the same command
times out at 600s (10m) with a goroutine dump showing hundreds of
ipRateLimiter.cleanup frames.
2026-04-28 17:20:03 -04:00
xarmian 0fd5d0cdfb fix: green up Go (PostgreSQL) CI (BUG-842) (#275)
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)

`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.

`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.

* fix(server): drain background goroutines on Stop() (BUG-842)

`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.

Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:

- middleware_auth.go (TouchUserActivity)
- handlers_auth.go   (password reset email)
- handlers_cloud.go  (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)

Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.

* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)

The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.

PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.

Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.

The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.

Surfaces:
  - dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
    placeholders each in the PG dialect.
  - items.go: listItemsFTS PG branch + SearchItems PG branch update
    args to pass (raw, sanitized) for every PG `?` placeholder.
  - search.go: SearchItems main / count / facets PG branches updated
    likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
  - documents.go: ListDocuments PG branch updated.

Tests:
  - TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
    case to pin the OR-combined logic — naive hyphen-stripping would
    silently regress this.
  - New TestSanitizePGFTSQuery unit test.

* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)

The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.

Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:

  cmd/pad/configure.go
  cmd/pad/main.go
  internal/cli/format.go
  internal/server/handlers_admin_invitations.go
  internal/server/handlers_admin_users.go
  internal/server/handlers_grants.go
  internal/server/handlers_share_links.go
  internal/server/handlers_stars.go
  internal/server/middleware_auth.go
  internal/store/store.go
  internal/store/store_test.go

After this commit `gofmt -l ./cmd ./internal` returns clean.
2026-04-28 16:21:43 -04:00
xarmian 43b2565afe fix(web): stop infinite recursion in marked link renderer (BUG-849) (#274)
* fix(web): stop infinite recursion in marked link renderer (BUG-849)

The custom link renderer called marked.parseInline on the raw text of a
link's child tokens to render the visible text. For autolinks (bare URLs
that GFM auto-detects as links) the raw text *is* the URL, so the
recursive parseInline re-tokenized it as another autolink and re-entered
the same renderer — stack overflow, browser console spammed with
"Please report this to https://github.com/markedjs/marked", and the
item page rendered as fallback text.

Triggered on any item whose content or comments contained a bare URL,
e.g. HT-786 had a comment with https://manage.maileroo.app.

Use marked's intended API: this.parser.parseInline(tokens) renders the
already-parsed inline tokens directly, no re-tokenization. Required:
- regular function (not arrow) so `this` binds to the Renderer instance
  (marked invokes overrides via override.apply(rendererInstance, args))
- import Renderer for the `this: Renderer` annotation
- escape the title attribute via escapeHtml() at the source instead of
  relying on DOMPurify after the fact

* fix(web): encode href in markdown link renderer (defense-in-depth)

Mirror marked's internal cleanUrl() so the custom link renderer produces
well-formed HTML even when href contains spaces, quotes, or other
URL-unsafe characters — and degrades gracefully to plain text when
encodeURI throws (lone surrogates).

Before: an href like `http://x" onclick="alert(1)` (reachable via marked's
`[x](<...>)` URL-with-spaces syntax) would land in the attribute
verbatim, producing malformed HTML the sanitizer then had to repair.
After: encodeURI turns the quotes into %22, so the intermediate HTML is
already well-formed before DOMPurify runs. The %25 → % round-trip avoids
double-encoding hrefs that already contain percent-encoded bytes
(e.g. %20).

DOMPurify is still the URL-safety authority — javascript:/data: schemes
are stripped by sanitizeMarkdownHtml's ALLOWED_URI_REGEXP. This change
is defense-in-depth plus correctness for the intermediate HTML, matching
the behavior of marked's default renderer.

Flagged in Codex review of #274.
2026-04-28 13:52:43 -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).
v0.0.1-rc.2
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 0ab6d3ed10 feat(web): signed-in account chip on CLI auth approval + switch-accounts (TASK-836) (#271)
* feat(web): show signed-in account chip on CLI auth approval page (TASK-836)

The CLI auth approval page (/auth/cli/{code}) previously showed only an
"Approve" button with no indication of WHICH account was about to grant
the CLI access. For OAuth users on Pad Cloud — most of whom have
multiple GitHub/Google accounts — wrong-account approval was a silent
footgun, recoverable only by revoking the CLI token after the fact.

This change renders an account chip above the Approve button when the
session is pending, showing:

- The user's avatar (when avatar_url is present)
- Display name (or username fallback if name is empty)
- Email

Below the chip, a "I'm not <Name> — switch accounts" link button calls
api.auth.logout() and navigates to /login?redirect=/auth/cli/{code}, so
after re-login the user lands back on this same approval page (the
login page already validates relative-only redirects to prevent open
redirects).

Graceful degradation: api.auth.me() is wrapped in its own try/catch.
If it fails, currentUser stays null and the chip simply doesn't
render — the Approve flow still works. The Approve button is also
disabled while a switch-accounts call is in flight to avoid
double-action races.

Works for both email/password (self-hosted) and OAuth (Cloud)
sessions because api.auth.me() and api.auth.logout() operate on the
unified pad session regardless of how it was established.

Parent: PLAN-833. Source: IDEA-831 issue #3.

* fix(web): plumb redirect through OAuth login + surface logout failures

Codex round-1 findings on TASK-836:

- MEDIUM: The login page already preserved ?redirect= for password and
  2FA login but the GitHub/Google OAuth buttons were plain anchors with
  hardcoded hrefs. A user clicking "Switch accounts" on the CLI auth
  approval page and then signing in via OAuth would land at /console
  instead of back at /auth/cli/{code}. Added a $derived oauthRedirectQuery
  rune that reuses the existing getRedirectTarget() validation and
  appends ?redirect=<encoded> to both OAuth links when the redirect is
  non-default. Whether pad-cloud's /auth/github and /auth/google handlers
  honor the redirect param is an out-of-tree concern and tracked
  separately if needed; the client side now consistently passes it.

- LOW: handleSwitchAccount silently swallowed logout failures and then
  navigated to /login. If the server didn't actually invalidate the
  session cookie (network/CSRF), login's onMount would see an
  authenticated session and bounce the user right back to the approval
  page — making "switch accounts" appear to be a no-op. The handler now
  surfaces the error in the page error slot and stays on the approval
  page, giving the user a clear next step (retry or close the tab) and
  also resets switchingAccount so the UI isn't stuck in a "Switching..."
  state.

A defensive code check was also added to handleSwitchAccount to mirror
handleApprove's "Missing CLI session code" guard, even though the button
only renders when status === 'pending'.

Parent: PLAN-833.

* fix(web): tighten redirect validation + cover OAuth banner buttons

Codex round-2 findings on TASK-836:

- MEDIUM: getRedirectTarget() accepted protocol-relative URLs (`//host`
  and `/\host`) because the bare `startsWith('/')` check passes for
  both. Browsers and most server-side redirect handlers treat those as
  cross-origin destinations, so a crafted `?redirect=//evil.example`
  could become an open redirect once forwarded through the OAuth
  handler. Now also rejects strings that start with `//` or `/\`. This
  was a pre-existing bug in the password/2FA redirect path; the OAuth
  link change made the surface area worth tightening.
- LOW: The "Use a different GitHub/Google account" banner buttons that
  appear on `oauth_provider_not_linked` errors hardcoded `?force=1` and
  dropped the redirect target. Added a sibling `oauthRedirectAmpQuery`
  derived value (`&redirect=...`) so those links compose properly with
  `?force=1`. When the redirect is the default `/console` it stays
  empty so we don't add redundant query noise.

Both changes live in cmd/pad/... no, in web/src/routes/login/+page.svelte
and don't affect the password / 2FA paths beyond the validation
tightening (which they were already passing through silently).

Parent: PLAN-833.
2026-04-27 23:11:35 -04:00
xarmian afd3b3c5ee feat(cli): explicit cancel + clean SIGINT for pad init prompts (TASK-835) (#270)
* feat(cli): explicit cancel + clean SIGINT for pad init prompts (TASK-835)

The interactive prompts in 'pad init' / 'pad workspace init' previously
relied on Go's default Ctrl+C behavior (terminate with no message) and
offered no in-prompt way to back out. A user who realized mid-init that
they were in the wrong directory had no clean exit and risked partial
state.

This change:

- Adds cmd/pad/cancel.go with errCancelled (sentinel), cancelInit() (the
  canonical "Cancelled." + os.Exit(130) path), and an installable
  SIGINT/SIGTERM handler.
- Template picker accepts c/q/cancel/quit (case-insensitive) and returns
  errCancelled. Prompt text now mentions the cancel option.
- Mode picker (pad configure) gains the same cancel keywords + prompt
  hint.
- Both pad init and pad workspace init RunEs install the signal handler
  and convert any propagated errCancelled into the same cancelInit()
  exit, using a named-return + LIFO defer so the existing body is
  unchanged.
- SilenceErrors + SilenceUsage are set on both init commands so cobra
  doesn't render an "Error: cancelled by user" line on top of our
  friendly message.

State on cancel: the template picker is invoked AFTER step 1
(configure) but BEFORE the workspace is created on the server and
BEFORE .pad.toml is written, so an abort at that prompt leaves no
half-created workspace, no orphan .pad.toml, and no stale credentials.

Tests cover all cancel keyword variants (both via the picker and via
errors.Is on wrapped errors), and verify the prompt surface mentions
the cancel option.

Parent: PLAN-833. Source: IDEA-831 issue #4.

* fix(cli): wire cancellation into all init paths per Codex review

Round 1 findings:

- HIGH: getConfiguredConfig() called os.Exit(1) on errCancelled, bypassing
  the canonical "Cancelled." + 130 exit. It now recognizes the sentinel and
  routes through cancelInit() before falling through to its generic
  Error path.
- HIGH: SilenceErrors+SilenceUsage on the init commands silenced every
  error, hiding real failures (e.g. server connection problems). Removed
  both — cancelInit() never returns to cobra, so the cancellation case
  doesn't need silencing, and real errors print normally again.
- MEDIUM: doBrowserLogin returned `fmt.Errorf("login cancelled")` on
  context cancel, which is not errCancelled. If its inner signal listener
  won the race against the outer init handler on Ctrl+C, the propagated
  error didn't match isCancellation and the command exited 1 with a
  generic message. doBrowserLogin now returns errCancelled directly so
  whichever goroutine wins the race, the exit converges on 130.
- MEDIUM: cancel.go cleanup race — if a signal arrived between init
  completion and the goroutine returning, both sigCh and done could be
  ready and select could pick sigCh, turning a successful run into a
  spurious 130 exit. Added a re-check on done inside the sigCh branch
  so late signals are suppressed once cleanup has run. Also reordered
  cleanup to call signal.Stop before close(done) so no new signals
  enter the buffer during shutdown.
- MEDIUM: promptForValue (the URL prompt for remote/docker mode) still
  treated 'c' as URL input and failed validation. It now recognizes
  c/q/cancel/quit and returns errCancelled, matching the picker and
  mode-prompt behavior.

LOW finding (account-setup prompts) intentionally not addressed: Ctrl+C
already covers them via the outer handler, and explicit keyword
recognition on the password prompt would risk collision with real
passwords. doInteractiveLogin is not on an init path.

Parent: PLAN-833.

* fix(cli): cancel sentinel handling for pad auth configure / pad auth login

Codex round-2 findings:

- MEDIUM: pad auth configure RunE returned errCancelled directly to
  cobra. Now wraps the body with the same isCancellation -> cancelInit()
  deferred check used in pad init, so 'c' at the mode/URL prompt exits
  with the canonical "Cancelled." + 130.
- LOW: pad auth login RunE called doBrowserLogin (which now returns
  errCancelled on signal cancellation). The sentinel was leaking to
  cobra. Added the same deferred check so SIGINT during browser login
  exits 130 with a friendly message regardless of which goroutine wins
  the cancellation race.

Neither command installs the outer SIGINT handler — pad auth login
relies on doBrowserLogin's existing inner listener (avoiding the
double-listener race) and pad auth configure's prompts are short
enough that Go's default Ctrl+C handling for those is acceptable. The
new deferred checks just plug the sentinel-leak holes.

Parent: PLAN-833.
2026-04-27 22:05:45 -04:00
xarmian 189b22825e fix(cli): use cfg.BaseURL()/BrowserURL() in pad init success message + pad open (TASK-834) (#269)
* fix(cli): use cfg.BaseURL() in pad init success message (TASK-834)

The "Or open the web UI at http://localhost:7777" line in
printOnboardingHints was hardcoded, which is wrong for any
non-local connection mode (Remote, Docker, eventual Cloud).
The CLI already knows the configured base URL — it just used
it to talk to the server.

Same hardcoded URL existed in the workspace-onboard skip path
("You can activate conventions from the library: ...").

Both call sites now use cfg.BaseURL(), which yields the correct
URL for every mode:
- Local: http://127.0.0.1:7777 (default host:port)
- Remote/Docker/Cloud: the configured URL (e.g. https://app.getpad.dev)

printOnboardingHints now takes a *config.Config; both call sites
already had cfg in scope.

Parent: PLAN-833 (pad init UX gaps + Pad Cloud onboarding fixes).
Source: IDEA-831 issue #5.

* fix(config): add BrowserURL() that normalizes 0.0.0.0 to 127.0.0.1

Per Codex review (round 1): when local mode runs with --host 0.0.0.0
(bind-all), cfg.BaseURL() returned "http://0.0.0.0:7777" — a bind
address that browsers don't reliably accept.

BrowserURL() behaves like BaseURL() except that when constructing
from host:port, an unspecified bind-all host (empty, "0.0.0.0", "::",
"[::]") is rewritten to "127.0.0.1". Explicit URL configurations
(Remote/Docker/Cloud) are returned unchanged.

The two onboarding-hint call sites updated in the previous commit now
use BrowserURL() so the success message and skip-path show a clickable
URL in every supported configuration. Tests cover loopback, named
hosts, empty/0.0.0.0/::/[::] normalization, and explicit-URL
precedence.

Parent: PLAN-833.

* fix(cli): use BrowserURL() in pad open for bind-all safety

Per Codex review (round 2): the 'pad open' command prints and opens
cfg.BaseURL(), which produces 'http://0.0.0.0:7777' when the local
server is bound bind-all. Same class of bug as the onboarding hint
fix in this PR — switch to cfg.BrowserURL() so the URL is a usable
browser destination.

A second related issue Codex flagged — the server-issued CLI auth URL
in doBrowserLogin (which goes through internal/server/handlers_cli_auth.go
using r.Host) — is a different surface with multiple possible fix
strategies and overlaps with the post-v0.1.0 OAuth-architecture work.
Deferred to TASK-839 with a written-up runbook so it isn't lost.

Parent: PLAN-833.
2026-04-27 21:48:50 -04:00
xarmian 8ae009fa40 feat(admin): add Billing tab and dashboard page (TASK-828) (#267)
* feat(admin): add Billing tab and dashboard page (TASK-828)

Surfaces the Pad Cloud billing metrics in the admin console as a new
tab between "Audit Log" and "Settings". Final piece of PLAN-825.

The page calls GET /api/v1/admin/billing-stats (TASK-827) and renders
six metric cards in a responsive auto-fit grid:

1. MRR              (Stripe-derived; greyed when unavailable)
2. ARR              (Stripe-derived; greyed when unavailable)
3. Active Subs      (Stripe-derived; greyed when unavailable)
4. Customers/Plan   (LOCAL — always real; e.g. "Free: 42 · Pro: 7")
5. New Signups 30d  (LOCAL — always real)
6. Churn 30d        (Stripe-derived; greyed when unavailable;
                     subtitle shows cancelled count)

Two banners drive the degraded-state UX:
- cloud_unreachable=true  → amber warning ("sidecar unreachable, showing
                            local data only")
- stripe_configured=false → blue info banner explaining that Stripe
                            metrics will be zero until STRIPE_SECRET_KEY
                            is set on pad-cloud (the expected pre-launch
                            steady state)

Header carries a Refresh button (re-fetches without unmounting the page)
and an "Open in Stripe Dashboard ↗" external anchor (rel=noopener).
A subtle footer renders "Updated just now" or "Updated N min ago" from
the cache_age_seconds field.

The Billing tab is hidden from the layout's tab list when
adminStore.stats.cloud_mode is false — self-host operators won't see a
tab that always 404s on click. Used $derived(...) for the tabs array so
the tab list reacts to the cloud_mode flag flipping after stats load.

Svelte 5: runes throughout ($state, $derived, $props), single onMount
for the initial fetch, no combined effect-on-effect chains (CONVE-606).
Visual idiom mirrors the existing /console/admin stats-bar (.stat
cards, --bg-secondary background, --radius-lg, value/label sizing).

Validated with the svelte MCP autofixer (clean) and `npm run build`
(clean, page emitted to entries/pages/console/admin/billing).

Closes PLAN-825's UI work.

* fix(admin): add role=status / aria-live=polite to Stripe info banner

Codex round 1 LOW: the warning banner already carries role=alert because
its message is urgent (sidecar unreachable), but the "Stripe not
configured" info banner appears asynchronously after load with no live-
region semantics, so screen readers never announce that the page is in
a degraded state. Add role=status + aria-live=polite so the announcement
is non-interrupting but happens.
v0.0.1-rc.1
2026-04-27 14:52:13 -04:00
xarmian 8e067c19db feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827) (#266)
* feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827)

New admin endpoint that powers the upcoming Pad Cloud Billing dashboard:
GET /api/v1/admin/billing-stats merges Stripe-derived metrics from pad-cloud
(active subs, MRR, ARR, churn, 30-day cancellations) with locally-computed
aggregates from the users table (customers_by_plan, new_signups_30d in the
last 30 days for plan='pro').

Architecture (PLAN-825 Option B):
- pad-cloud (TASK-826, already merged) hosts the Stripe API access in one
  place; this PR adds the reverse pad → pad-cloud client method.
- Existing internal/billing.CloudClient gains GetBillingMetrics(): GET on
  /admin/metrics/billing with the X-Cloud-Secret header (the same secret
  pad-cloud already validates inbound calls with).
- New CloudSidecar.GetBillingMetrics() interface method keeps the server
  package free of HTTP/Stripe dependencies and lets tests inject fakes.
- Existing fakeSidecar in handlers_account_test.go grows a no-op stub so
  the account-delete tests still satisfy the extended interface.

Degradation contract:
- The endpoint always returns 200. Two booleans tell the UI which fallback
  to render: cloud_unreachable=true (sidecar errored or unwired) and
  stripe_configured=false (sidecar reachable but no STRIPE_SECRET_KEY yet).
- requireCloudMode + requireAdmin gate the route. Self-host gets 404,
  non-admin gets 403.

Web glue:
- Added AdminBillingStats type to web/src/lib/types/index.ts.
- Added api.admin.getBillingStats() to web/src/lib/api/client.ts.
The Billing tab and metric cards land in TASK-828.

Tests:
- Billing package: GetBillingMetrics happy path (verifies method, path,
  X-Cloud-Secret header, Accept header), Stripe-not-configured pass-through,
  non-200 → SidecarError, transport error stays bare, malformed JSON,
  nil/unconfigured client guards.
- Server package: self-host 404, non-admin 403, admin happy path
  (merges local + remote correctly, handles plan="" → "free", filters
  new_signups_30d to plan='pro' AND created_at >30d ago), no-sidecar
  degrades to local-only, transport error degrades, sidecar 5xx degrades,
  stripe_configured=false propagates verbatim with cloud_unreachable=false.

Part of PLAN-825 (Pad Cloud Admin Billing Dashboard).

* fix(admin): address Codex review (round 1) on billing-stats proxy

- Replace handler-side ListUsers walk with store.CountBillingAggregates
  (two scalar SQL queries: COUNT(*) GROUP BY plan + a single COUNT(*)
  for new pro signups). Removes the per-row TOTP decrypt overhead that
  ListUsers performs and bounds CPU/bandwidth as the user table grows.
- Fix misleading TS comment on AdminBillingStats: clarify that "fully
  healthy" requires cloud_unreachable=false AND stripe_configured=true,
  not "both flags false" as previously stated.

Adds TestCountBillingAggregates exercising empty store, mixed plans,
empty-plan → "free" bucketing, and the 30-day cutoff filter for new
pro signups.

* fix(store): GROUP BY normalised plan expression in CountBillingAggregates

Codex round 2 caught a real bug: SELECT projected the COALESCE'd plan but
GROUP BY operated on the raw `plan` column, so users with plan='' and
plan='free' produced two distinct result rows that both scanned as "free"
in Go — the second iteration overwrote the first in CustomersByPlan,
silently underreporting the free-tier count.

Fix: GROUP BY COALESCE(NULLIF(plan, ''), 'free') so the grouping matches
the projection. Test updated: insertWithPlanAndDate now seeds an explicit
'' plan alongside two explicit 'free' rows and asserts the aggregate
rolls them up to 3 — the previous test only used CreateUser which always
inserts the column default ('free') and never exercised the empty-string
path.
2026-04-27 14:42:53 -04:00
xarmian c4b5a36330 feat: warn at startup when shipped FTS triggers are missing (TASK-824) (#265)
* feat(store): warn at startup when shipped FTS triggers are missing (TASK-824)

Defensive follow-up to BUG-822, where the documents_* triggers had
silently drifted off some production DBs and search was broken until
a user noticed. The migration runner had no notion that the triggers
should exist — the only invariant was "this migration ran without
erroring," which is too weak when SQLite's table-rebuild path can leave
auxiliary objects in a different state than the migration intended.

Add a hardcoded list of expected FTS5 triggers (one row per trigger,
naming the table it's attached to) and a one-shot validateFTSInvariants
step at the end of Store.migrate(). Each missing trigger emits a
structured slog.Warn that points the operator at the recovery
migration (046).

Choices:
- SQLite-only. Postgres uses tsvector update functions in pgmigrations
  with a different invariant model.
- Logging-only, no auto-repair. Auto-creating triggers here would mask
  legitimate future removals and obscure the source of truth (the
  migrations directory). The recovery path is a targeted migration
  like 046_restore_documents_fts_triggers.sql.
- Non-fatal. A missing trigger doesn't block startup; the operator
  may have intentionally removed one and just not updated the list
  yet, and we'd rather warn loudly than refuse to boot.

Tests:
- TestStartupInvariants_AllFTSTriggersExist — fresh DB has all 9
  expected triggers (forward-looking guard against future migrations
  that break one).
- TestStartupInvariants_LogsOnMissingTrigger — drop a trigger, run
  validator, capture slog records, assert a warning naming the
  missing trigger was emitted.

Manual verification on the production DB:
- Clean DB (after migration 046): no warnings on startup.
- After manually `DROP TRIGGER documents_ai`: server logs
  `level=WARN msg="FTS trigger missing — ..." trigger=documents_ai
  table=documents` immediately on startup.

* test(store): address Codex review on TASK-824 — bidirectional drift + Record.Clone

Two LOW findings from Codex's first pass:

1. recordCapturingHandler.Handle stored slog.Record values without
   cloning. Records have internal shared state; the documented pattern
   for retaining them is r.Clone() first. Test passed today only
   because nothing mutated the record after Handle, but the helper was
   relying on slog internals.

2. TestStartupInvariants_AllFTSTriggersExist only proved every entry
   in expectedFTSTriggers exists. It didn't catch the inverse: a future
   migration adding a new FTS-style trigger on items/comments/documents
   without also adding it to expectedFTSTriggers, leaving the new
   trigger off the invariant check forever.

Add TestExpectedFTSTriggers_MatchesActual which queries sqlite_master
for every trigger on items/comments/documents and asserts each is in
the expected list. A new trigger that isn't tracked fails this test
with a clear "update the list in store.go" message.

If a future trigger on these tables is legitimately not FTS-related,
the test failure points the developer at this guard and they can
either add it to expectedFTSTriggers or extend the exclusion.
2026-04-27 13:34:10 -04:00
xarmian 4608108acf fix: restore documents_fts triggers + rebuild index (BUG-822) (#264)
* fix(store): restore documents_fts triggers + rebuild index (BUG-822)

Some production DBs ended up missing the documents_ai/au/ad triggers,
even though migration 025 (which rebuilt the documents table for the
doc_type CHECK constraint change) was recorded as applied. Items_fts
and comments_fts triggers were unaffected — issue is isolated to the
documents table-rebuild path.

Without these triggers, INSERT INTO documents never propagates rows
into documents_fts, so newly-created documents are silently invisible
to search. Plain list views still surface them, masking the regression.

Migration 046 is idempotent and safe to apply on any DB:

  1. DROP TRIGGER IF EXISTS for the three documents_* triggers — round-
     trips for DBs that ran 025 cleanly, recovers DBs missing the
     triggers.
  2. CREATE TRIGGER for all three (matching the bodies in 001/025).
  3. INSERT INTO documents_fts(documents_fts) VALUES ('rebuild') to
     repopulate the FTS5 internal index from the current documents
     table — recovers searchability for documents created while the
     triggers were missing.

Postgres path uses a separate tsvector trigger function and is not
affected (only pgmigrations are applied there; this migration lives
in the SQLite migrations directory).

Tests:
- TestMigration046_DocumentsFTSTriggersExist — assert all three
  documents_* triggers exist after migrations run.
- TestCreateDocument_IsSearchableImmediately — regression test for
  the failure mode: create a doc, immediately search by a unique
  title-keyword, assert it's findable.

Manual verification on the production DB:
- Triggers re-appeared after `make install` (migration 046 applied).
- POST /documents with title "BUG822verify distinctive" → immediately
  findable via ?q=BUG822verify (returned 1 result, the new doc).

* test(store): pin the BUG-822 recovery path with a rebuild test

Codex review on the BUG-822 fix flagged that neither existing test would
fail if the `INSERT INTO documents_fts(documents_fts) VALUES('rebuild')`
step were removed from migration 046. The trigger-existence and
post-fix-search-works tests both pass on a clean migration run, but
they don't exercise the historical-recovery half of the migration —
the part that rescues already-broken DBs whose documents were inserted
while the triggers were missing.

Add TestMigration046_RebuildRecoversUnindexedDocs which:
1. Drops the documents_* triggers to simulate the broken state.
2. Inserts a document via the store path — won't reach FTS without
   triggers.
3. Asserts the doc is invisible to ListDocuments (sanity-pinning the
   broken state).
4. Runs just the rebuild step from migration 046.
5. Asserts the previously-unindexed doc is now searchable.

This locks in the recovery contract: removing the rebuild step from
046 will now make this test fail.
2026-04-27 13:04:02 -04:00
xarmian dcf7c1d58e fix(store): apply Tag and Pinned filters in ListDocuments FTS branch (BUG-820) (#263)
The non-FTS path in ListDocuments applies Tag and Pinned filters (lines
31-42), but when params.Query is non-empty the FTS branch rebuilds query
and args from scratch and only re-applies Type and Status — Tag and
Pinned were silently dropped. Result:
`/documents?q=foo&tag=urgent` returned all docs matching foo regardless
of tag, similarly for pinned.

Documents-side analog of BUG-812 (which fixed the equivalent issue on
the items FTS path).

Fix: mirror the Tag (s.dialect.JSONArrayContains on d.tags) and Pinned
(d.pinned = TRUE/FALSE) filter blocks into the FTS branch after the
existing Type/Status blocks. Backend-only — handlers and DocumentListParams
already plumb both params through.

Tests:
- TestListDocuments_FTS_TagFilter — two docs match the search; only one
  has the tag; assert exactly the tagged one returned.
- TestListDocuments_FTS_PinnedFilter — covers both pinned=true and
  pinned=false branches, asserting each narrows correctly.

Manual verification: with two docs `BUG820scratch alpha` (tagged
"urgent", pinned) and `BUG820scratch beta` (untagged, unpinned):
- ?q=BUG820scratch              → 2 docs
- ?q=BUG820scratch&tag=urgent   → 1 doc (alpha)
- ?q=BUG820scratch&pinned=true  → 1 doc (alpha)
- ?q=BUG820scratch&pinned=false → 1 doc (beta)
2026-04-27 12:38:50 -04:00
xarmian 068c208824 fix: sanitize SQLite FTS5 queries + whitespace guards (BUG-818) (#261)
* fix(store): sanitize FTS5 queries in listItemsFTS and SearchItems (BUG-818)

The sanitizeFTSQuery helper in internal/store/search.go wraps each
whitespace-delimited token in double quotes so SQLite FTS5 treats
specials (hyphens, AND/OR/NOT, parens) as literal characters rather
than boolean operators. Store.Search already used it; Store.listItemsFTS
and Store.SearchItems didn't, so any hyphen in `?search=` returned
HTTP 500 with "no such column: <suffix>" — including issue refs like
TASK-5, kebab-case slugs, dates, etc.

Apply sanitizeFTSQuery at the SQLite arg-binding sites in both unfixed
functions. Postgres branches stay unsanitized: plainto_tsquery accepts
arbitrary input safely (matches the existing pattern in Store.Search).

Tests:
- TestListItems_FTS_HyphenatedSearchTerm — exercises the listItems path
  on multiple hyphenated queries via a table-driven sub-test.
- TestSearchItems_HyphenatedQuery — same regression on the SearchItems
  path used by /api/v1/search.
- TestSanitizeFTSQuery — direct unit test covering empty, whitespace-
  only, plain word, hyphenated phrase, multi-token, FTS5 boolean
  operators (AND/OR/NOT), parens, embedded quotes (stripped),
  surrounding whitespace, and unicode.

Manual verification: previously-500 queries now return 200 with results:
  /items?search=match-me   → HTTP 200, 2 items
  /items?search=TASK-5     → HTTP 200, 8 items
  /items?search=pad-cloud  → HTTP 200, 103 items

* fix(store): address Codex review on PR for BUG-818

Codex review caught two extensions to the original BUG-818 fix:

1. MEDIUM — Store.ListDocuments (internal/store/documents.go) had the
   same FTS5 boolean-parser vulnerability as Store.listItemsFTS and
   Store.SearchItems before the original commit. Hyphenated /documents?q=
   queries (e.g. ?q=release-notes-q2) returned HTTP 500 with "no such
   column" the same way. Apply sanitizeFTSQuery in the SQLite branch;
   leave Postgres unchanged.

2. LOW — Whitespace-only queries collapse to empty after FTS sanitization,
   and SQLite FTS5 errors on `MATCH ''` with "syntax error near \"\"".
   Add TrimSpace guards at the routing/entry points:
   - listItems: route to FTS only if TrimSpace(Search) != ""
   - SearchItems: short-circuit to empty results
   - ListDocuments: same routing guard
   - Store.Search: short-circuit to empty results

Tests:
- TestListDocuments_HyphenatedQuery — regression on the documents FTS path
- TestFTS_WhitespaceOnlyQuery_DoesNotCrash — covers all 3 entry points
  (ListItems, SearchItems, ListDocuments) for spaces, tabs, mixed
  whitespace

Manual verification (all 6 endpoints now HTTP 200):
- /workspaces/{ws}/items?search=task-five
- /workspaces/{ws}/items?search=<3 spaces>
- /workspaces/{ws}/documents?q=release-notes
- /workspaces/{ws}/documents?q=<3 spaces>
- /search?q=task-5
- /search?q=<3 spaces>
2026-04-27 12:19:18 -04:00
xarmian 10e17e0ca1 fix(store): apply Tag/ParentID/Assignee/AgentRole/Fields filters in listItemsFTS (BUG-812) (#260)
When `search` is set, ListItems routes through listItemsFTS, which
historically only re-applied CollectionSlug, CollectionIDs, ItemIDs, and
(post-BUG-734) ParentLinkID. Other filter parameters silently dropped:

- Tag
- ParentID (legacy items.parent_id column)
- AssignedUserID
- AgentRoleID (both ID-equality and slug-OR branches)
- Fields (custom-field equality / IN-list)

Result: combining ?search=foo with any of the above returned more items
than the caller asked for. Web UI list filters chained with the search
box, the per-collection filter chips, and any API consumer with the same
combo were all affected.

Fix: mirror the relevant filter blocks from the non-FTS listItems path
into listItemsFTS, preserving isValidFieldKey injection guarding on
field keys.

Tests (internal/store/items_test.go):
- TestListItems_FTS_TagFilter
- TestListItems_FTS_ParentIDFilter
- TestListItems_FTS_AssignedUserFilter
- TestListItems_FTS_AgentRoleFilter (covers both role-ID and role-slug
  branches)
- TestListItems_FTS_FieldFilter (single-value, IN-list, and the
  invalid-key silent-drop)

Out of scope: IncludeArchived parity (FTS hardcodes deleted_at IS NULL),
Sort parity (FTS deliberately sorts by relevance rank), Offset (FTS
honors only Limit). Unrelated to BUG-812; can ship together later if
desired.

Manual verification: with two tasks `Bug812scratch alpha` (priority=high)
and `Bug812scratch beta` (priority=low), `?search=Bug812scratch` returns
both, `?search=Bug812scratch&priority=high` returns only alpha.
2026-04-27 11:12:37 -04:00
xarmian 0bf710eea5 fix: hide item_links pointing to soft-deleted items (BUG-734) (#259)
* fix(store): hide item_links pointing to soft-deleted items (BUG-734)

Item-link queries that JOIN against `items` now also filter on
`deleted_at IS NULL` for both source and target. This prevents
`pad item related`, the lineage breadcrumb, and dashboard enrichment
from surfacing dangling endpoints when one side has been archived.

Affected queries in internal/store/items.go:
- GetItemLinks  (powers `pad item related`, lineage, dashboard)
- GetItemLink   (singular; fixed for consistency)
- GetParentForItem (breadcrumb / lineage; archived parent reads as none)

Other item_links queries already filtered on deleted_at; export.go
deliberately keeps all rows for backup correctness — left unchanged.

The link rows themselves are preserved on disk, so restoring a
soft-deleted item resurrects its relationships automatically.

Tests:
- TestItemLinks_HidesSoftDeletedEndpoints — delete + restore round-trip
  on both source-side and target-side
- TestGetParentForItem_HidesSoftDeletedParent — parent breadcrumb path

Manually verified: PLAN + TASK with `implements` link, soft-delete the
TASK, `pad item related <PLAN>` correctly returns no implementers.

* fix(store): address Codex review findings on PR #259 (BUG-734)

Three follow-ups from Codex's review of the soft-delete filter on item-link
queries:

1. MEDIUM — GetParentMap now JOINs items on both sides and filters on
   deleted_at IS NULL. handlers_dashboard.go uses this map directly to
   detect orphaned tasks (items not present in the map are flagged), so
   without the filter a task whose parent had been soft-deleted would
   silently fail to appear as orphaned.

2. LOW — Revert the deleted_at filter on getItemLink (lowercase, private).
   Its only caller is the post-insert readback in CreateItemLink, which
   means filtering buys nothing user-facing and introduces a delete-race
   window where a successful INSERT returns nil. SetParentLink's readback
   was switched from GetItemLinks to getItemLink for the same reason.
   User-facing surfaces still go through GetItemLinks (plural) and
   GetParentForItem, both of which retain the filter.

3. LOW — Add an explicit comment in export.go documenting that item_links
   are exported in full (including links to soft-deleted items), and why
   that intentionally diverges from the user-facing query behavior.

Tests: TestGetParentMap_ExcludesSoftDeletedEndpoints exercises the
dashboard regression path on both source-side and target-side soft-delete,
plus the restore round-trip.

* fix(store): reject soft-deleted parent in ListItems UUID parent filter (BUG-734)

Codex review on 288283b flagged that ListItems(parent=<UUID>) at items.go:534
runs an EXISTS subquery against item_links without checking whether the
target parent is soft-deleted. Slug/ref input rejects deleted parents
upstream via GetItem/GetItemBySlug, but raw-UUID input bypasses that path
and would still return active children of an archived parent.

Fix: JOIN items into the EXISTS subquery and require deleted_at IS NULL on
the parent.

Test: TestListItems_ParentFilter_RespectsSoftDeletedParent — covers the
delete + restore round-trip on the parent.

* fix(store): apply parent-filter in FTS path so search+parent enforces deleted-parent rejection (BUG-734)

Codex's 3rd review pass on PR #259 caught that listItemsFTS does not
re-apply ParentLinkID. Combining `parent=<UUID>&search=<q>` therefore
silently dropped the parent constraint — and, by extension, the
deleted-parent rejection introduced earlier in this PR.

Fix: replay the same EXISTS-with-deleted_at-IS-NULL predicate in the FTS
branch. Test: TestListItems_ParentFilter_FTS_RespectsSoftDeletedParent
covers the delete + restore round-trip on the search path.

The wider FTS filter-bypass (Tags, AssignedUserID, AgentRoleID, Fields,
ParentID are all silently dropped when search is set) is pre-existing
behavior outside BUG-734's scope; tracked as BUG-812.
2026-04-27 10:57:24 -04:00
xarmian 96b3f68b5a docs(readme): use pad init as the canonical entry point (#258)
* docs(readme): use pad init as the canonical entry point

The Quick Start and Getting Started sections still walked users
through the deprecated multi-step flow (pad auth configure +
pad workspace init + pad agent install), even though pad init is
a single smart command that orchestrates all six setup steps.

Changes:
- Quick Start: 3 commands -> 2 (brew install + pad init).
- Getting Started: collapsed sections 1-3 ("Configure this client",
  "Initialize a workspace", "Install the AI skill") into a single
  "Set up Pad" section that uses pad init.
- Template examples updated from pad workspace init --template X
  to pad init --template X. --list-templates kept as
  pad workspace init --list-templates (the only command that
  supports it today).
- Tagline ("No accounts.") + architecture summary ("no accounts.")
  -> "No accounts required." Pad supports user accounts with
  email/password auth and workspace invitations; the strict claim
  contradicted later sections.
- Removed Pad Cloud directive in the Docker section -- Cloud is
  not released yet, so the README should not direct users to it.
- Replaced full Docker Compose subsection with a one-line pointer
  to docs/deployment.md. Postgres + Redis is an advanced multi-
  instance path; the README should keep its binary-first focus.
- Aligned the pad github CLI reference columns (3 lines were
  off-spec).

* docs(readme): use pad init in the comparison table too (codex nit)

* docs(readme): reframe Authentication section to point local installs at pad init (codex P2)

* docs(readme): scope pad init bootstrap to local mode (codex P2)
2026-04-26 23:04:47 -04:00
xarmian 29f720c996 docs: add real README screenshots (dashboard + board views) (#257)
The README had two TODO placeholders for screenshots that have been
sitting commented-out since the project started. With the launch
imminent, fill them in.

Captures:
- docs/screenshots/dashboard.png — workspace dashboard with Active
  Work cards, Active Plans (v0.2 — Collaboration with progress),
  collection summaries, recent activity.
- docs/screenshots/board.png — tasks board view, four columns
  (Open / In-Progress / Done / Cancelled) with realistic task cards.
- docs/screenshots/list.png — list view (not currently referenced
  from the README, but kept as part of the reproducible asset set).

Reproducibility:

web/e2e/screenshots.spec.ts is a gated Playwright spec (skipped
unless PAD_SCREENSHOTS=1) that uses the existing e2e fixture
infrastructure to:
1. Spin up a fresh pad binary against a clean data dir.
2. Bootstrap an admin + workspace seeded with the startup template.
3. Add a realistic demo dataset (1 active plan, 7 tasks across
   open/in-progress/done with mixed priorities, 2 ideas).
4. Navigate + capture three views at 1440x900.

To regenerate:

  make build
  cd web && PAD_SCREENSHOTS=1 PAD_E2E_PORT=17801 \\
    npx playwright test screenshots --project=desktop-chromium

Notes:

- Table view (?view=table) was originally in scope but the URL
  parser only accepts list/board today; setting via toggle would
  require localStorage manipulation. Three screenshots already
  cover the README's needs; revisit if/when table view becomes
  URL-reachable.
- Dark/light variants were also in scope but the web UI is dark-
  mode-only at present, so the captures are dark-only.

Refs: TASK-673
2026-04-26 20:12:40 -04:00
xarmian e57da62917 chore: modernize goreleaser config + wire homebrew-tap token (#256)
* chore: wire HOMEBREW_TAP_GITHUB_TOKEN into release pipeline

The brews block in .goreleaser.yaml targets the separate xarmian/homebrew-tap
repo. Without a token override, goreleaser falls back to the workflow's
GITHUB_TOKEN — which is scoped to xarmian/pad only and cannot push to the
tap repo. At first real tag time the brew publish step would fail with a
permission error.

- Add `repository.token` to the brews block, referencing
  `{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}`
- Export `HOMEBREW_TAP_GITHUB_TOKEN` from the workflow `secrets` into the
  goreleaser step env, alongside the existing `GITHUB_TOKEN`
- Comment both edits with the rationale + the fine-grained PAT permissions
  the secret needs (`Contents: write` + `Metadata: read` on the tap repo)

The secret itself is created on the human side (HT-780). Snapshot mode
skips publishing so this isn't testable locally — the gate is HT-782's
v0.0.1-rc.1 dress rehearsal.

Refs: TASK-806, TASK-778 (audit), HT-780 (operator step)

* chore: migrate goreleaser deprecations (archives.formats, dockers_v2, homebrew_casks)

Three v2 deprecations were flagged by `goreleaser check` while wiring the
tap token. Migrating them now (instead of filing tech debt) because we're
already touching the file and these are part of the same release pipeline
that ships at v0.1.0 — no point landing a "wire the token" commit that
still trips deprecation warnings on the very next CI run.

Changes:

- archives: `format: tar.gz` + `format_overrides[].format: zip` →
  `formats: ["tar.gz"]` + `format_overrides[].formats: ["zip"]`
  (single-string is still accepted but the list form is the new spec)

- dockers + docker_manifests → dockers_v2:
  Single block with `images:` + `tags:` + `platforms:` collapses the
  prior per-architecture builds plus separate manifest declarations into
  one declaration. buildx + multi-platform are implicit. Snapshot
  validates: amd64 + arm64 images both build, manifest list assembled,
  binary runs inside the cross-built image.

  goreleaser flags dockers_v2 as "experimental and subject to change" —
  it's the documented forward path for v2 and the project is already
  pinned to `version: "~> v2"`, so we're committed to the roadmap.

- Dockerfile.goreleaser: add `ARG TARGETPLATFORM` and update the COPY to
  `${TARGETPLATFORM}/pad`. dockers_v2 organizes pre-built binaries under
  `linux/amd64/pad`, `linux/arm64/pad` etc; buildx populates
  TARGETPLATFORM per platform during the build.

- brews → homebrew_casks: `directory: Formula` → `directory: Casks`.
  The `brews` keyword is fully phased out in v2.10+; goreleaser's
  homebrew_casks now natively handles pre-compiled binaries (which used
  to require workarounds with the old brews block). End-user UX is
  unchanged: `brew install xarmian/tap/pad` works identically because
  modern Homebrew auto-detects whether a tap entry is a formula or a
  cask. Removed the no-op `test:` stanza that doesn't apply to casks.

Validation: `goreleaser check` clean (zero warnings), `goreleaser
release --snapshot --clean --skip=publish,sign,sbom` builds all six
binaries, six archives, one cask, two cross-platform docker images.
`docker run --rm ghcr.io/xarmian/pad:latest-amd64 --version` returns
the snapshot version as expected.

Refs: TASK-806
2026-04-26 16:38:45 -04:00
xarmian 89a5647543 docs: strip 'Hardening for public deployments' from README (#255)
The OSS package defaults to loopback and is positioned as a local-first,
single-user product. A polished operator checklist for self-hosting beyond
loopback competes directly with the Pad Cloud funnel — the multi-user team
segment we want to convert to hosted.

- Strip the entire 'Hardening for public deployments' section from README
  (network boundary, secrets, authentication, observability, CI gates,
  quick checklist — five subheadings).
- Reword the Docker subsection so single-user-on-LAN / Tailscale / home
  VPN reads as a positive supported path. Multi-user team setups get a
  soft handoff to Pad Cloud.
- Move the npm audit + govulncheck CI guidance to CONTRIBUTING.md as a
  Quality Gates subsection — that material is contributor-facing, not
  user-facing, so it stays.
- docs/deployment.md unchanged — multi-user Postgres + K8s recipes still
  exist there for the determined self-hoster, but unpromoted from the
  README.

No new docs/SELF-HOSTING.md created (initially considered) — would have
competed with the hosted-product positioning.

Refs: TASK-777
2026-04-26 16:13:09 -04:00
xarmian a1bbfabf67 fix: topbar overflow drag/drop and dashboard flicker (IDEA-758) (#254)
Series of regressions found while testing the workspace topbar overflow
menu shipped in IDEA-758 / TASK-759:

- Layout collapse: `.workspace-list` had no `flex: 1`, so
  ResizeObserver fed the shrinking content width back into the
  fitting calc and ratcheted down to "active pill only". Wrap pills,
  trigger, and add button in a centered `.workspace-row` that owns
  `flex: 1`; the row's full width now drives the split.
- Trigger position + menu anchoring: trigger now sits next to the
  last visible pill, and the menu opens directly under the trigger
  via a `position: relative` `.overflow-anchor` wrapper.
- Overflow zone not registering as a drop target: switched from
  `pointer-events: none` / `transform: scale(0)` to
  `visibility: hidden` for the closed state. svelte-dnd-action's
  hit-test uses bounding-rect math (not `elementsFromPoint`), and
  `scale(0)` confuses its transform-undoing on percentage origins.
- Pre-mount the menu DOM on mousedown via `dragArmed` so the dndzone
  is registered before drag starts (mid-drag mount isn't picked up).
- Post-drop snap-back: set `dropCooldown = true` synchronously in
  finalize handlers, before flipping `isDragging`, so the resync
  effect doesn't clobber the post-drag zones before the persist
  microtask runs.
- Click-after-drop navigation: `dropClickGuard` swallows the
  synthetic click that fires on the dragged `<a>` after mouseup,
  preventing `goto()` from firing on every drop.
- Dashboard re-fetch flicker: `workspaceStore.setCurrent`'s
  synchronous `workspaces.find(...)` was leaking a reactive dep on
  `workspaceStore.workspaces` into both the workspace `+layout`
  effect and the dashboard `+page` load effect. Wrap both in
  `untrack(...)` so they only re-run on `wsSlug` change.
- Active-pin reject cleanup: rejection paths now call
  `clearCooldownAfterRejection()` so a stuck `dropCooldown` from
  the source-zone finalize doesn't gate sync effects forever.
- A11y: `aria-expanded` on the trigger now uses a `menuVisible`
  derived (`overflowOpen || isDragging || dragArmed`) so it matches
  the visual open state.
- Replace `CHROME_RESERVATION = 72` magic number with named parts
  derived from the actual CSS box model (= 68, was off by 4).
2026-04-25 19:14:58 -04:00
xarmian e5e2bd7b86 chore: flip CI only-new-issues=false + scope lint policy (TASK-771) (#253)
* chore: gate CI on full lint, scoped to checks we enforce (TASK-771)

Flip golangci-lint-action's only-new-issues from true to false so CI
fails on ANY linter finding, not just findings on PR-changed lines.
This catches lint regressions on the next push instead of letting them
drift into main.

The gate flip is paired with a deliberate scope-down of .golangci.yml:

1. errcheck is disabled. The codebase has 325 pre-existing unchecked-
   error sites where the error is intentionally discarded (best-effort
   logging writes, defensive parses with zero-valued fallbacks, etc.).
   Auditing every site is its own project — bigger than IDEA-732 by
   an order of magnitude. Tracked as a follow-up if/when we want the
   safety net back.

2. staticcheck is restricted to the SA* check family (real-bug
   detectors). The ST*/QF*/S* families are stylistic/quick-fix
   suggestions we don't gate CI on yet — they would have re-flooded
   the lint output with capitalized error strings, De Morgan's law
   simplification suggestions, etc., that aren't bug-finding signals.
   Re-enable selectively if the team wants them.

After scoping, the live linters are: govet, ineffassign, staticcheck
(SA*), unused, gofmt — exactly the set that IDEA-732 cleaned up.

Other changes in this PR:

- Drop pull-requests:read permission. It was only required by the
  golangci-lint-action when only-new-issues=true (the action used it
  to fetch PR diff metadata). Not needed any more.
- Update the Run-golangci-lint comment block to explain the new
  policy and reference the IDEA-732 cleanup PRs (#247/#249/#251/#252).
- Replace the SA4017 //lint:ignore directive in cmd/pad/main.go:4631
  with an inline //nolint:staticcheck — the multi-line //lint:ignore
  block was too far from the if statement for staticcheck's
  proximity rule, so the directive wasn't taking effect.
- Apply gofmt -w on three files where post-deletion blank-line
  artifacts had drifted (cmd/pad/main.go imports, two trailing newline
  fix-ups in handlers_items.go and middleware_ratelimit.go).

Verified:
- `golangci-lint run ./...` reports 0 issues.
- `go build ./...` clean.
- `go vet ./...` clean.
- `go test ./...` all pass.

Parent: PLAN-644.

* chore: address Codex round 1 on PR #253 (TASK-771)

Two LOWs from Codex on the gate-flip PR:

1. //nolint:staticcheck was broader than necessary (suppressed any
   future staticcheck diagnostic on the line) and didn't self-report
   when the underlying false positive gets fixed upstream. Codex
   suggested swapping back to a tightly-placed //lint:ignore SA4017.

   I tried that, but golangci-lint v2's staticcheck integration does
   not honour //lint:ignore the way direct staticcheck does — the
   directive was silently no-op'd via golangci-lint while the same
   directive worked when staticcheck was invoked directly.

   So instead of fighting the linter wrapper, sidestep the false
   positive entirely: rewrite the keepalive check from
   `strings.HasPrefix(line, ":")` to `len(line) > 0 && line[0] == ':'`.
   Same observable behaviour for a single-byte ASCII prefix, no
   suppression directive needed at all, no exposure when staticcheck
   eventually fixes the false positive.

2. The new lint-step comment in ci.yml said main is "clean of
   staticcheck SA*/U1000" — but U1000 is reported by the standalone
   `unused` linter in .golangci.yml, not by staticcheck.checks.
   Tighten the comment to attribute each enforced check correctly.

Verified:
- `golangci-lint run ./...` reports 0 issues
- `go test ./cmd/pad/...` passes (the SSE watch loop is exercised by
  reconcile_test.go and the broader integration tests).
2026-04-25 12:36:05 -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 3c3251f5af chore: drop dead visibility-filter block in dashboard handler (TASK-765) (#251)
* chore: drop dead visibility-filter block in dashboard handler (TASK-765)

internal/server/handlers_dashboard.go had a 10-line block that built a
visibility-filtered `filtered` collections slice and reassigned it back
to `collections` — but `collections` was never read after the
reassignment, so the filtering had no effect. Staticcheck flagged it
as SA4006 (line 220) + SA4010 (line 217) — same dead block, two
findings.

Investigation confirmed this is leftover refactor scaffolding rather
than a missing-filter bug:

- The summary section (line 236) uses `allItems` from
  `ListItems(workspaceID, {CollectionIDs: dashCollIDs, ItemIDs: dashItemIDs})`.
- Active plans, attention items, suggestions all use the same
  `dashCollIDs`/`dashItemIDs` parameters plus inline
  `isCollectionVisible(child.CollectionID, visibleIDs)` checks.

So visibility is already correctly applied to every dashboard output —
through the `dashCollIDs` / `dashItemIDs` path established earlier at
lines 185-190 — not through the deleted block. The previous comment
("drives the collection-summary section") was misleading; the summary
section reads items, not the `collections` slice.

Replace the dead block with an inline note explaining where visibility
*actually* gets applied so the next reader doesn't reach for the
filter pattern again.

Verified:
- `go build ./...` clean
- `go test ./internal/server/...` all pass (Dashboard tests cover this
  path).
- `staticcheck -checks "SA4006,SA4010" ./...` clean

Parent: PLAN-644.

* docs: tighten dashboard visibility comment per Codex round 1 (TASK-765)

Codex review on PR #251 flagged that my replacement comment claimed
dashCollIDs/dashItemIDs is THE filter path for all dashboard outputs,
but for graph-walking outputs (plan progress, blocked attention,
suggested next) final visibility actually comes from a combination of
the ListItems-param filtering and per-item isCollectionVisible /
isItemVisibleToGuest checks.

Tighten the comment to call out both layers so future readers know
the canonical answer is "dashCollIDs/dashItemIDs PLUS per-item
visibility checks", not "dashCollIDs/dashItemIDs alone".

The MEDIUM finding (dashboard visibility tests don't exercise the
filter path) is a pre-existing coverage gap, not a regression from
this PR. Tracked as its own follow-up task under PLAN-644 rather than
expanding the scope of this cleanup. See the PR body for the link.
2026-04-25 12:02:37 -04:00
xarmian bf5ab5b366 chore: clear staticcheck SA + U1000 findings on main (TASK-764) (#249)
* chore: clear cosmetic staticcheck findings (TASK-764)

Apply zero-behavior-change fixes for 8 staticcheck findings on main:

- SA4023 cmd/pad/main.go:431 — drop always-true `if eventBus != nil`
  guard. eventBus is wrapped in metrics.NewInstrumentedBus a few lines
  above, which returns a concrete *InstrumentedBus that is never nil.
- SA1019 cmd/pad/main.go:3926 — replace deprecated strings.Title with
  golang.org/x/text/cases.Title(language.English).String. golang.org/x/text
  was already an indirect dep; now promoted to direct.
- SA4031 internal/server/handlers_changes.go:130 — delete dead
  `if updatedItems == nil { ... }` block. make([]T, n) always returns
  non-nil; the JSON marshalling already produced [] not null.
- SA9003 cmd/pad/init.go:351 — delete empty if branch and fold its
  intent into the surrounding comment.
- SA9003 internal/server/handlers_dashboard.go:125 — replace empty
  `if err == nil { ... }` branch with `_ = json.Unmarshal(...)` to
  match the sibling settings parse and document the best-effort intent.
- SA4006 internal/cli/format.go:153 — drop the dead initial
  `titlePart := item.Title` (overwritten in both branches below);
  declare titlePart with `var` instead.
- SA4006 internal/store/workspaces.go:70 — drop the dead first call
  to s.uniqueSlug; only the workspace-specific uniqueWorkspaceSlug
  is meaningful (workspace slugs are globally unique, not workspace-
  scoped like collection/item slugs).
- SA4000 internal/store/store_test.go:99 — remove always-true outer
  `if idx := len(connStr) - len(connStr); idx >= 0` and unindent the
  inner '?' query-string split.

go.mod side effects from `go mod tidy` under Go 1.26: golang.org/x/text
moves to direct (used directly now); pquerna/otp, prometheus/client_*
and trustelem/zxcvbn move from indirect to direct (they were already
used directly — Go 1.26's tidy correctly classifies them).

Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass (including the replaceDBName test path)
- `staticcheck -checks "SA1019,SA4000,SA4006,SA4023,SA4031,SA9003"` clean
  except for handlers_dashboard.go:221 (SA4006, dashboard visibility-
  filter dead block — handled in TASK-765)

Parent: PLAN-644.

* fix: clear SA5011 nil-deref in buildReconcileFindings (TASK-764)

extractItemStatus(item.Fields) on the first line of the function would
have panicked on a nil item before the `if item != nil && item.CodeContext
== nil` guard could fire. Staticcheck SA5011 flagged the inconsistency.

Drop the (item != nil) half of the guard — the function now documents
its non-nil contract in the doc comment. All callers (reconcile.go:204
plus three sites in cmd/pad/reconcile_test.go) already pass non-nil,
so this is documentation, not behaviour change.

Verified:
- `go build ./...` clean
- `go test ./cmd/pad/...` passes (the existing reconcile tests cover the
  contract)
- `staticcheck -checks SA5011 ./...` clean

Parent: PLAN-644.

* chore: silence SA4017 false positive in watchCmd SSE loop (TASK-764)

cmd/pad/main.go SSE keepalive branch:

    if strings.HasPrefix(line, ":") {
        continue
    }

Staticcheck SA4017 reports "HasPrefix doesn't have side effects and
its return value is ignored" — but the return value IS used as the
if condition. Two sibling strings.HasPrefix calls earlier in the same
for-loop body (matching "event: " and "data: " prefixes) are not
flagged, which strongly suggests an SSA-analysis quirk specific to
this branch rather than a real defect.

Suppress the finding with a //lint:ignore directive that explains
the false positive in-place. Rewriting to a different form (extract
to a bool var, comma-OK on a synthetic value, etc.) would be uglier
than the suppression comment.

Verified:
- `staticcheck -checks SA4017 ./...` clean
- `go build ./...` clean

Parent: PLAN-644.

* chore: delete dead code flagged by U1000 (TASK-764)

Pre-launch (no external contributors yet) — no consumer fork can be
relying on these unreferenced symbols, so we delete them rather than
carry the maintenance burden into v1.

## Helpers (14 functions, 1 type)

cmd/pad/main.go
- progressBar — never called

internal/cli/format.go
- stripHTMLTags — never called

internal/server/handlers_dashboard_test.go
- updateItem (test helper) — never called from any test

internal/server/handlers_items.go
- publishItemEvent — wrapper over publishItemEventWithName; all 5 call
  sites use the *WithName variant directly.
- resolveRelationFields — never called.
- resolveRelationFieldFiltersForWorkspace, resolveRelationFieldFilters,
  relationFilterKeys, resolveRelationFilterValue — closed loop of dead
  helpers (each one only called by another dead one in the family).
- extractStatus — never called (cmd/pad/reconcile.go has its own copy).

internal/server/handlers_versions.go
- handleGetDiff (HTTP handler) — never wired into setupRouter.
- diffsToChanges, diffChange (type) — only used by handleGetDiff above.
- Removes now-unused imports `strconv` and `dmp` (sergi/go-diff).

internal/server/middleware_ratelimit.go
- writeTooManyRequests — never called; the live ratelimit middleware
  uses a dedicated 429 path with Retry-After-Bucket headers.

internal/server/server.go
- guestVisibleItemIDs — never called. handlers_events.go had a
  comment cross-reference; updated to drop the reference.

## Constants

internal/events/redis_bus.go
- reconnectDelay — never read.

internal/store/api_tokens.go
- defaultTokenExpiryDays — never read.

## Out of scope
The 5 unwired handlers in internal/server/handlers_documents.go are
left alone: they are the subject of TASK-769 (a product decision —
wire up vs. delete — that may want different treatment per handler).

The two SA4006/SA4010 findings on internal/server/handlers_dashboard.go
visibility-filter block are similarly left for TASK-765.

## Verified
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass
- `staticcheck -checks "SA*,U1000" ./...` clean except the two TASK-
  765 / TASK-769 follow-ups noted above.

Parent: PLAN-644.

* docs: correct caller name in buildReconcileFindings doc (TASK-764)

Codex round 1 caught: the doc comment named the caller `reconcileSingle`
but the actual function is `reconcileItem` (cmd/pad/reconcile.go:204).
Fix the contract comment so it doesn't go stale on the first git blame.
2026-04-25 11:53:31 -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 f58290272f fix(web): persistent low-opacity expand tabs for hidden sidebar/topbar (TASK-762) (#246)
Implements IDEA-757.

⌘\ toggles BOTH the sidebar and the topbar at once. When they go hidden,
the only on-screen affordances to bring them back are the .topbar-expand-btn
and .sidebar-expand-btn tabs, which were styled `opacity: 0` at idle and
only became visible on `:hover` of the parent container. A user who hits the
shortcut accidentally and stares at a now-mostly-empty screen sees no
affordance at all.

Bump idle opacity to 0.5 on both expand tabs so the affordance is always
faintly visible. Hover amplification to 1 (existing) is unchanged. The
tooltips on the tabs ("Show workspace bar (⌘\)" / "Open sidebar (⌘\)") now
become discoverable, teaching the shortcut on first encounter.

CSS-only change.
2026-04-25 09:57:55 -04:00
xarmian 441f624584 feat(web): mobile navbar workspace switcher always present, preserve sidebar state on switch (TASK-761) (#245)
* feat(web): mobile workspace switcher always present, preserve sidebar state on switch (TASK-761)

Implements IDEA-760.

- web/src/routes/+layout.svelte: replace the mobile-header workspace-name link
  with <WorkspaceSwitcher mobile /> so the switcher is reachable from both
  sidebar states. Add `.mobile-switcher-slot` to flex-fill the gap next to the
  hamburger; drop the now-unused `.mobile-title` rules.
- web/src/lib/components/layout/WorkspaceSwitcher.svelte: drop uiStore.onNavigate()
  from select() so workspace switching no longer collapses the mobile sidebar —
  the user's sidebar state carries over to the new workspace per IDEA-760. Add
  same-workspace dashboard parity (mirrors TopBar.handleWsClick) so tapping the
  current workspace still gives a one-tap path back to the dashboard.

openCreateModal() retains its uiStore.onNavigate() — separate modal-overlay UX.

* fix(web): tighten WorkspaceSwitcher dashboard URL + a11y on switcher trigger

Codex P2 + nit follow-up to TASK-761:

- WorkspaceSwitcher.select(): same-workspace dashboard branch now reads
  owner_username from workspaceStore.current rather than ws.owner_username
  (which is typed optional). When isCurrent is true `current` is non-null and
  shares the slug, so its owner_username is guaranteed present. Avoids the
  edge case where a caller passing a workspace without owner_username would
  produce `//slug` (scheme-relative URL) instead of an in-app path.
- WorkspaceSwitcher trigger: add aria-haspopup="menu", aria-expanded={open},
  and aria-hidden on the chevron glyph so screen readers get the menu
  semantics + open/closed state on the new primary mobile navbar control.

* fix(web): aria-haspopup type matches actual popup (dialog mobile, menu desktop)

Codex follow-up nit on TASK-761: the WorkspaceSwitcher trigger advertised
aria-haspopup="menu" unconditionally, but on mobile the popup is a
role="dialog" BottomSheet and on desktop it's a dropdown of buttons.
Make the hint match the actual surface by deriving from isMobile.

* fix(web): drop aria-haspopup on desktop WorkspaceSwitcher popup

Codex follow-up nit on TASK-761: the desktop popup is a plain dropdown
<div> of buttons without role=menu/menuitem or arrow-key keyboard nav,
so aria-haspopup="menu" overstated the semantics. Mobile keeps
aria-haspopup="dialog" because that branch genuinely renders a
role="dialog" BottomSheet. Desktop falls back to aria-expanded alone,
which is sufficient for "button toggles a popup" without claiming
specific popup type semantics that aren't backed by roles.
2026-04-25 09:57:28 -04:00
xarmian 8346f9348e feat(web): replace desktop navbar scroll with overflow menu (TASK-759) (#244)
* feat(web): replace desktop navbar scroll with overflow menu (TASK-759)

The desktop top bar's workspace list previously used `overflow-x: auto`
with a hidden scrollbar — workspaces past the visible edge were reachable
only by horizontal scroll, with no visual cue that anything was hidden.
Mobile already solved this via BottomSheet (TASK-637); desktop never got
the equivalent.

This change implements a "priority+" overflow pattern in TopBar.svelte:

- Pills are measured in a hidden ghost row keyed by slug.
- A ResizeObserver tracks the visible container's width.
- Pills that don't fit move into a `…` overflow menu anchored under the
  trigger. The active workspace is pinned to the visible row regardless
  of fit position so the "you are here" cue is never hidden.
- The trigger is always rendered (with `visibility: hidden` when empty)
  to prevent layout oscillation as workspaces are added or removed.

Drag-and-drop works to and from the overflow menu on day one. Three
dndzones share `type: 'topbar-workspace'`: the visible row, the menu,
and the trigger as a single-slot drop target. A 400 ms spring-loaded
auto-open lets the user drag onto the trigger and place the dropped
item at a precise position inside the menu. Dropping on the trigger
without waiting appends to overflow. Active is rejected from overflow
finalize and snapped back to visible.

Persistence reuses the existing `api.workspaces.reorder()` path. Both
zones' finalize events are coalesced into a single persist via
queueMicrotask. A 1s `dropCooldown` prevents store→local sync from
fighting the just-written order, mirroring BoardView's pattern.

Mobile (≤640px) is unchanged — still uses WorkspaceSwitcher BottomSheet.

Spec: IDEA-758.

* fix(web): address Codex review round 1 (TASK-759)

Per Codex review on PR #244, round 1:

HIGH — Drop active onto `…` trigger silently dropped active from the
persisted order. handleTriggerFinalize stripped active from droppedSafe
without restoring it to visibleZone, so persistGlobalOrder rebuilt
fullOrder = visibleZone + overflowZone with active missing from both.
Now both rejection paths (overflow zone and trigger zone) reset all
zones from the un-mutated propVisible/propOverflow derived split and
cancel the queued persist via cancelPersist().

MEDIUM — Active-pin rejection in the overflow zone snapped active to
the END of visible instead of restoring its original position. Same fix
as above — reset from the derived split, which preserves sort order.

MEDIUM — Failure rollback was hidden by dropCooldown for ~1s. The catch
block now also clears the cooldown timer, immediately resyncs zones
from the restored derived split, and unblocks the sync effect.

MEDIUM — dropCooldown setTimeouts stacked. Track a single cooldownTimer,
clearTimeout it on each new write, and cancel on rollback.

MEDIUM — A single long active-workspace name could blow past the bar
because active is pinned visible. Cap `.workspace-name` at max-width
200px with ellipsis inside `.workspace-list` and `.workspace-ghost`
(not in the overflow menu — full names read better there).

LOW — Lost the "click current workspace → workspace dashboard"
override during the click-handler refactor. The pre-PR onclick branched
on `ws.slug === currentSlug`. Restored.

LOW — Pending springLoadTimer / cooldownTimer would survive component
destroy. Added an $effect cleanup that cancels both on unmount.

* fix(web): address Codex review round 2 (TASK-759)

HIGH — Active-pin rejection only worked when the target zone's finalize
fired AFTER the source's. svelte-dnd-action does not guarantee the
order, so when handleVisibleFinalize ran AFTER handleOverflow/Trigger
finalize, it overwrote the freshly-restored visibleZone with its own
post-drag items (which excluded active). Added a `dragRejected` flag:
target-zone rejection sets it, handleVisibleFinalize early-returns if
set so the reset isn't clobbered. Cleared at the start of every
consider event so it doesn't bleed across drags.

MEDIUM — Cooldown timer race: a prior persist's pending timer was only
cleared AFTER awaiting the new persist's reorder/load, so it could fire
mid-request and flip dropCooldown false while a newer write was still
in flight. Cleared the prior timer at the start of persistGlobalOrder
(before the await) instead.

* fix(web): address Codex review round 3 (TASK-759)

MEDIUM — persistCancelled could leak past a rejected active-pin drag.
On pointer DnD svelte-dnd-action finalizes the target zone BEFORE the
source. In that order, cancelPersist() runs in the rejection handler
when no microtask was queued (the source's schedulePersist hadn't
fired yet), then handleVisibleFinalize early-returns on dragRejected
without scheduling. The flag was left set, so the next legitimate
reorder was silently dropped.

Fixed by clearing persistCancelled at the start of schedulePersist —
each new schedule begins from a clean slate, regardless of what stale
state a prior rejection may have left.
2026-04-25 01:17:17 -04:00
xarmian 2c59bfa925 fix(web): wire desktop topbar workspace switching through last-route restore (#243)
* fix(web): wire desktop topbar workspace switching through last-route restore (TASK-754 follow-up)

The TASK-754 restore logic only fired from WorkspaceSwitcher.svelte
(used on mobile). On DESKTOP, the workspace switcher is the topbar's
horizontal workspace icon list, which used plain `<a href>` links to
`/{owner}/{slug}` — bypassing restore entirely and silently
overwriting the workspace's saved deep route on every left-click.

Symptom (reported by user): "navigate to a deep page → storage updates
to that page → navigate to another workspace → saved value sticks →
click back via topbar → lands on dashboard, and the saved value gets
overwritten back to dashboard."

Fix:
- Extract the validation+pickup logic into a pure helper at
  `web/src/lib/utils/workspace-route.ts` (`workspaceRestoreTarget`).
- WorkspaceSwitcher.svelte's `select()` now delegates to the helper
  (no behavior change on mobile).
- TopBar.svelte intercepts plain left-click on each workspace `<a>` to
  goto the restore target. `href=` stays pointed at the dashboard so
  modifier-clicks (cmd/ctrl/shift/alt) and middle-click still open a
  fresh dashboard in a new tab.

Other workspace nav surfaces are left alone on purpose:
- Sidebar Dashboard nav item, mobile-header workspace name, and
  /console workspace cards are not "switchers" — semantically they're
  Home/breadcrumb/picker navigation that should always land on the
  dashboard.

Parent: IDEA-753.

* fix(web): clicking current workspace in topbar goes to dashboard

When the user clicks the workspace they're already in, override the
last-route restore and go straight to the dashboard. Gives users a
way back to the workspace home from any nested route. Clicking a
different workspace still restores its last-visited route.
2026-04-24 23:30:23 -04:00
xarmian 1ee3e5d725 feat(web): persist + restore page scroll on collection re-entry (TASK-755) (#241)
* feat(web): persist + restore page scroll on collection re-entry (TASK-755)

Page-level scroll position is now persisted (debounced 200ms) on the
collection list/board/table view, keyed by
'pad-last-scroll-{wsSlug}-{pathname+search}', so the workspace switcher
(TASK-754) brings the user back not just to the same URL but to the
same scroll offset.

Restore semantics:
- Triggers once after data hydrates (loading=false, items present).
- Gate is keyed by pathname (NOT pathname+search), so in-page filter
  toggles via replaceState do not re-restore — that would teleport the
  user away from where they're currently scrolling. Sidebar nav to a
  different collection and back DOES re-restore.
- Top-of-page (scrollY=0) clears the entry to keep storage tidy.
- Two RAFs before scrollTo so layout settles after items render;
  behavior is 'instant' (this is a positional restore, not a UX jump).

Out of scope:
- Board view's internal '.board-view' horizontal scroll and per-column
  '.column-cards' vertical scroll. BoardView would need to expose
  scroll refs; deferred. Page-level vertical scroll still applies and
  covers list and table (the dominant views).

Implements IDEA-753.

Parent: IDEA-753.

* fix(web): scroll save race + restore-gate stuck state per Codex review (round 1)

Round 1 Codex findings (TASK-755):

- HIGH: scheduleScrollSave() captured scrollKey at timer fire time, not
  scroll-event time. If the user scrolled on URL A then changed
  filters/view (replaceState) within the 200ms debounce window, the
  pending timer would write A's scroll-y under B's URL key. SvelteKit's
  auto-scroll-to-top on real navigations could also clobber a stored
  entry by writing y=0 before the restore effect ran.
  Fix: capture `key` and `y` synchronously inside scheduleScrollSave
  before setTimeout, gate saves on `scrollRestoredFor === scrollGateKey`
  (no save until restore has had its window), and clearTimeout the
  pending save in onDestroy so a debounced write can't fire post-unmount.

- MEDIUM: The once-per-pathname gate only advanced when a real restore
  attempt was made (filteredItems.length > 0). Visiting an empty/error
  collection between two visits to A left scrollRestoredFor stuck on
  A's gateKey, so re-entry to A would skip restore.
  Fix: separate $effect that resets scrollRestoredFor whenever
  scrollGateKey changes (CONVE-606 — kept its own clean dep list).

Parent: IDEA-753.

* fix(web): cross-key flush + RAF restore guard per Codex review (round 2)

Round 2 Codex findings (TASK-755):

- LOW: A single shared debounce timer with cross-key cancellation lost
  the user's last position on collection A when they navigated to and
  scrolled on collection B within the 200ms debounce window — the new
  scheduleScrollSave() cleared A's timer to start B's, so A never
  flushed. Note: [collection] param changes reuse the same +page.svelte
  instance, so onDestroy doesn't fire between them.
  Fix: track pending (key, y) explicitly. When scheduleScrollSave is
  called with a key different from the pending one, FLUSH the prior
  pending save before reseating the timer. Same flush also runs from
  onDestroy so the final position survives unmount.

- LOW: The restore effect's queued requestAnimationFrame had no
  still-on-the-same-gate check before calling window.scrollTo. A fast
  follow-up navigation between effect-run and RAF-fire could scroll the
  NEW page to the OLD saved offset (visible jump, even though the save
  gate now prevents persistence).
  Fix: capture expectedGate = scrollGateKey in the closure; verify
  scrollGateKey === expectedGate inside the inner RAF before scrolling.

Parent: IDEA-753.

* fix(web): cancel queued restore RAF on unmount per Codex review (round 3)

Round 3 Codex finding (TASK-755):

- LOW: The expectedGate guard at the inner restore RAF only catches
  same-instance gate changes. Once the component is destroyed (e.g.
  fast cross-route nav), scrollGateKey settles at its last computed
  value inside the closure, so the check passes and window.scrollTo
  fires on the next page.
  Fix: track the RAF id (scrollRestoreRAF) and cancelAnimationFrame on
  onDestroy. Cleared inside the inner RAF too so a successful run
  doesn't leave a stale id around.

Parent: IDEA-753.

* fix(web): include showArchived in scroll key per Codex review (round 4)

Round 4 Codex finding (TASK-755):

- LOW: showArchived changes the fetched dataset but isn't synced to the
  URL — saving a scroll position while archived view was on would later
  be reapplied to the non-archived view, landing the user at an
  unrelated/clamped offset.
  Fix: append '|archived' to scrollKey when showArchived is true so the
  archived and non-archived views maintain separate scroll entries.
  showArchived is not added to scrollGateKey on purpose: toggling
  archive within a page is a filter-like action, and re-restoring on
  every toggle would teleport the user (same rationale as not gating
  on pathname+search).

Parent: IDEA-753.

* fix(web): early gate-mark + RAF re-validate scrollKey per Codex review (round 5)

Round 5 Codex findings (TASK-755):

- LOW: The restore effect bailed on filteredItems.length === 0 BEFORE
  marking scrollRestoredFor. If the user landed on an empty collection
  / over-restrictive filter and items later appeared on the same
  pathname (e.g. user creates an item, or a filter toggle that produces
  items but doesn't change the gate-key), the restore would fire as a
  surprise teleport.
  Fix: set scrollRestoredFor = scrollGateKey BEFORE the empty-items
  short-circuit. Empty-state visits still 'consume' the gate so later
  items don't re-trigger restore.

- LOW: The queued RAF re-checked scrollGateKey but not scrollKey. A
  filter/archive toggle changes scrollKey without changing scrollGateKey
  (filters share the same pathname-only gate), so a queued restore
  could scroll to the previous filter combo's offset on the new view.
  Fix: also re-check scrollKey === expectedKey inside the inner RAF
  before scrollTo.

Parent: IDEA-753.
2026-04-24 22:16:33 -04:00
xarmian b999a7aaee feat(web): restore last-visited route on workspace switch (TASK-754) (#240)
* feat(web): restore last-visited route on workspace switch (TASK-754)

The workspace switcher previously always landed on the dashboard. Now
the workspace +layout writes the current pathname to localStorage on
every navigation (keyed by `pad-last-route-{wsSlug}`), and the
switcher reads that key on click and routes there instead — falling
back to the dashboard on miss, storage error, or any saved path that
doesn't belong to the target workspace (guards username changes,
corrupt entries, cross-workspace bleed).

Storage layer:
- Per CONVE-606, the persistence is its own $effect with a clean
  dependency list (wsSlug + pathname) — combining with the title
  sync above would re-run on async workspace-name resolution.
- Storage failures (private mode, disabled storage) swallowed; the
  feature degrades to the previous dashboard-only behavior.

UX:
- Direct Dashboard navigation (sidebar + mobile header use plain
  `<a href>` to the workspace root) is unaffected — only the
  switcher takes the last-route path.
- Initial page load is unchanged (URL-driven).
- Stale targets (deleted item) take the user to the existing 404
  surface; subsequent navs overwrite the bad entry.

Implements IDEA-753.

Parent: IDEA-753.

* fix(web): persist query string + clear cache on item-fetch error per Codex review (round 1)

Round 1 Codex findings (TASK-754):

- MEDIUM: Storing only `pathname` dropped URL-carried collection state
  (?view, ?sort, ?group-by, filters, ?q). Now persist
  `pathname + search`. Switcher splits on '?' before validating the
  path-portion against the target workspace prefix.

- LOW: A restored route to a since-deleted item became a sticky
  re-entry target — the leaf page renders an inline error and the
  +layout effect re-saves the same dead URL on every visit. Now the
  item-detail catch path clears `pad-last-route-{wsSlug}` so the next
  switcher click falls back to the dashboard. The cache repopulates
  on the user's next nav.

Parent: IDEA-753.

* fix(web): stale-request guard + path canonicalization per Codex review (round 2)

Round 2 Codex findings (TASK-754):

- LOW: The item-page catch path cleared 'pad-last-route-{wsSlug}' with
  no stale-request guard. If the user opened a deleted item then
  navigated away in the same workspace before the fetch rejected, the
  +layout effect would save the new valid route first, then the old
  rejected catch would clobber it. Now we capture (username, wsSlug,
  collSlug, itemSlug) at loadData entry and only clear the cache if
  its current value still points at THAT failed URL. Comparison
  strips ?query / #hash before checking.

- LOW: WorkspaceSwitcher's split-on-'?' prefix check could be bypassed
  by encoded traversal (e.g. /owner/ws/%2e%2e/other?q=1) — passes
  startsWith(fallback + '/') textually but goto() normalizes outside
  the workspace path. Now we canonicalize via URL(saved, origin) and
  require: same origin, workspace prefix on the normalized pathname,
  and no '/..' / '/./' / '//' / percent-encoded chars in the path
  (the app never generates any of those).

Parent: IDEA-753.
2026-04-24 21:39:14 -04:00
xarmian 7e56b20d0c fix(store): eliminate spurious SQLITE_BUSY on concurrent writes (#239)
* fix(store): eliminate spurious SQLITE_BUSY on concurrent writes

`pad item update --comment ...` (and any concurrent write workload)
intermittently failed with `internal error` and a server log line of
`update item: database is locked (5) (SQLITE_BUSY)`. The skill's CLI
reference even documented a workaround — "use a separate `pad item
comment` call rather than --comment on update" — but that just lowered
the contention probability; both call paths hit the same root cause.

Root cause

Go's default `db.Begin()` issues `BEGIN DEFERRED` on SQLite, which
takes only a SHARED lock at BEGIN time. The first INSERT/UPDATE in
the transaction tries to upgrade to a write lock — and SQLite refuses
that upgrade with SQLITE_BUSY *immediately* if any other connection
already holds the write lock. busy_timeout's wait-and-retry behavior
does NOT apply on lock-upgrade because waiting would risk deadlock
between two connections both holding SHARED locks. Net effect: under
even modest write concurrency, transactions fail in milliseconds
instead of waiting out the 5-second busy_timeout we configured.

Repro before the fix: 20 concurrent CreateItem calls produced ~4
SQLITE_BUSY errors. Under the running server, two PATCHes within a
few ms of each other (e.g. status update + activity-log write) hit
this regularly during workflow tooling like /ship-tasks.

Fix

Set `_txlock=immediate` in the DSN. Every `db.Begin()` now issues
`BEGIN IMMEDIATE`, acquiring the write lock up-front. Lock-acquisition
DOES honor busy_timeout, so concurrent writers wait up to 5 seconds
to serialize cleanly instead of failing fast. Reads are unaffected:
single-statement SELECTs don't open a transaction at the SQL layer.

Also fold `foreign_keys=on` into the DSN's `_pragma` list. FK
enforcement is per-connection in SQLite, so the previous
`db.Exec("PRAGMA foreign_keys=ON")` only configured the one
connection that received the call — every OTHER pool member ran
without FK enforcement. The DSN form applies it to every connection
the driver opens.

`journal_mode=WAL` stays as a `db.Exec` call because WAL is a
database-level setting recorded in the file header; it persists
across connections after the first one applies it.

Validation

- Reproduced the failure under the live binary: 20 concurrent PATCHes
  in a tight loop produced 4 SQLITE_BUSY errors. After this change,
  same workload: 0 errors.
- New regression test `TestSQLiteConcurrentWritersNoBusy` does 20
  concurrent CreateItem calls and asserts zero errors. Skipped under
  PAD_TEST_POSTGRES_URL (postgres has different concurrency model).
- Existing `TestConcurrentWritePerformance` benchmark now reports 0
  errors at every concurrency level it tests (1, 5, 10, 25, 50
  workers). Previously this benchmark was acknowledging non-zero
  errors at high concurrency as expected.
- Full test suite green: go test ./... — all 14 packages pass.

* fix(store): document IMMEDIATE tradeoff + tighten regression test (Codex round 1)

Address all three findings from Codex review of #239:

MEDIUM — IMMEDIATE widens the writer critical section because update
flows now hold the write lock during diff/version-throttle reads and
slug-collision checks, not just the final UPDATE. Document this
tradeoff explicitly in the DSN comment block: the pre-fix behaviour
was "fail fast with BUSY" and the post-fix behaviour is "wait briefly
for cleanly serialized work" — strictly better. If a future hot path
produces pathologically long write transactions (>100ms holding the
lock), the right move is to narrow that specific transaction, not to
revert this fix.

LOW — Foreign-key enforcement was previously per-connection, applied
to only one pool member. Latent integrity violations in databases
written through other pool members (with FKs disabled) may now
surface as errors on the next write. Document the rollout note and
mention `PRAGMA foreign_key_check` as the diagnostic.

LOW — Tighten TestSQLiteConcurrentWritersNoBusy: the original 20×1
form gave goroutines no synchronization, so a slow CI runner could
sequentialize the work and let a regression slip through. New form
uses an explicit start gate (sync.WaitGroup acting as a barrier) so
all goroutines try to write at the same moment, plus 25 workers ×
5 ops each (125 total) so each goroutine produces several BEGIN/
COMMIT cycles. Still passes; significantly less prone to false
negatives on CI.

No code-path change beyond test tightening; the comment additions
are docstring-only.

* fix(store): use true barrier in concurrency test (Codex round 2)

Codex round 2 caught that the previous start-gate pattern wasn't a
real barrier:

    var startGate sync.WaitGroup
    startGate.Add(1)
    for ... { go func() { startGate.Wait(); ... }() }
    startGate.Done()  // <-- fires before all goroutines reach Wait()

`startGate.Done()` runs as soon as the launch loop finishes, with no
guarantee the scheduler has actually run the goroutines yet. Late-
scheduled goroutines reach `startGate.Wait()` after Done() has already
fired and proceed without ever parking — so on a slow CI runner with
goroutine startup spread across tens of milliseconds, the contention
window we wanted to create simply doesn't exist, and a regressed
deferred-transaction build could quietly pass.

Switch to the standard two-WaitGroup barrier: every worker signals
"ready" via `ready.Done()` and parks on `release.Wait()`, the main
thread `ready.Wait()`s for all workers to confirm they're parked,
then `release.Done()`s to fire them all simultaneously. This
guarantees every goroutine reaches BEGIN IMMEDIATE inside the same
narrow contention window regardless of scheduler latency.

Confirmed: `go test -count=20 -run TestSQLiteConcurrentWritersNoBusy
./internal/store` — all 20 invocations green.

* docs(store): be honest about barrier imprecision + add empirical proof (Codex round 2)

Codex round 2 noted the two-WaitGroup pattern still has a small
unobservable gap between ready.Done() and release.Wait() in each
worker. That's technically correct — the barrier isn't mathematically
exact, and a worker descheduled in that gap could miss the simultaneous
release. The previous comment overstated the guarantee by calling it
a "TRUE barrier".

Soften the comment to acknowledge the gap honestly, AND back the test
with empirical proof: with `_txlock=immediate` removed from the DSN
this test reliably FAILS (22/125 errors per run, all SQLITE_BUSY).
With the fix in place, 20 consecutive `go test -count=20` invocations
all pass. So the small theoretical imprecision in the barrier doesn't
impair the test's regression-catching ability — the multiple-ops-per-
worker structure means even slightly-late workers still produce enough
concurrent BEGIN IMMEDIATE attempts to exercise the race.

Documentation-only commit. No code change.

* docs(store): comment-consistency cleanups (Codex round 3)

Two LOW findings, both pure doc:

1. Inline comment on `ready.Wait()` was still asserting "every worker
   is parked on release.Wait()", contradicting the softened block
   comment above. Change to "every worker has called ready.Done()
   (best-effort gate)".

2. Block comment hardcoded "22 errors out of 125 ops per run" as
   though it were a standing expectation. The exact rate is host-
   and scheduler-dependent; reword as a representative observation
   ("a representative run on a developer laptop produced ~22 errors
   ...; the exact rate is host- and scheduler-dependent but
   consistently >0").

No code change.
2026-04-24 20:30:32 -04:00
xarmian fe4ff887a0 fix(web): truncate long parent titles on item cards (BUG-630) (#238)
`item.parent_title` is populated by `enrichItemForResponse()` (via
`GetParentForItem()`) for both `parent` AND `implements` link types
— see `childLinkTypes` in `internal/store/items.go:17`. So when a task
implements an idea (a common pattern via the Implements relationship),
the idea's title becomes the task's `parent_title` and renders in the
`.meta-parent` chip on the item card.

That chip had `white-space: nowrap` and no width cap, so a long idea
title (e.g. an idea recorded as a full sentence — "we should add a
'pad info' cli command that provides information about the local
instance" is 89 chars) pushed the card past its column bounds on
Board view.

Fix:
- `.meta-parent`: add `overflow: hidden; text-overflow: ellipsis;
  max-width: 100%; min-width: 0;` alongside the existing `nowrap`,
  so the chip truncates with an ellipsis at the card-content edge.
- `.card-meta`: add `min-width: 0` so flex children with intrinsic
  content wider than the card can actually shrink instead of forcing
  the parent to grow.
- Template: bind a single `parentLabel` `@const` and pass it through
  to a `title={parentLabel}` attribute on the chip so the full label
  is still accessible via hover tooltip after truncation.

Affects both Board and List views (ItemCard is shared); the original
report focused on Board where columns are narrowest.

Verified manually on the running server with the known offending
item (`add-pad-server-info-for-local-and-remote-connection-status`
in docapp/tasks, parent IDEA-322, 89-char title): card now stays
within its column on Board view, chip truncates with ellipsis,
tooltip shows full text on hover.

Verified: web/npm run build clean, go test ./... green.
2026-04-24 19:53:18 -04:00
xarmian fd0ace48ff fix(web): long-press delay on mobile status-header drag (BUG-641) (#237)
ListView's outer dndzone for status groups was missing `delayTouchStart`,
so any touch on a group header was immediately interpreted as the start
of a group-reorder drag. On mobile this meant trying to scroll the page
by touching a header instead grabbed the header and dragged it with the
finger — the page wouldn't scroll and the user couldn't reach content
below the visible status bands.

Mirror the inner item dndzone's `delayTouchStart: touchDragDelayMs`
(500ms) on the outer group dndzone so the same long-press gesture is
required to start a group reorder. Quick taps (collapse toggle) and
short touch-drags (page scroll) now pass through unmolested; the
existing drag-to-reorder behaviour is preserved behind the long-press,
matching what already works for items inside a group.

The `touchDragDelayMs` constant (line 46) was already in scope and
already used for the inner dndzone, so this is a one-line addition.

Verified manually on iOS at the running server: status headers no
longer hijack scroll; long-press still reorders groups; tap-to-collapse
unaffected.

Verified: web/npm run build clean, go test ./... green.
2026-04-24 19:38:31 -04:00
xarmian b165e5fe7a fix(server): summarise structured field changes in activity feed (BUG-748) (#236)
* fix(server): summarise structured field changes in activity feed (BUG-748)

The activity-feed `metadata.changes` string is built by `diffFields()` in
`handlers_documents.go`, which used `fmt.Sprintf("%v", val)` to stringify
each old/new value. For structured fields (implementation_notes,
decision_log, or any other slice/map value in item.fields) Go's default
formatting dumps the raw map repr — e.g.
  implementation_notes: → [map[created_at:2026-04-23T... details:Code audit
  on 2026-04-23 found Phases 1, 2, and most of Phase 3 already implemented:
  - **Phase 1a** ... created_by:user summary:Phases 1-3 verified shipped]]

Activity cards on the item detail page surfaced this verbatim, leaking
internal field shape into the UI.

Replace the bare `%v` with a `formatChangeValue` helper:

  - Primitives (string/number/bool): unchanged Go default formatting.
  - Slices: counted summary. Known fields get domain-specific phrasing
    (`(1 note)` / `(N notes)` for implementation_notes, `(1 entry)` /
    `(N entries)` for decision_log); unknown fields fall back to
    `(N items)`.
  - Maps/objects: `(object)` placeholder.
  - nil: empty string.

This is a backend-only change. The frontend `TimelineActivityCard.svelte`
keeps splitting on `→` exactly as before, so the contract is unchanged
beyond the value-formatting.

Companion to PR #235 (frontend `.prose` class fix on TimelineCommentCard).
Together they close BUG-748 — markdown content was unrenderable both
in plain timeline comments AND in activity-feed change pills that
referenced structured field updates.

Tests: 9 new cases in handlers_documents_test.go covering primitives,
added/removed fields, implementation_notes single + plural, decision_log
single + plural, generic slice fallback, object fallback, invalid JSON,
and nil safety. All green.

Verified: go build ./..., go vet ./..., go test ./..., web/npm run build
all clean.

* fix(server): compare values, not display strings, in diffFields (Codex round 1)

Codex flagged two MEDIUM regressions in PR #236 round 1:

1. Object-valued fields (e.g. `convention`, `github_pr`) all stringify to
   the same `(object)` label, so an in-place edit produced
   oldStr == newStr == "(object)" and `diffFields()` silently dropped the
   change from `metadata.changes` — the activity card stopped recording
   that the field had been edited.

2. Same problem for slice fields when length is unchanged: replacing one
   `implementation_note` with a different one (`{"summary":"original"}`
   → `{"summary":"revised"}`) collapsed both sides to `(1 note)` and
   the change vanished from the activity log.

Switch the equality check from string-on-display to `reflect.DeepEqual`
on the raw decoded values. The display strings still go through
`formatChangeValue()` so the activity card stays clean (`(1 note) → (1
note)` for a same-cardinality replacement is coarse but correct — the
user knows something changed and can drill in via the timeline). For
truly identical values the entry is omitted, so no false positives.

`reflect.DeepEqual` is correct for the types `json.Unmarshal` into
`map[string]any` produces: nil, bool, float64, string, []any,
map[string]any.

New tests:
- TestDiffFieldsSameCardinalityArrayChangeStillReported
- TestDiffFieldsObjectMutationStillReported
Each also asserts the no-op case (identical input on both sides emits
nothing).

Verified: go build ./..., go vet ./..., go test -count=1 ./... all green.
2026-04-24 19:13:03 -04:00
xarmian 190d589afe fix(web): render markdown in timeline comments via .prose class (BUG-748) (#235)
* fix(web): render markdown in timeline comments via .prose class (BUG-748)

TimelineCommentCard tagged comment + reply bodies with `markdown-body`,
a class with no rules anywhere in the codebase. The global
`* { margin: 0; padding: 0 }` reset in app.css then stripped list
padding, heading margins, code-block backgrounds, blockquote borders,
and table styling — so any comment containing markdown (bullet lists,
headings, fenced code, quotes) rendered as run-on text without its
visual structure.

Switch both bodies to the existing `.prose` class (same one used by
the item-detail content view), and override `max-width: none` in the
scoped style so comments still fill the timeline column instead of
shrinking to the 960px content width that .prose pins for long-form
item bodies.

Comments are sanitized through DOMPurify in renderMarkdown (TASK-647);
this change is purely styling.

Verified: web/npm run build clean, go test ./... green.

* fix(web): explicit font-family + table overflow on comment-body (Codex round 1)

Address two LOW findings from Codex review of #235:

1. `.prose` pins `font-family: var(--font-content)`. The scoped
   `.comment-body, .reply-body` rule didn't override font-family, so
   comments inherited the .prose font. Currently identical to --font-ui,
   but make the relationship explicit (`font-family: inherit`) so a
   future divergence between --font-ui and --font-content doesn't
   silently change comment typography.

2. `.prose table { width: 100% }` plus padded cells can produce a wider-
   than-column table inside the indented `.reply-card` (which sits
   inside `.replies` with an extra padding-left + border-left, so its
   inner width is significantly narrower than a top-level comment).
   Add `overflow-x: auto` to .comment-body/.reply-body so wide tables
   scroll horizontally instead of overflowing the card.

Verified: web/npm run build clean, go test ./... green.
2026-04-24 18:25:24 -04:00
xarmian 7478d013cb feat(auth): surface ?error= and ?linked= on login + settings (TASK-741) (#234)
* feat(auth): surface ?error= and ?linked= on login + settings (TASK-741)

Before this change, pad-cloud's OAuth redirects with ?error=... and
?linked=... query params were silently ignored. A user who unlinked
GitHub and then hit "Sign in with GitHub" would land on a clean form
with no explanation of why their OAuth didn't work — classic silent
failure.

### Login page (/login)

- readOAuthErrorFromQuery() parses ?error= and the optional ?provider=
  hint on mount.
- Five recognised codes map to actionable banners:
  * oauth_provider_not_linked — the core recovery path: "That
    <Provider> account isn't linked to a Pad account. Sign in with
    your password below, or retry with a different account." with
    "Use a different GitHub account" / "Use a different Google
    account" CTAs wired to /auth/{github,google}?force=1 (shipped in
    pad-cloud PR #21). If ?provider is not present, both CTAs render
    so the user picks.
  * oauth_failed — generic retry prompt.
  * no_email — "verify your email with the provider" guidance.
  * too_many_attempts — rate-limit language (no client-side Retry-After
    countdown; the pad-cloud redirect doesn't carry that info).
  * account_disabled — "contact an administrator", no retry.
- Unknown codes fall back to a safe generic message so a future code
  never breaks the page.
- After rendering, ?error / ?provider stripped via
  history.replaceState so refresh / back-button doesn't re-show.
- Dismiss button on the banner for users who want to clear it
  before retrying.

### Settings page (/console/settings)

- readOAuthQueryStatus() on mount handles the three link-flow error
  codes and the two success flags:
  * ?linked=github / ?linked=google → providerMsg success toast
  * ?error=not_logged_in → session-expired guidance
  * ?error=email_mismatch → identity-mismatch fix-up
  * ?error=link_failed → generic retry prompt
  * Unknown → generic fallback
- Same history.replaceState cleanup.

### Why this is a beta blocker

A legit user who unlinks a provider can become silently un-loginable
with no UI path back. Shipping PLAN-645 to beta operators without
this makes every provider-unlink a support ticket.

Parent: PLAN-645. Depends on TASK-742 (?force=1, already merged) for
the "Use a different account" CTAs to actually work. Optional
?provider= hint will be a small pad-cloud follow-up (handler today
emits ?error= only).

* fix(settings): make provider msg/error live regions for screen readers (Codex round 1)

Addresses PR #234 Codex MEDIUM. The settings page's provider-section
banners (providerMsg / providerError) were plain <p> elements, so the
readOAuthQueryStatus() result on mount was silent to screen-reader
users — unlike the login page's oauth-banner which already had
role/aria-live. Added role='status' + aria-live='polite' to the
success element and role='alert' + aria-live='assertive' to the
error element so both get announced on mount and on subsequent
unlink/link form actions.
2026-04-24 14:53:10 -04:00
xarmian 3f58e0badc feat(billing): plan comparison matrix on /console/billing (TASK-712) (#233)
* feat(billing): plan comparison matrix on /console/billing (TASK-712)

Replaces the single-column Usage section with a side-by-side Free vs
Pro comparison table. Before: users saw their own plan's limits but
had no visible reason to upgrade — the Upgrade CTA linked to checkout
without any explanation of what Pro actually changes. Now: every
field from PlanLimits is rendered for both tiers in one table, the
current plan's column is highlighted, and a secondary Upgrade CTA
lives directly beneath the comparison for Free users.

Changes on /console/billing:

- PlanLimits interface extended with webhooks + automated_backups so
  the UI renders every field the server advertises (DefaultFreeLimits
  and DefaultProLimits in internal/store/limits.go both expose them).
- New formatBytes helper — renders storage_bytes in the natural unit
  (500 MB for Free, 10 GB for Pro) rather than raw byte counts.
- New formatCompareCell helper — 0 → "—" (reads as "not included" for
  Webhooks / Automated backups on the Free tier); -1 → "Unlimited";
  undefined → "…" while limits are loading; storage → formatBytes;
  anything else → locale-formatted integer.
- Comparison table component: scoped <th> headers for accessibility,
  a "Current" tag next to the user's plan column, subtle accent-blue
  wash on every cell in the current plan's column. Rows driven by a
  static COMPARE_ROWS array keyed on LimitKey so TypeScript enforces
  that every column references a real PlanLimits field.
- Mobile-friendly padding at the 480px breakpoint.

Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 4 —
the last bullet of the umbrella. TASK-712 can close after this lands.

* fix(billing): match server's negative→unlimited, don't collapse 0, fix formatBytes boundary + badge contrast (Codex round 1)

Addresses PR #233 round 1 findings:

1. formatCompareCell collapsed every 0 to "—" to read as "not included".
   Admin-configured plan limits are arbitrary integers (see
   /console/admin/settings), so a legitimate zero — "storage_bytes = 0",
   "workspaces = 0", "api_tokens = 0" — misrepresented as a placeholder.
   Removed the 0-case; zero now renders as the literal "0". "—" readability
   on the Free tier's 0-valued webhooks/automated_backups is a small loss
   compared to the correctness win.

2. formatCompareCell only treated exactly -1 as "Unlimited", but
   internal/store/limits.go enforces ANY negative value as unlimited
   (checkLimit returns Allowed=true for limit < 0). A stored -2 would
   behave unlimited server-side while the billing table showed "-2 B".
   Changed the check to "value < 0" to match server semantics.

3. formatBytes rounded at each unit tier, so values just below a unit
   boundary (1,048,575 bytes → "1024 KB", 1,073,741,823 → "1024 MB")
   overflowed the displayed value. Rewrote to use "bump" thresholds
   (bumpMB = MB - KB/2, bumpGB = GB - MB/2): a value that would
   round-display as 1024 of the smaller unit is instead shown as "1.0"
   of the next unit. Extracted the value/unit rendering into
   formatUnit() so the tier thresholds stay readable.

4. .current-tag on the comparison table header used accent-blue text on
   an 18%-alpha accent-blue wash, landing around 3.5-3.9:1 in either
   theme — below the 4.5:1 target for 0.7rem text. Switched to solid
   accent-blue background with #fff text, which stays comfortably above
   4.5:1 across both themes.
2026-04-24 14:24:05 -04:00