Commit Graph

400 Commits

Author SHA1 Message Date
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
xarmian 119e2d8aa2 feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) (#232)
* feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2)

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

Changes:

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

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

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

- ActionPaymentFailedEmailSent audit constant for the new entry.

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

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

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

Addresses PR #232 round 1 findings:

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

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

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

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

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

Addresses PR #232 round 2 findings:

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

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

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

Addresses PR #232 round 3 LOWs:

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

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

The backend emits payment_failed_email_sent and the custom formatter
knows how to render it, but the audit-log page's ACTION_TYPES /
ACTION_LABELS registry omitted the action, so admins couldn't filter
for these events from the dropdown — undercutting the dunning
reconciliation workflow this PR is adding. Added 'payment_failed_email_sent'
to the ACTION_TYPES list and 'Payment Failed Email' to ACTION_LABELS.
2026-04-24 01:50:08 -04:00
xarmian 69e0b2017a feat(billing): confirm-upgrade polling on /console/billing (TASK-712) (#231)
* feat(billing): confirm-upgrade polling on /console/billing (TASK-712)

Stripe Checkout redirects back to /console/billing?checkout=success the
moment the user finishes paying, but pad-cloud's checkout.session.completed
webhook is asynchronous — it needs a beat to land, authenticate against
pad's /admin/plan endpoint, and flip the user's plan to "pro". Before this
change, the returning user saw the Free plan with the "Upgrade to Pro"
button and had to refresh manually before the app caught up.

Changes on /console/billing:

- Detects ?checkout=success on mount. Runs a single fresh authStore.load()
  first — if the webhook is already in, skip straight to the confirmed
  state. Otherwise start polling authStore.load() every 2s for up to 30s.
- Four states: idle (default), checking (spinner + "Confirming your
  upgrade…"), confirmed (green check + "welcome to Pro!"), timeout
  (yellow, payment went through + support contact).
- On confirm, clears the ?checkout=success query via history.replaceState
  so a page reload does not re-enter the polling branch.
- onDestroy stops the interval — no dangling timers after navigation.
- Reduced-motion users see a static spinner frame per prefers-reduced-motion.
- Banner has role="status" aria-live="polite" so screen readers announce
  state changes.

Reuses authStore's existing inflight-coalescing + generation guard (shipped
with PR #229), so concurrent polls share a single /auth/session fetch and
a post-logout navigation cannot resurrect a stale plan value.

Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 2.
Bullet 3 (failed-payment email) ships next; bullet 4 (plan matrix) later.

* fix(billing): destroyed guard, parallel tasks, plan-reconcile banner (Codex round 1)

Addresses PR #231 review findings:

HIGH — onMount's awaits could race with onDestroy: a late authStore.load()
or plan-limits fetch finishing after the user navigated away would still
mutate upgradeStatus/limits, and startUpgradeConfirmation could even
install a setInterval on a destroyed component. Added a 'destroyed' flag
set in onDestroy and checked after every await; stopPolling also runs on
teardown and inside pollForUpgrade's post-await guard for belt-and-
braces.

MEDIUM — startUpgradeConfirmation was sequenced behind the plan-limits
fetch. A slow /plan-limits request would delay the 'checking' banner and
the first authStore.load() refresh, defeating the purpose of the PR.
Split them: onMount is now synchronous, kicks off startUpgradeConfirmation
and loadPlanLimits in parallel as fire-and-forget promises, each with
its own destroyed-guarded error handling.

LOW — upgradeStatus latched 'confirmed' independently of the current plan
value. If plan transitioned away from 'pro' for any reason after the
banner appeared, it would stay stuck showing the success message. Render
the confirmed banner only while upgradeStatus === 'confirmed' AND isPro
so the banner fades out automatically if the plan reconciles down.
2026-04-24 00:52:02 -04:00
xarmian cf00eeba84 feat(web): add Support + Status links on auth pages and user menu (TASK-713) (#230)
Implements TASK-713 bullets 1+2 for Pad Cloud: a support@getpad.dev
mailto and a https://status.getpad.dev link users can reach before
signing in and from inside the app. Discord link deferred — spawn an
HT follow-up once the server URL is known.

Changes:
- New SupportFooter.svelte — Support · Status row, gated on cloudMode,
  styled consistently with LegalFooter (underlined, focus-visible
  outline). Rendered below LegalFooter on login, register, and
  forgot-password.
- TopBar user dropdown (desktop + mobile branches): Support and Status
  entries between the theme toggle and Sign out, grouped by a divider,
  gated on cloudMode so self-hosted installs do not advertise links
  that are not theirs to offer.

Bullets 3+4 of TASK-713 (admin impersonation, MRR/churn dashboard)
remain out of scope for Chunk 1 per the PLAN-645 audit decision; they
will be tracked in a follow-up plan after beta launch.

Parent: PLAN-645 (Pad Cloud Beta Readiness). Second PR in Chunk 1.
2026-04-24 00:09:03 -04:00
xarmian cf3e64caf4 feat(web): add legal footer + consent notice on auth pages (TASK-714) (#229)
* feat(web): add legal footer + consent notice on auth pages (TASK-714)

Pad Cloud (cloudMode=true) needs visible Terms / Privacy / Sub-processors
links so Stripe Live-mode compliance is defensible and users know what
they're agreeing to. Self-hosted installs (cloudMode=false) don't need
these — the legal docs at getpad.dev are Perpetual Software LLC's TOS
for the hosted service, not the user's own instance.

Changes:
- New LegalFooter.svelte component: renders Terms · Privacy · Sub-processors
  links to https://getpad.dev/{terms,privacy,subprocessors}. Gated on a
  cloudMode prop so self-hosted installs see nothing.
- login, register, forgot-password pages: render <LegalFooter> below the
  card. Each page already fetches session; register/forgot-password now
  persist session.cloud_mode into a local $state so the footer can be
  reused consistently.
- register page adds a "By creating an account, you agree to Terms and
  Privacy Policy" consent notice directly under the Create account button
  (only when cloudMode=true). This is the signup touchpoint for
  Stripe Live mode.
- Auth-page containers switched to flex-direction: column so the card
  and footer stack cleanly centered.

Cookie banner intentionally deferred: the privacy policy already asserts
"strictly-necessary cookies only, no banner required" and app.getpad.dev
only sets first-party session/CSRF cookies. Stripe Checkout runs on
billing.stripe.com (separate origin) so its cookies don't apply here.

Parent: PLAN-645 (Pad Cloud Beta Readiness). This covers the main
launch-blocking legal bullet (Stripe Live mode compliance); the pad-web
marketing site already hosts the actual legal content.

* fix(web): read cloudMode from authStore; add link affordance (Codex round 1)

Addresses PR #229 review findings:

MEDIUM — register and forgot-password pages were fetching session via
api.auth.session() specifically to derive cloudMode, duplicating the
root layout's authStore.load() and adding a silent failure path (if the
extra request failed, the legal footer/consent would vanish even on
Pad Cloud). Switched to authStore.cloudMode in both pages. register
still calls api.auth.session() for its pre-existing setup_required +
authenticated checks, but no longer reads cloud_mode from that call.
forgot-password no longer needs onMount at all — its only addition
was the cloudMode fetch.

LOW — legal footer links had no affordance until hover: muted color,
no underline, nothing for keyboard users. Added persistent subtle
underline (1px, 2px offset) and :focus-visible outline so the links
are discoverable for touch and keyboard users.

Also: login page intentionally left untouched. It has a pre-existing
local cloudMode state used for OAuth buttons + sign-up link + legal
footer; unifying it with authStore is a separate refactor and outside
this PR's scope.

* fix(auth): add authStore.ensureLoaded for post-logout auth nav (Codex round 2)

Addresses PR #229 round 2 finding:

MEDIUM — After logout, authStore.session is cleared and the root layout
doesn't re-run onMount on SPA navigation, so subsequent visits to
/register or /forgot-password would read authStore.cloudMode=false and
silently hide the legal footer/consent on Pad Cloud.

Fix: add authStore.ensureLoaded() which returns the cached session when
present or fetches it otherwise. register's onMount now routes its
pre-existing session fetch through ensureLoaded (so the same call
populates authStore for downstream components like LegalFooter).
forgot-password calls ensureLoaded() on mount — cheap no-op when the
store is already populated, one fetch when it's been cleared.

This keeps the single-source-of-truth benefit from round 1 while
handling the logout-then-navigate case Codex flagged.

* fix(auth): coalesce concurrent session loads (Codex round 3)

Addresses PR #229 round 3 finding:

MEDIUM — authStore.ensureLoaded() did a bare 'if (session)' check, so a
hard page load that fired both the root layout's authStore.load() and
the page's ensureLoaded() could issue two /auth/session requests. If
one succeeded and the other failed, the later catch path (session =
null) would overwrite the good session, leaving cloudMode=false after
a successful fetch.

Fix: add an inflight Promise in auth.svelte.ts. load() returns the
inflight promise when one exists; the then/catch/finally chain runs
exactly once per fetch and clears inflight in finally. ensureLoaded()
keeps its cached-session short-circuit and otherwise delegates to
load(), so concurrent callers all await the same underlying request.

* fix(auth): guard stale session fetches with generation counter (Codex round 4)

Addresses PR #229 round 4 findings:

MEDIUM — clear() did not invalidate a pending inflight load, so a
pre-logout /auth/session call could resolve after logout and resurrect
the logged-out user's session. Subsequent ensureLoaded() calls would
also attach to that stale promise.

LOW — inflight was only cleared in the promise's finally, so a
permanently-hanging fetch wedged loading=true and every subsequent
load()/ensureLoaded() returned the same never-settling promise.

Fix: introduce a 'generation' counter that bumps on clear(). load()
captures the current generation at fetch start and only writes session
/ clears loading / clears inflight when the generation is still
current. clear() now also drops the inflight reference and resets
loading=false, so the next ensureLoaded() fires a fresh request and
the UI is not left hanging. Late callbacks from pre-logout fetches
still resolve in the background but cannot mutate authStore state.
2026-04-23 23:33:42 -04:00
xarmian 775dd89fdc feat(cloud): add /admin/stripe-event-unmark endpoint (TASK-736 / 1 of 2) (#228)
* feat(cloud): add /admin/stripe-event-unmark endpoint (TASK-736 / 1 of 2)

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

* fix(cloud): add processed_at race protection + audit log per Codex review (round 1)
2026-04-23 20:26:02 -04:00
xarmian 6cda2da48d feat(billing): cancel Stripe customer on account delete (TASK-690) (#227)
* feat(billing): cancel Stripe customer on account delete (TASK-690)

Parent: PLAN-645. Pair with pad-cloud PR #12.

* fix(billing): abort on all non-200 per Codex review (round 1)

* fix(billing): env wiring + docstrings + partial_delete test per Codex review (round 2)

* fix(compose): wire cloud env vars from .env per Codex review (round 3)
2026-04-23 19:35:12 -04:00
xarmian 0cbadf873b feat(server): durable Stripe webhook idempotency endpoint (TASK-696) (#226)
Adds a new cloud-gated admin endpoint that the pad-cloud sidecar uses
to record-or-detect-duplicate Stripe webhook events. Previously the
sidecar tracked processed event IDs in an in-memory map, which lost
state on restart and caused Stripe's 72h retries to re-run handlers.

Changes:

  migrations/045 + pgmigrations/025
    New stripe_processed_events(event_id PK, processed_at) table +
    index on processed_at for the pruning query.

  store/stripe_events.go
    MarkStripeEventProcessed(eventID) — INSERT ... ON CONFLICT DO
    NOTHING; returns alreadyProcessed from RowsAffected. Atomic.
    PruneStripeProcessedEvents(maxAge) — DELETE WHERE processed_at < ?.
    ShouldPruneStripeEvents() — ~1% random sample via crypto/rand.

  server/handlers_cloud.go
    handleStripeEventProcessed — POST /api/v1/admin/stripe-event-processed.
    Validates cloud_secret, requires event_id with 'evt_' prefix,
    returns {event_id, already_processed}. Opportunistically fires
    a background prune ~1% of calls (7-day retention window covers
    Stripe's 72h retry with a safe margin).

    Adds the new path to cloudAdminPaths so the secret-marker gate
    accepts X-Cloud-Secret / body-secret auth here too.

  server/server.go
    Registers POST /api/v1/admin/stripe-event-processed under the
    existing requireCloudMode group.

  server/middleware_ratelimit.go
    Adds the new path to the cloud-admin rate-limit bucket alongside
    /admin/plan, /admin/stripe-customer-id, /admin/user-by-customer.

  server/cloud_admin_gate_test.go
    Adds self-host-404 test case + two new tests:
      TestStripeEventProcessed_RecordsAndDetectsDuplicates
      TestStripeEventProcessed_ValidatesEventIDPrefix

Design notes in the PR body.

Parent: PLAN-645 (chunk 3).
2026-04-23 15:16:11 -04:00
xarmian c8601a2031 test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689) (#225)
* test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689)

Option A of TASK-689: land the test infrastructure and a minimal smoke
test on both mobile and desktop viewports. Broader flow coverage (board
view drag, item detail, comments, mobile hamburger, BottomSheet
regression guard) is tracked as TASK-733.

Infrastructure
--------------
- web/playwright.config.ts: two projects (desktop-chromium, mobile-
  chromium via Pixel 7), reporter list+html, trace/video/screenshot
  retained on failure, webServer that wipes + recreates the data dir
  then runs the pad binary. Paths anchored to the config file's
  directory so runs are cwd-invariant.
- web/e2e/global-setup.ts: bootstraps admin via POST /auth/bootstrap,
  logs in, creates the e2e workspace, mints a user-scoped API token,
  and persists the token + resolved admin username to fixture.json.
- web/e2e/fixtures.ts: extends base test so every BrowserContext
  automatically gets Authorization: Bearer <token>. Uses a token
  rather than a session cookie because sessions are User-Agent bound
  in middleware_auth.go and a node-minted session would be rejected
  by a Chromium UA.

Tests
-----
- web/e2e/dashboard.spec.ts: a logged-in user lands on the seeded
  workspace, no login form is rendered, and the workspace name
  appears on the page. Runs in both project viewports.

CI
--
- New `e2e` job in .github/workflows/ci.yml: builds web UI + binary,
  installs Playwright chromium with OS deps, runs the suite, and
  uploads the HTML report as an artifact on failure. Timeout capped
  at 10 minutes (suite itself runs in ~4s today).

Local run (in mcr.microsoft.com/playwright:v1.59.1-noble): 2 passed
in 4.1s.

Parent: PLAN-644.
Follow-up: TASK-733 for broader flow coverage (Option B in the
original ship plan).

* fix(e2e): persist server-returned workspace slug instead of the constant (TASK-689)

Addresses Codex P2 on PR #225. When Playwright's `reuseExistingServer:
true` (local dev), a re-run of globalSetup hits `POST /api/v1/workspaces`
against a DB that already has `e2e`. The server uniquifies the slug
(`e2e` → `e2e-2` → …) and returns the uniquified value, but the old
code wrote `WORKSPACE_SLUG` (the constant) to fixture.json. Tests
then navigated to /e2e-admin/e2e — which might still exist from a
previous run with stale state — instead of /e2e-admin/e2e-2, missing
regressions in freshly-seeded content.

Fix: read `slug` back from the workspace-create response and use that
when writing fixture.json. Local re-runs now always point at the
workspace this run actually created.

Parent: PLAN-644.

* fix(e2e): cross-platform webServer bootstrap via Node wrapper (TASK-689)

Addresses Codex P2 on PR #225: `rm -rf && mkdir -p && pad server start`
in webServer.command is POSIX-only. Windows contributors on cmd.exe or
PowerShell can't run `npm run test:e2e` at all — the e2e suite becomes
Linux/macOS-only, defeating the "CI parity" goal.

Fix: extract the wipe-and-exec logic into web/e2e/run-pad.mjs. Node's
fs.rmSync / mkdirSync / child_process.spawn are uniform across
platforms, and the wrapper forwards SIGTERM/SIGINT so Playwright's
teardown still cleanly kills the child on suite exit.

Local re-run in mcr.microsoft.com/playwright:v1.59.1-noble: 2 passed.

Parent: PLAN-644.
2026-04-22 22:35:08 -04:00
xarmian 9f0f168c6b chore(release): cosign signing + SBOM + SLSA provenance (TASK-684) (#224)
Matches the security-posture promise in SECURITY.md and README. Every
GA release will now publish:

- Signed checksums.txt (Sigstore keyless cosign via GitHub OIDC)
- Signed container manifests for ghcr.io/xarmian/pad (cosign sign)
- SPDX SBOM (.spdx.json) next to each archive (syft via goreleaser
  sboms: section)
- SLSA v1 build provenance attestation for every archive via
  actions/attest-build-provenance

.goreleaser.yaml:
- Add sboms: (artifacts: archive, SPDX JSON)
- Add signs: for checksum artifact with --output-certificate /
  --output-signature
- Add docker_signs: for container manifests
- Fix pre-existing v2 incompatibility in before.hooks (map-with-cmd
  form is rejected in v2; switched to the plain-string form required
  by the v2 schema — the existing config never validated)

.github/workflows/release.yml:
- Grant id-token: write (required for cosign keyless OIDC) and
  attestations: write (required by attest-build-provenance)
- Install cosign (sigstore/cosign-installer v3.10.1) before goreleaser
- Install syft (anchore/sbom-action/download-syft v0.18.0) before
  goreleaser
- Add actions/attest-build-provenance (v4.1.0) step after goreleaser
  to mint SLSA provenance for all built archives

All new actions pinned to 40-char SHAs per the existing pinning
convention. Verified with:
  docker run goreleaser/goreleaser:v2.15.4 check  # config valid
  yq -e '.' .github/workflows/release.yml         # YAML valid

Parent: PLAN-644.
2026-04-22 21:16:33 -04:00
xarmian e116b53be5 docs: add Go Report Card, GHCR, Sponsors badges to README (TASK-688) (#223)
Grouped README polish to give the repo the public-OSS texture
reviewers expect:

- Go Report Card — reinforces code-quality signal once the repo is
  public (goreportcard.com auto-indexes Go repos).
- GHCR container image — links directly to the package page; uses a
  static shields.io badge (GHCR doesn't expose a pulls endpoint the
  way Docker Hub does, so we avoid the unreliable third-party pull
  services).
- GitHub Sponsors — shows live sponsor count; complements the
  FUNDING.yml Sponsor button added in TASK-679.

CI, Release, and License badges kept as-is.

Parent: PLAN-644.
2026-04-22 21:02:50 -04:00