Commit Graph

298 Commits

Author SHA1 Message Date
xarmian da21598f10 fix(server): bearer-gate checkItemVisible's admin bypass (BUG-1918) (#793)
Bearer-authed platform admins who are restricted workspace members can
no longer read, update, delete, or export a hidden collection's item by
direct ref — checkItemVisible's unconditional admin bypass previously
ignored isBearerAuth entirely, letting a bearer admin sidestep
BUG-1917's list-level scoping for anyone who could guess a ref. Cookie
session admins keep the existing unrestricted web-UI affordance.

checkItemVisible gains an isBearer parameter (mirroring the existing
authIsBearer idiom in resolverWorkspaceRole / guestResourceFilterCore)
and gates its admin bypass on !isBearer. requireItemVisible's own
signature is unchanged, so its ~20 call sites (comments, links, stars,
versions, timeline, playbooks, backlinks, storage, artifact-export)
inherit the fix for free; the three direct checkItemVisible callers
(writeItemResolveError, handleBulkItems, resolverItemVisible) are
updated explicitly.
2026-07-03 15:31:17 -04:00
xarmian a7f6fdf099 fix(server): bearer-gate visibleCollectionIDs to close BUG-1917 (#792)
Bearer-authed platform admins (PAT/CLI/OAuth) who are restricted
members of a workspace were unrestricted on dashboard, bootstrap,
items, and graph reads (plus item creation) — the last remaining
gap in the BUG-1616/1617 pattern, where RequireWorkspaceAccess
already suppresses the admin bypass for bearer auth everywhere
else. visibleCollectionIDs now applies the same `!isBearerAuth(r)`
gate as reportVisibleCollections, so a bearer admin who is only a
scoped member is correctly restricted to their membership; a
cookie-session admin keeps the existing unrestricted web UI
affordance. Because this is the shared helper, every consumer
(buildDashboardResponse -> /dashboard and /bootstrap, handleListItems,
handleGetWorkspaceGraph, handleCreateItem's collection check, and
~25 other call sites) is fixed at once.

This also completes TASK-1894's known asymmetry: standup's
blockers/suggested_next sections (sourced from buildDashboardResponse)
are now scoped for bearer admins just like its completed/in_progress
sections already were. Folds the now-redundant
bearerAwareVisibleCollectionIDs into the shared gate.

Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
2026-07-03 14:40:54 -04:00
xarmian 7c0b13767f feat(server): REST endpoints for project next/standup/changelog + WebMCP wiring (TASK-1894) (#791)
* feat(server): add REST endpoints for project next/standup/changelog

Adds GET /workspaces/{ws}/next, /standup, /changelog — session-authed
reads mirroring `pad project next|standup|changelog --format json`,
reusing buildDashboardResponse + store.ListItems so the browser
WebMCP surface stops returning "not available" for these catalog
actions (TASK-1894). Cross-references the MCP HTTP transport's
existing dispatchProjectNext/Standup/Changelog (dispatch_http_slice4.go)
with KEEP IN SYNC comments at both sites, since this is now a third
reproduction of the same reshaping contract pending a follow-up
consolidation.

* feat(web): wire next/standup/changelog into WebMCP dispatch + api client

Adds client.ts next()/standup()/changelog() methods and replaces the
three "not available in the browser" dispatch.ts stubs with real
handlers now that the backend endpoints exist (TASK-1894). Extracts
DashboardSuggestion as a shared type and adds StandupResponse /
ChangelogResponse types mirroring the Go response shapes.

* fix(server): make projectIntelVisibility bearer-aware (TASK-1894 codex R1)

standup/changelog's own item-list scoping used visibleCollectionIDs, which
has no bearer gate: a platform admin authenticated via a bearer token
(PAT/CLI/OAuth) who is only a restricted member of a workspace got the
unrestricted admin view instead of being scoped to their real membership.
Adds bearerAwareVisibleCollectionIDs, mirroring reportVisibleCollections'
existing BUG-1616/1617 gate, and switches projectIntelVisibility onto it
while preserving its item-level grant handling (which reportVisibleCollections
deliberately drops for aggregate reports).

buildDashboardResponse (and therefore /next, and standup's blockers/
suggested_next sections) is intentionally left ungated in this change —
gating it would break next's parity with dashboard.suggested_next and
diverge it from the CLI and MCP siblings. The resulting asymmetry is
documented inline pending a follow-up fix to buildDashboardResponse itself.

* docs(server): reference BUG-1917 in projectIntelVisibility comments

Replaces the textual placeholder ("the visibleCollectionIDs bearer-gate
bug filed from TASK-1894 review") with the actual bug number now that
it's been filed. Comment-only change, no behavior difference.
2026-07-03 13:51:04 -04:00
xarmian 0a1dbc8c73 perf(test): wire remaining store helpers onto storetest fixture (TASK-1915) (#789)
newPadServer (internal/mcp), testStoreOAuth (internal/oauth), and
newMetricsTestServer (internal/server) were left on the slow
per-call migration path after IDEA-1914/#788 wired testServer and
store's white-box testStore onto storetest.NewSQLite. Switch all
three, and add minimal TestMains to internal/mcp and internal/oauth
to release storetest's process-wide template DB, matching
internal/server's existing TestMain.
2026-07-03 12:17:03 -04:00
xarmian 20544fdd44 perf(test): build the SQLite migration chain once per test binary (IDEA-1914) (#788)
* perf(test): build the SQLite migration chain once per test binary (IDEA-1914)

internal/server's -race suite spent ~30 minutes replaying all 69
migrations + 3 backfills per test (~2.7s each, 622 store-backed tests,
BUG-1913). Add internal/store/storetest, which runs the full migration
chain once into a checkpointed, sidecar-free template DB (sync.Once)
and hands every test a plain file copy opened via store.New. Wire it
into internal/server's testServer/testServer_Stop_DrainsRateLimiterCleanup
and internal/store's own testStore (duplicated inline there — an
import cycle rules out sharing storetest with store's white-box
tests). Postgres-mode tests are untouched.

internal/server -race: 1819s -> 183s.

* fix(test): plug template-dir leak and Cleanup race in storetest fixture

Codex round 2 on IDEA-1914: buildTemplate/buildSQLiteTemplate left the
MkdirTemp'd template dir on disk if store.New/checkpoint/journal_mode
failed after mkdir succeeded — now removed via a disarm-on-success
defer in both mirrored copies. Also guard Cleanup()/removeSQLiteTemplate
against racing an in-flight build+copy with a sync.RWMutex (read-locked
across build+copy, write-locked for removal) in both places.
2026-07-03 10:15:49 -04:00
xarmian e41ed8a236 fix(server): reserve parent/plan schema field keys (TASK-1912) (#786)
* fix(server): reserve parent/plan schema field keys (TASK-1912)

A collection schema field keyed exactly "parent" or "plan" makes the
parent-link extraction sites in handlers_items.go silently skip
fields-JSON extraction, disabling subtask linking with no error
anywhere. Reject newly-added occurrences of these keys on collection
create/update (grandfathering keys already present in a prior schema),
and add them to the web's reserved-key list so authors are steered
away before hitting the 400.

* fix(server): reject empty-string schema on collection PATCH (TASK-1912)

Codex round 2: handleUpdateCollection's validation guard was skipped
whenever input.Schema was a non-nil pointer to "", so a PATCH with
{"schema": ""} stored the empty string verbatim and every later
item-create against that collection 500'd instead of the mutation
being rejected up front. Drop the empty-string carve-out so "" flows
into json.Unmarshal, fails, and returns the existing 400 "Invalid
schema JSON". Omitting the schema field entirely (nil) is unaffected.
2026-07-03 03:27:04 -04:00
xarmian 584ac9a806 fix(web): treat CLI/MCP-created workspaces as agent-connected (BUG-1557) (#781)
`pad init` connects an agent (installs the skill, stores credentials) and
creates a workspace, but the web UI still showed the "connect an agent"
banner and onboarding launchpad. The only signal for "agent connected" was
has_agent_activity — an item existing with source cli/mcp — and a fresh
pad-init workspace has zero items, so the UI nagged to connect an agent the
user already had.

Give the server a truthful signal: a workspace created through an agent
surface already has an agent wired up before it creates its first item. Add
a `source` column to workspaces (web/cli/mcp), attributed authoritatively
server-side from the request auth shape (actorFromRequest) — never from the
request body, so a web client can't spoof "cli" to self-suppress the
prompts. The dashboard ORs source in (cli,mcp) into has_agent_activity when
the cheap item check comes up empty.

- migrations 069 (sqlite) / 047 (postgres): workspaces.source NOT NULL
  DEFAULT '' (legacy rows stay "unknown", never treated as agent-created)
- models.Workspace.Source + WorkspaceCreate.Source (json:"-", server-set)
- thread source through the CreateWorkspace INSERT + all 7 workspace scan
  sites (workspaces.go, workspace_members.go)
- handleCreateWorkspace derives source from actorFromRequest
- OnboardingLaunchpad step 1 collapses to "Agent connected" when the agent
  is already wired up, shifting emphasis to "tell it to set up"

Web modal and cloud-signup auto-create flows are unchanged and still
correctly prompt to connect (source web / empty).

Tests: store source round-trip across reads; dashboard reports
agent-connected for a cli-created workspace with zero items; web-created
stays not-connected until an agent item exists; a web body-spoofed source
is ignored.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-01 23:25:24 -04:00
xarmian 9b9e2eb26b fix(admin): stop leaking 2FA secret via GET /admin/settings (BUG-1909) (#779)
platform_settings holds admin-managed UI keys alongside server secrets
(2fa_challenge_secret — the 2FA challenge HMAC signing key) and
plan_limits_* rows. handleGetPlatformSettings returned the whole table,
so any admin client received the 2FA signing secret (and limit rows) in
plaintext — a read-side secret exposure (not corruptible; the key isn't
in the write whitelist, but it was fully exposed).

Adopt deny-by-default: introduce adminManagedSettings, the canonical
allowlist of the 8 admin-editable keys, and share it across read and
write so they can't drift. GET now projects only those keys (masking
maileroo_api_key); anything else in platform_settings — the 2FA secret,
plan-limit rows, any future internal secret — is never exposed. PATCH
gates writes on the same set.

Tests: SecretsNotExposed (2fa_challenge_secret and plan_limits_* absent
from GET, raw-body substring check, only allowlisted keys returned) and
SecretNotWritable (PATCH can't overwrite the 2FA secret or write limit
rows). A codebase-wide secret-exposure audit found no other confirmed
leak surfaces.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-01 17:44:43 -04:00
xarmian 7e5917056a fix(admin): don't persist masked Maileroo API key on email settings save (BUG-1890) (#778)
* fix(admin): don't persist masked Maileroo API key on email settings save (BUG-1890)

The admin settings "Save Email Settings" button PATCHed the whole
platformSettings object. GET /admin/settings returns the Maileroo key
masked (abcd...wxyz for >8 chars, **** otherwise), so saving without
re-typing the key persisted the mask over the real key — silently
breaking email until re-entered.

Two layers:

- Client (+page.svelte): track whether the API-key field was edited
  (apiKeyEdited flag) and scope the PATCH to the email fields this form
  owns (mirrors the TASK-1889 Integrations save). The key is included
  only when the admin actually edited it; an untouched save preserves
  the stored key, and clearing the field still sends "" to disable.

- Server (handlers_admin.go): extract maskAPIKey() as the single source
  of truth for the mask format and skip persisting maileroo_api_key when
  the incoming non-empty value equals the mask of the currently-stored
  key. Best-effort backstop for non-web/old clients; the client fix is
  authoritative.

Tests (handlers_admin_settings_test.go): maskAPIKey unit cases, the
masked-key-not-persisted regression (both long and **** short masks),
real-key-update-wins, and empty-key-clears.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST

* fix(admin): clear Maileroo key when disabling email provider (BUG-1890)

Codex review of the scoped email-save payload found a regression: when
an admin selects Provider "None" without touching the key field, the
scoped payload omitted maileroo_api_key, leaving the stored key. Because
reconfigureEmail keys email enablement off the presence of the API key
and ignores email_provider, "None" no longer disabled email.

Send an explicit empty key whenever the provider isn't Maileroo, so
disabling actually turns email off. The masked-key guard still applies
when the provider is Maileroo and the key was left untouched.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST

* fix(server): tear down live email sender when platform key is cleared (BUG-1890)

Codex review: clearing the Maileroo key (e.g. disabling via provider
"None") wrote the empty key to the DB, but reconfigureEmail's empty-key
branch returned early without clearing the in-memory s.email sender —
so the running process kept sending mail until restart, contradicting
the UI's "disabled" state.

Track whether email was wired from env vars (emailEnvConfigured, set in
SetEmailSender). When platform settings carry no key, reconfigureEmail
now tears down the live sender (s.email = nil, emailAPIKey = "") unless
env config exists — env is the deployment baseline the admin UI doesn't
disable. Tests pin both the teardown and the env-preserved paths.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-01 17:27:00 -04:00
xarmian 915f7e66c5 fix(web): show issue ID and status pills in activity log (BUG-1748) (#776)
Activity rows (the dedicated Activity page and the dashboard's Recent
Activity list) showed only the item title, never the issue ID. Add the
ref (e.g. BUG-1748) as a leading monospace badge on both surfaces.

The ref rides on the per-row item lookup that already runs to populate
the title, so there are no new DB queries — enrichActivities and the
dashboard recent-activity builder now also copy item.Ref after
ComputeRef(). New item_ref field on models.Activity, DashboardActivity,
and the TS Activity / recent_activity types.

The Activity page now renders field changes as structured pills
("status: open → fixing") instead of a raw string, via a new shared
parseFieldChanges util that also replaces the private copy in
TimelineActivityCard.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-06-29 13:07:36 -05:00
xarmian 44ee6a4604 fix(server): consistent soft-delete handling across item sub-resources (#771)
* fix(server): consistent soft-delete handling across item sub-resources

The main GET returns archived (soft-deleted) items read-only (200) and
PATCH/DELETE reject them with 409 "archived" (BUG-1791), but every item
sub-resource still resolved through the deleted_at-filtering ResolveItem
and returned a misleading 404 — which broke the archived-item detail
page, since it loads links/progress/timeline/etc. against the archived
slug. Mirror the GET/PATCH policy across the whole item surface: reads
behave like GET (200), writes behave like PATCH (409).

- Read sub-resources (children, progress, activity, backlinks, links GET,
  timeline, comments GET, versions list/get, artifact export, star
  status, item grants GET, share-links GET) now resolve via
  ResolveItemIncludeDeleted + the same requireItemVisible gate -> 200.
- Write sub-resources (create comment, create link, version restore,
  star/unstar, create item grant, create share-link) now route the nil
  case through writeItemResolveError -> 409 "archived" instead of 404.
- Dashboard recent-activity and the workspace activity feed resolve the
  referenced item include-deleted so archived-item activity renders with
  its real title/slug (gated by the same visibility checks) instead of a
  blank "ghost" row; this also stops a deleted-item row from bypassing
  the collection-visibility filter.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST

* fix(server): share-link item handlers had read/write soft-delete treatment swapped

handleCreateItemShareLink (a mutation) was wrongly resolving archived
items include-deleted and 404ing on miss, which let an owner create a
public share link for an archived item — the public resolver excludes
soft-deleted items, so the link 404s immediately. handleListItemShareLinks
(read-only) was wrongly returning 409 archived. Swap them back: create
rejects archived with 409 via writeItemResolveError; list resolves
include-deleted and returns 200. Per Codex review (round 1).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-06-27 21:42:52 -05:00
xarmian e4caad2c64 feat(server): expose MCP tool-surface over authed REST endpoint (#764)
Add GET /api/v1/mcp/tool-surface, a session/token-authenticated
same-origin endpoint that serves the MCP catalog descriptor JSON
(the nine env.Catalog tools, their actions, and input schemas) with a
new per-action read_only bool. Backs the Phase 3 browser-side WebMCP
layer (PLAN-1888): the client fetches once and derives readOnlyHint
from the read_only flags without re-deriving the read set in TS.

Wired via the SetMCPTransport injection pattern to avoid the import
cycle: internal/mcp already imports internal/server (dispatch_http.go),
so internal/server cannot import internal/mcp. internal/mcp exports a
cycle-free ToolSurfaceJSON() that builds from the package-global
Catalog plus a co-located readOnlyActions allowlist; cmd/pad/main.go
(which imports both) injects it via Server.SetToolSurfaceHandler before
setupRouter. The route mounts in the authed API group so it inherits
TokenAuth/SessionAuth/CSRFProtect/RequireAuth — NOT the bearer-gated
/mcp infra path — and is available on both cloud and self-host.

The existing actionMetaToolSurface (pad_meta action=tool-surface) now
shares the same serializer, so MCP and REST can't drift; it gains the
additive read_only flag too. No ToolSurfaceVersion bump (DR-7): adding
read_only is additive metadata; names/actions/params are unchanged.

Refs TASK-1891 / PLAN-1888

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-24 22:45:32 -04:00
xarmian 3c0aed55db feat(server): add webmcp_enabled platform setting + session flag (#763)
* chore(docs): correct cloud MCP from "future /mcp endpoint" to live mcp.getpad.dev vhost

The HTTPHandlerDispatcher description called the remote MCP server a
"future /mcp endpoint." It's live: a cloud-mode-gated Streamable HTTP
server mounted on the dedicated mcp.getpad.dev vhost via
SetMCPTransport / registerMCPRoutes. Point at handlers_mcp.go.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ

* feat(server): add webmcp_enabled platform setting + session flag

Introduce the opt-in gate for the browser-side WebMCP surface
(PLAN-1888 Phase 1, DR-6). New webmcp_enabled platform setting,
default off, admin-writable, surfaced to the web client via the
/api/v1/auth/session payload so client tool registration can gate
on it.

- internal/server/handlers_admin.go: add settingWebMCPEnabled to the
  admin-PATCH whitelist (else silently dropped) + serialize a "false"
  default in the GET settings response.
- internal/server/handlers_auth.go: emit webmcp_enabled in the session
  payload via a fail-closed webMCPEnabled() helper (false on unset or
  read error).
- web/src/lib/api/client.ts: add webmcp_enabled?: boolean to AuthSession.
- web/.../console/admin/settings/+page.svelte: Integrations section with
  a WebMCP toggle + security warning copy (Phase 4 admin-warning intent).
- Go tests: admin PATCH persists + non-admin 403; session payload reflects
  stored value with default false.

No migration (platform_settings is an existing kv table).

Refs TASK-1889 / PLAN-1888

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-24 22:28:34 -04:00
xarmian 616a6d2a0a feat(auth): localhost password recovery for locked-out self-host admins (#760)
Add a loopback-only account-recovery path so a self-hosted operator who
forgot their password (with no email provider configured) can recover
without editing the database by hand.

- POST /api/v1/auth/local-reset: loopback-gated, non-cloud, no auth
  required (same trust model as bootstrap). Returns a single-use reset
  link, or a temporary password with {"temp_password": true}.
- pad auth reset-password <email> [--temp-password]: talks to the local
  server over loopback directly (not the configured public URL), so the
  command works on the server host regardless of CLI config. Prints the
  server's shareable reset_url when a public base URL is known.
- Web /forgot-password reads email_configured from the session and shows
  host-recovery instructions instead of a dead "we emailed you a link"
  when no provider is configured.
- forgot-password server log emits the reset path on non-cloud instances
  so operators can also recover straight from the logs.
- Docs: CLAUDE.md + docs/deployment.md recovery sections.

Tests cover the loopback/cloud gates, the shareable reset_url, and both
output modes (reset link + temp password).
2026-06-22 20:58:50 -04:00
xarmian 341cbd373d fix(artifact): normalize import field map so json fields validate (BUG-1883) (#759)
Importing a playbook artifact with an `arguments` array failed:
  field "arguments" must be a JSON object, array, or null
artifact.Decode normalizes arguments to []map[string]any, but ValidateFields'
json case only accepts map[string]any/[]any/nil. handleCreateItem never hits
this because its field map comes from JSON-unmarshalling the request body.

Fix: round-trip the import field map through JSON (marshal→unmarshal) before
createItemChecked, yielding canonical []any/map[string]any — matches the wire
create path, no per-field special-casing. Regression test round-trips a
playbook with arguments through import.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-22 13:31:45 -04:00
xarmian 4f0984bb15 feat(artifact): server export + import endpoints for playbooks & conventions (#755)
* feat(artifact): server export + import endpoints for playbooks & conventions

Phase 2 of PLAN-1867. Adds:
- GET  /workspaces/{ws}/items/{ref}/export  — item-visibility-gated; encodes a
  playbook/convention item to a Markdown+frontmatter artifact.
- POST /workspaces/{ws}/import-artifact      — editor-gated; byte-capped +
  YAML-bomb-guarded parse, forgiving preprocess (foreign selects blanked,
  invocation_slug de-collided, status forced draft), creates via the shared
  create path.
- Extracts createItemChecked from handleCreateItem so import inherits
  validation / uniqueness / edit-perm / side-effects (no direct store.CreateItem).
- PAD_IMPORT_ARTIFACT_MAX_BYTES env override.

Server validation, coercion, and YAML input limits land at the HTTP boundary
per DR-4/DR-7/DR-8 and the Codex P2 notes (collSlug via shared helper,
item-visibility export auth, byte-cap→node-walk→decode ordering).

Implements TASK-1871, TASK-1872, TASK-1873, TASK-1874.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ

* fix(artifact): enforce item quota + require title on artifact import

Addresses Codex Phase-2 review:
- P1: handleImportArtifact now calls enforcePlanLimit(items_per_workspace)
  before create, matching handleCreateItem — imports can't exceed the plan cap.
- P2: reject empty/whitespace-only artifact titles with 400 (Title is required),
  matching the normal create path.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-22 11:43:52 -04:00
xarmian fa8064e2b9 feat(onboard): capture workspace intent at creation, warm the onboard run (TASK-1855) (#746)
Intent-as-seed for the onboarding bridge. The workspace `description` column
already existed end-to-end but nothing captured or surfaced it:

- Web: CreateWorkspaceModal gains an optional "What are you tracking?"
  textarea (create-only), sent as `description` on create.
- Bootstrap: AgentBootstrapWorkspace now carries `description` (omitempty,
  additive) so the onboard playbook can read the user's stated intent.
- Onboard playbook: pre-flight reads workspace.description; B1 reflects it
  back ("You mentioned this is for X — let's build around that") instead of
  opening cold with "what is this project?", falling back when absent.

Net effect: a user who types one line at creation gets an onboard interview
that starts warm instead of from zero.

Parent: PLAN-1847 (Phase 3).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 12:25:46 -04:00
xarmian 14614c98f7 feat(auth): surface server version on /auth/session (TASK-1839) (#740)
Add the server build version to both the setup-state and authenticated
/auth/session payloads (same source as /health). The mobile shells call
/auth/session on connect; surfacing version there lets them read it in
the round-trip they already make and warn when a server is below their
minimum supported version, without a second request (IDEA-1826).

Keep the web AuthSession TS type in sync (CONVE-1741).
2026-06-21 00:34:25 -04:00
xarmian 22d901c823 fix(auth): unify first-run setup into one browser handoff (BUG-1843) (#739)
On a fresh instance, `pad init` / `pad auth setup` created the admin
account in the browser and dropped the operator on the console, then
printed a SECOND "authorize the CLI" URL back in the terminal that a
user who'd moved to the browser never saw — forcing a ctrl-C + re-run.

Collapse it into a single browser tab: the CLI mints the pending CLI
auth session up front and hands /setup a validated `next=/auth/cli/<code>`
target, so account creation flows straight into the approval page where
the just-bootstrapped admin approves in one click and the CLI connects.

- internal/cli/bootstrap.go: thread `next` into the /setup URL (query
  before the #token fragment); raise bootstrapPollTimeout to 20m to
  match the setup session TTL.
- cmd/pad/main.go: extract pollAndSaveCLIAuth; runBrowserSetup pre-creates
  the session and polls it; `pad workspace init` drives local setup inline.
- cmd/pad/init.go: `pad init` routes through the unified handoff.
- internal/store + internal/server: grant a setup-specific 20m CLI auth
  session TTL when UserCount==0 so the combined create-account + approve
  window can't expire mid-flow; normal logins keep the 5m default.
- web/src/routes/setup: honor a validated local `next` redirect (open-
  redirect guarded), preserved across the token-fragment scrub.

Reviewed via Codex loop (3 rounds → clean).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-20 23:51:43 -04:00
xarmian 99b4649bb6 fix(items): surface archived items instead of masking them as missing (BUG-1791) (#733)
A soft-deleted (archived) item still appears in include-archived list
results (all=true) but 404'd on get/update/move and was absent from search
and status-filtered lists — all=true is the only read path that includes
archived rows. With no archived marker in list output and a bare "Item not
found" on get/update, this looked like index/FTS corruption (the report's
diagnosis). It is not: every read path was behaving correctly for an
archived item. The root cause is observability, not a desync.

- scanItems now scans i.deleted_at; all six feeding SELECTs select it
  (ListItems, listItemsFTS x2 dialects, getChildItems, ItemsModifiedSince,
  ListStarredItems). Archived rows in include-archived results now carry
  deleted_at so callers can tell them apart from live rows; the
  deleted_at-filtered paths are unaffected (value stays NULL there).
- GET item resolves include-deleted, returning an archived item read-only
  (200) with its deleted_at marker rather than 404 — an agent can read it
  and see it is archived.
- UPDATE/DELETE/MOVE of an archived ref return a clear 409 "archived"
  (restore first) instead of a bare 404; visibility is enforced exactly as
  the active path so an archived item is never revealed to a caller who
  can't see it.
- CLI shows an (archived) marker in lists and an Archived line in detail.

Tests: store IncludeArchived populates DeletedAt; server GET archived -> 200
with deleted_at, UPDATE/MOVE archived -> 409 "archived". Verified on SQLite
and Postgres (make test-pg).
2026-06-15 14:44:58 -04:00
xarmian 33e49434ed fix(server): non-fatal UA session binding + sliding session renewal (#727)
Two root causes behind users being logged out:

- UA session binding was unconditional and fatal — any User-Agent change
  (browser/WebView update, DevTools device emulation, mobile rebuild)
  silently de-authenticated the session. Now log-only across all three
  enforcement sites (TokenAuth, SessionAuth, and the validateSessionCookie
  helper used by CLI-auth/account/session-check routes), mirroring the
  default IP-change handling. (BUG-1815)

- Sessions had a fixed absolute TTL with no refresh on activity, so even an
  active user hit the cliff at 7d (web) / 30d (CLI). Adds sliding renewal:
  RenewSessionIfStale extends expires_at when past the half-window threshold,
  capped at created_at + 90d (SessionMaxLifetime), CAS-guarded and only
  reported when RowsAffected confirms the write. The middleware re-issues the
  session + CSRF cookies on renewal. New renew_ttl_seconds column (sqlite +
  pg migrations); legacy rows (0) keep their fixed expiry. (TASK-1816)

Reviewed by Codex (clean). Tests: store + server suites pass.
2026-06-14 21:15:35 -04:00
xarmian 3c3016abd9 feat(server): focused neighborhood mode on workspace graph endpoint (TASK-1781) (#718)
* feat(server): focused neighborhood mode on workspace graph endpoint (TASK-1781)

Add ?focus=REF&depth=N to GET /workspaces/{ws}/graph. When focus is set,
BFS-traverse typed edges (undirected) out from the ref up to depth hops
(default 2, clamped to [1,5]) and return only that neighborhood's nodes +
edges. Without focus the whole-workspace behavior is unchanged.

- The focused item is always included, even when terminal (you asked to
  view it); neighbors honor the existing include_terminal filter.
- Neighborhood is intersected with the visibility-filtered item set, so a
  guest can't infer hidden items from dangling edges.
- Node-count cap (maxFocusNodes=200) stops BFS expansion early and sets a
  new GraphResponse.Truncated flag (omitempty — whole-workspace payload
  shape unchanged) so the client can offer expand-on-click.
- An unknown/invisible focus ref returns 404.

Tests: depth bounds + clamping, both-direction traversal, terminal focus
node inclusion, terminal-neighbor filtering, cross-collection typed edges,
unknown ref 404, and truncation.

Parent: PLAN-1780.

* fix(server): preserve true child_count in focus mode per Codex review (round 1)

In focus mode child_count was derived from the depth/cap-filtered edge
set, so a boundary parent whose children fell outside the neighborhood
reported child_count=0. The web UI gates hub-label and children-pill
visibility on child_count > 0, so those would wrongly hide.

Count children over the full visible item set instead (terminal filter
on the child preserved), independent of the focus subgraph. This also
reproduces the whole-workspace semantics exactly. Added a regression
test (focused parent with a child beyond depth still reports count=1).

Parent: PLAN-1780.
2026-06-08 18:13:10 -04:00
xarmian 10a55d1d5c feat(auth): accept provider=apple in cloud oauth-login/link/unlink (TASK-1773) (#714)
* feat(auth): accept provider=apple in cloud oauth-login/link/unlink (TASK-1773)

Sign in with Apple (PLAN-1772, App Store 4.8) needs pad to recognize
'apple' as an OAuth provider. The oauth-login, oauth-link, and
oauth-unlink handlers each hard-rejected anything but github/google;
DRY the triplicated literal into supportedOAuthProviders +
isSupportedOAuthProvider and add apple.

The rest of the path is already provider-agnostic: find-or-create user,
auto-link, the oauth_provider_not_linked gate for existing accounts, and
the verified-email requirement all work unchanged. Storage
(users.oauth_providers) is a free-form JSON array with no DB constraint,
so no migration.

Prerequisite for the pad-cloud /auth/apple/native endpoint (TASK-1774).

* test(auth): cover apple via oauth-link handler (Codex nit)

Prove the shared isSupportedOAuthProvider allowlist is wired through the
link call site, not only oauth-login. oauth-unlink shares the same gate
(unit-tested via TestIsSupportedOAuthProvider).
2026-06-07 22:08:14 -04:00
xarmian 35cc26daaf fix(web): collection cards show real child-item progress; child-progress endpoint (BUG-1509) (#710)
* BUG-1509: show real child-item progress on non-plan collection cards

Backend: extract collectionChildrenProgress helper from handlePlansProgress
and expose it at GET /collections/{collSlug}/child-progress with identical
visibility/guest-grant filtering. handlePlansProgress refactored to delegate
to the shared helper (no duplication). Route registered in the existing
/{collSlug} subrouter alongside checkbox-progress.

Frontend: +page.svelte fetches child-progress + checkbox-progress in parallel
for non-plans collections; per-item merge prefers child-progress (label
"tasks") when total>0, falls back to checkbox counts (label "done"). ItemCard
extended to render progress.label when present. ChildItems.svelte render gate
fixed to include error state so a failed /children fetch surfaces instead of
silently vanishing.

Tests: TestCollectionChildProgress covers happy path (linked children counted
correctly), zero-children items (present with total=0), 404 for unknown
collection, and restricted-member visibility gate (empty response for hidden
collection, not a data leak).

* fix: include_archived on child-progress and progressLabel desync (codex r2)

P1: GetAllItemProgress now accepts includeArchived bool; the parent-row
filter (AND p.deleted_at IS NULL) is conditioned on it, mirroring
CollectionCheckboxProgress. handleCollectionChildrenProgress reads
?include_archived=true and threads it through. handlePlansProgress
hardcodes false — no contract change there. collectionChildProgress()
client method gains opts?: { includeArchived? } with qs() serialisation.
Both call sites in +page.svelte (loadCollection and refreshProgress) now
pass includeArchived to the child-progress fetch.

P2: refreshProgress plans branch now sets progressLabel = 'tasks' so a
sync-triggered refresh after a failed initial plans load renders with the
correct label. progressLabel = 'done' moved inside the non-plans try block
(symmetric with plans) so a thrown fetch leaves the label in whatever
state the previous collection set, not silently desync'd.

Tests: TestCollectionChildProgress extended — archives parentA, confirms
it drops from default response and reappears with include_archived=true.

* fix: thread includeArchived through childrenDoneFiltersForCollection (codex r3)

GetAllItemProgress conditionally drops the p.deleted_at IS NULL parent
filter when includeArchived=true, but the filter-discovery call at the
top of the function — childrenDoneFiltersForCollection — still had the
filter hardcoded. If a child collection's only parent links pointed to
archived parents, that collection was absent from the done-semantics map,
and those children fell back to default status terminals rather than the
collection's configured done field — producing wrong done counts.

Fix: childrenDoneFiltersForCollection gains an includeArchived bool param;
the JOIN on items p conditions p.deleted_at IS NULL on it, exactly mirroring
the main query. GetAllItemProgress passes includeArchived through. The only
other caller of this helper (GetItemProgress via childrenDoneFiltersForParent)
is unaffected — that path is a separate function and never surfaces archived
parents.

Test: TestCollectionChildProgress extended with a "Widgets" collection whose
done field is `state` (terminal: "shipped") — not the default `status` field.
An archived task parent links two widget children (one shipped, one open); no
live task parent links into widgets, so the filter-discovery bug would drop
the collection from the map and produce done=0. The test asserts done=1 and
was verified to fail on the pre-fix code.
2026-06-06 11:14:23 -04:00
xarmian 1bd3e52230 feat(web): graph SSE live layer — glow/pulse on touched nodes (TASK-1736) (#704)
* feat(web): graph SSE live layer — glow/pulse on touched nodes (TASK-1736)

The graph now feels alive while agents work: item events from the
workspace SSE stream flash the touched node toward white and fade it
back over 45s (a lazy 2s prune interval animates the decay and stops
itself when idle). Structural events (created/archived/restored) and
item_updated fold into one trailing-debounced refetch (1.5s) through
the existing loadGraph stale-token path; comment_created is glow-only.
New items arrive glowing via a pending-uuid stash resolved when the
refetch lands. Pulse composes before focus-mode dimming so touched
nodes still flicker subtly in the dimmed crowd. Selection clears when
the selected item leaves the payload (archived under focus mode).

Events correlate via a uuid→ref bridge rebuilt per payload — the
graph endpoint now emits each node's item UUID alongside the ref.

Parent: PLAN-1730.

* fix(web): refetch graph on sync_required per Codex review (round 1)

items_bulk_updated and replay-buffer gaps route through onSyncRequired,
not onItemEvent — the graph stayed stale after bulk archive/move/assign
until the next single-item event. Fold both into the existing debounced
refetch.
2026-06-05 19:26:46 -04:00
xarmian 77dcd07ecd feat(web): graph search fly-to + collection/status/role filters (TASK-1735) (#703)
* feat(web): graph search fly-to + collection/status/role filters (TASK-1735)

Toolbar grows a type-ahead search (ref/title over the post-filter node
list; ArrowUp/Down + Enter picks, Escape closes without stealing the
page's deselect) that routes through the existing selectNode() — same
camera fly-to, highlight, and detail card as a click. Client-side
filters subset the rendered graph: collection chips with palette dots,
status chips, and a role select (hidden when no node carries a role;
the graph endpoint now emits the assigned agent-role slug per node).
Edges survive only when both endpoints do; counts read "X of Y" while
filtered. Workspace switch resets filters; show-completed doesn't.
Filter changes deselect so a vanished node can't strand focus mode.

New GraphToolbar.svelte owns the presentational toolbar; the page owns
authoritative filter state (CONVE-1688 discipline unchanged).

Parent: PLAN-1730.

* fix(web): close graph search dropdown on blur per Codex review (round 1)

The dropdown opened on focus/input but only closed on pick or Escape,
leaving stale results floating over the canvas after clicking away.
The result buttons already pick on mousedown+preventDefault, so the
input never blurs mid-pick — a plain onblur close is safe.

* fix(web): gate search Escape on dropdown visibility per Codex review (round 2)

Escape in a focused-but-empty search now falls through to the
page-level deselect instead of being swallowed by the searchOpen flag.
2026-06-05 19:14:45 -04:00
xarmian 93220845a0 feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731) (#699)
* feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731)

GET /api/v1/workspaces/{ws}/graph returns the whole workspace as
{nodes, edges} in one call, feeding the 3D graph view (PLAN-1730).
Nodes carry ref/title/collection/status/is_terminal/child_count/
updated_at; edges are typed (parent | blocks | implements | related |
wiki-link), with wiki-link edges sourced from the PLAN-1593 reverse
index, deduped per pair, self-links dropped.

Default response is active items only; ?include_terminal=true returns
the full history. Visibility follows the dashboard model (collection
visibility + guest item-level grants), and edges are filtered to the
visible node set so hidden items can't be inferred from dangling
endpoints.

Parent: PLAN-1730.

* fix(server): normalize graph edge types to advertised vocabulary per Codex review (round 1)

item_links can carry split_from / supersedes / wiki_link beyond the
documented enum. Map stored types to the hyphenated graph vocabulary
(wiki_link → wiki-link, split_from → split-from), dedupe (source,
target, type) so a stored wiki_link row and a parsed [[...]] mention
of the same pair emit once, and document the full edge enum. Unknown
future link types pass through rather than being dropped.

* fix(store): close graph edge enum against unknown link types per Codex review (round 2)

Route stored link types through models.NormalizeItemLinkType; values
it rejects (possible via the import path — no DB CHECK on
item_links.link_type) degrade to 'related' instead of leaking
undocumented edge types past the advertised vocabulary.
2026-06-05 18:12:56 -04:00
xarmian 3704cc2c9f fix(store): cast jsonb metadata to text for Postgres LIKE + gofmt (BUG-1702) (#693)
The status-transition backfill query used `a.metadata LIKE '%→%'`, but
activities.metadata is jsonb on Postgres where LIKE (~~) is undefined,
failing TestBackfillStatusTransitions(_SeedSeqBelowHop) and erroring in
any Postgres deployment. Cast to ::text on Postgres (dialect-guarded),
matching AttachmentReferenced. Also gofmt comment.go + the share-links
test that were tripping golangci-lint.
2026-06-02 09:39:22 -04:00
xarmian 72d8963c4c fix(timeline): resolve collab-snapshot diffs + collapse autosave bursts (BUG-1612) (#691)
Item timelines showed two collab-snapshot problems:

1. Artifacts: the timeline endpoint (ListItemVersionsBeforeTime) served
   diff versions unresolved, so TimelineVersionCard fed raw diff-match-patch
   patch text into DiffView. Add GET /items/{slug}/versions/{versionID}
   (handleGetItemVersion -> Store.GetItemVersionResolved) and have the card
   lazily fetch resolved content the first time a diff version is expanded.

2. Clutter: every ~5s web-editor autosave flushes a collab-snapshot version.
   buildTimeline now collapses uninterrupted collab-snapshot bursts (within
   10 min, no intervening event) to their newest entry, and the source badge
   renders as "Autosave" instead of the raw slug.

Adds TestCollapseAutosaveBursts. Known limitation (accepted): collapse is
page-local, so a 150+ cross-actor autosave chain can leak one row per
"Load more" page — gated by the 1h version throttle, degrades gracefully.
2026-06-01 21:25:42 -04:00
xarmian ae8173b42d fix(server): gate ref-resolver admin bypass on bearer auth (BUG-1618) (#690)
* fix(server): gate ref-resolver admin bypass on bearer auth (BUG-1618)

resolverWorkspaceRole returned "owner" for any platform admin regardless
of auth surface, so a bearer-borne admin (PAT / CLI / MCP) could probe
the existence of refs in workspaces they never joined via the /-/r/ 302
redirect — leaking workspace + ref existence plus the owner username and
collection slug in the redirect target.

Site 1 (real fix): thread isBearerAuth(r) into resolverWorkspaceRole and
gate the admin branch on !authIsBearer; the workspace-owner check stays
unconditional. Bearer-admins fall through to the member-then-grants check
(membership-only stance, matching BUG-1616/1617). Cookie-session admins
keep the owner bypass so the web-UI affordance is preserved. Added
TestRefResolver_AdminBearer_404OnNonMemberWorkspace (bearer -> 404) and
TestRefResolver_AdminCookie_StillRedirects (cookie -> 302).

Site 2 (audit, no logic change): the workspace sort-order bulk-update's
silent-skip needs no auth gate — UpdateWorkspaceSortOrder is scoped to
the caller's own workspace_members row, so a non-member PATCH touches
zero rows (no cross-ws write or leak), and handleListWorkspaces has been
membership-only for all authenticated users including admins since
BUG-982. Rewrote the stale comment to record both facts.

Parent: BUG-1617. Sibling: BUG-1616.

* fix(server): deny bearer-admin grant fallback in resolver per Codex review (round 1)

A bearer admin who isn't a member but holds a stray collection/item grant
got "guest" from resolverWorkspaceRole, then checkItemVisible's own
`user.Role == "admin"` bypass returned visible — reopening full resolver
access + 302 URL leakage the BUG-1618 fix was meant to close. Add the
membership-only guard (return "" for bearer-admin non-members before the
grant fallback), matching RequireWorkspaceAccess and the SSE/collab
sibling gates. New regression test TestRefResolver_AdminBearer_404EvenWithGrant.
2026-05-31 12:26:57 -04:00
xarmian a5c7fc986e fix(attachments): grant-aware upload auth so share-link editors can attach (BUG-1661) (#688)
handleUploadAttachment gated on requireMinRole("editor") — a workspace-level
check — but the editor and comment composer offer the paste/drop upload
affordance based on grant-aware edit permission. A grant-based editor (guest
with an item/collection edit grant via a share link, no workspace editor role)
could type/post but hit 403 on upload.

Server: read ?item_id early (before spooling the body); when present and
resolvable, authorize via requireEditPermission against the item's grant chain,
else fall back to requireMinRole("editor") for free-floating uploads (new-item
creation, storage settings). Reordered the nil/getWorkspaceID checks above auth.

Client: upload() now also sends item_id as a query param so the server can
authorize before spooling. Threaded the item UUID through Editor.svelte (both
mount sites) and CommentEditor.svelte (ItemTimeline composer + the 3
TimelineCommentCard composers via comment.item_id).

Test: TestUpload_GrantBasedEditorCanAttach — guest with an item edit grant gets
201 with ?item_id and 403 without it (confirms the editor-role fallback didn't
widen access).
2026-05-31 11:39:02 -04:00
xarmian be53856223 feat(share): include saved views in collection share payload (TASK-1681) (#682)
* feat(share): include saved views in collection share payload (TASK-1681)

Expose the collection's saved views on the public /s/{token} payload so the
read-only view switcher (TASK-1682) can render and toggle them. Fetched via
Store.ListViews (ordered by sort_order) and projected to a public shape
under collection.views — name, slug, view_type, config (parsed object),
is_default, sort_order — with internal UUIDs and timestamps stripped.
Always emits an array (never null); empty when the collection has no saved
views, so the switcher falls back to settings.default_view.

Extends the SharePayload TS type with PublicShareView + an optional
collection.views array (additive) for TASK-1682 to consume.

Parent: PLAN-1677.

* fix(share): pin distinct view sort_order in test per Codex review (round 1)

CreateView inserts sort_order=0 and now() is second-granularity, so the two
test views could tie on (sort_order, created_at) and SQL could return either
order, flaking the position-based assertion. Set explicit sort_order 0/1 and
assert on it.

Parent: PLAN-1677.
2026-05-31 01:15:38 -04:00
xarmian 873d351e24 feat(server): enrich collection share payload with settings, schema, item content (TASK-1678) (#680)
The public collection share-link resolver (`handleResolveShareLink`,
`collection` branch) previously returned only `{name, icon, description}`
plus a flat `{title, ref, fields}` per item. The public viewer at
`/s/{token}` therefore could not reproduce the owner's chosen view
type, grouping, field labels, or status colors, and had no body to
show for an inline read-only row expand.

Enrich the public collection DTO with:
- `collection.settings` — a presentation-only projection of
  CollectionSettings (`layout`, `default_view`, `board_group_by`,
  `list_sort_by`, `list_group_by`), emitted as a parsed JSON object.
  The authoring-only fields (`quick_actions`, `content_template`) are
  deliberately excluded from the public path.
- `collection.schema` — the parsed CollectionSchema object
  (`fields[]` with key/label/type/options/terminal_options/suffix),
  emitted as an object rather than a raw JSON string.
- `items[].content` — each item's markdown body, for the inline
  read-only row expand decided in TASK-1684.

Both settings and schema are parsed defensively: a malformed stored
JSON blob is simply omitted from the response rather than failing the
resolve. No internal IDs, creator info, workspace internals, or
timestamps are exposed. Adds an HTTP-level test asserting the enriched
shape and guarding against leakage of forbidden tokens.

Frontend integration (consuming this shape) is TASK-1680; security
review of the content exposure is tracked in TASK-1685.

Parent: PLAN-1677.
2026-05-31 00:58:59 -04:00
xarmian 1d9a611508 feat(api): bulk restore op for undo (TASK-1674 backend) (#675)
Add a 'restore' verb to the bulk endpoint so an undo of a bulk archive
is one call. The loop resolves include-deleted for restore (archived
rows are hidden from ResolveItem); applyBulkOp calls store.RestoreItem,
mapping UNIQUE-constraint races to a conflict and sql.ErrNoRows to
not-found, and logging action="restored". Also make
ResolveItemIncludeDeleted UUID-aware (mirrors ResolveItem) so restore
resolves by the ids the bulk response returns.

Adds 'restore' to the TS BulkItemOp / BulkItemsRequest union and a Go
test (archive → restore round-trip by id).
2026-05-30 22:28:34 -04:00
xarmian 57995c5898 fix(sync): moved-out tombstones for cross-visibility collection moves (BUG-1675) (#670)
* fix(sync): emit moved-out tombstones for cross-visibility collection moves (BUG-1675)

/items-changes filtered deltas by an item's CURRENT collection, so an
item moving from a collection a restricted member can see into one they
can't vanished with no eviction signal — the stale, now-unauthorized
row lingered in their local cache until a full rebootstrap.

Server:
- store.ListMovedOutSince: finds items that changed since the cursor,
  are now outside the caller's visible scope, and have a 'moved'
  activity FROM a collection the caller CAN see. Returns id+seq only —
  no destination data leaks (the caller has read access to the source).
- handleListItemsChanges merges these in as moved_out tombstones, then
  seq-sorts + caps the combined stream so pagination stays gap-free.
- Bulk collection moves now log a proper 'moved' activity with from/to
  collection slugs (mirroring handleMoveItem) — the signal the
  tombstone query reads. Previously they logged generic 'updated'.

Client:
- ItemChangeRow gains moved_out; applyDelta hard-evicts those ids from
  RAM + search and queues the IDB delete into the SAME atomic
  cursor-advance tx (persistDelta gains removeIds) so it can't
  resurrect on warm boot.

Full members (nil visibility) skip the extra query entirely — the path
only runs for restricted members/guests.

Tests: store-level matrix (ListMovedOutSince), end-to-end restricted
member /items-changes tombstone, bulk-move 'moved' activity logging.

* fix(sync): tie moved-out tombstone to the move event's seq per Codex review (round 1)

Keying the tombstone on the item's CURRENT seq meant any later change
while it sat in a hidden collection re-emitted a moved_out row — leaking
that an invisible item keeps mutating, and never settling. Stamp the
post-move seq into the 'moved' activity metadata (both single + bulk
move paths) and key the tombstone on THAT seq: it fires once, for the
move that crossed the visibility boundary, and the cursor settles past
it. Moves logged before the seq stamp are skipped (evict on rebootstrap)
rather than risk the re-fire.

Test: re-fire regression (a post-move hidden-collection update must not
re-emit the tombstone).

* fix(sync): page moved-out tombstones by move seq, not current seq per Codex review (round 2)

Ordering/capping candidates by the item's current seq could strand an
item that moved out early (low move seq) but later churned in the hidden
collection (high current seq): it fell past the limit while the cursor
advanced beyond its move seq, never to be emitted again. Collect all
eligible rows, keep the earliest qualifying move per item, sort by move
seq, then apply the limit at a move-seq boundary so dropped rows
re-fetch cleanly on the next poll.

Test: 3 items move out ascending; the earliest churns to a high current
seq; limit=2 must still return the two smallest move seqs, then the
third on the next page with no gap.

* fix(sync): durable item_collection_moves table for moved-out detection per Codex review (round 3)

Moved-out detection read the 'moved' activity row, which is written
after the move commits and best-effort (errors discarded) — so a delta
poll racing the audit write, or a failed write, could advance the cursor
past the move seq and strand the unauthorized item forever.

Record every cross-collection move in a new item_collection_moves table
inside the SAME transaction as the move (MoveItemWithPreCheck), carrying
the workspace seq the move assigned. ListMovedOutSince now reads that
table — fully SQL/indexed (from_collection_id IN visible, MIN(seq) for
multi-hop, current-collection NOT IN visible), no JSON parsing, no
best-effort dependency. The 'moved' activity stays for audit only.

Migration 066 adds the table + indexes. Tests updated to rely on the
durable record (MoveItem writes it) rather than hand-logged activity.

* fix(sync): add Postgres migration for item_collection_moves per Codex review (round 4)

Postgres reads the separate pgmigrations/ tree, so the SQLite-only
migration 066 left item_collection_moves absent on PG deploys — every
cross-collection move would fail at the in-tx insert and moved-out
queries would error. Add pgmigrations/045 with the equivalent table +
indexes.
2026-05-30 19:57:45 -04:00
xarmian dfd3811eee feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668) (#669)
* feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668)

Add POST /workspaces/{ws}/items/bulk accepting item IDs + a verb
(archive, move, tag, untag, set-priority, assign). The lane-header
bulk actions operate on a whole filtered lane, so the endpoint emits
ONE items_bulk_updated SSE event and ONE item.bulk_updated webhook for
the batch instead of per-item fan-out.

Reuses the existing store paths (UpdateItemWithPreCheck / MoveItem /
DeleteItem) rather than re-implementing writes; the open-children
guard runs per status-bearing move exactly as the single PATCH path
does (force-overridable). Per-row failures are collected into the
response envelope (updated/failed/total) rather than aborting the
batch. Editor/owner gated.

Frontend client + TS types follow in TASK-1669; UI wiring in TASK-1672.

Parent: PLAN-1667.

* fix(api): per-item visibility + collection-move guard on bulk endpoint per Codex review (round 1)

- Enforce per-item collection visibility (checkItemVisible) in the bulk
  loop so a member with collection_access="specific" can't bulk-mutate
  items in hidden collections by guessing refs; report invisible rows as
  not-found. Also gate the move target collection on visibility.
- Route bulk collection moves through MoveItemWithPreCheck with the
  open-children guard (destination schema), closing the bypass where a
  collection move + terminal status could mark a parent terminal with
  open children. Status-only moves already ran the guard.
- Tests: status-move + collection-move guard coverage (reject + force
  override + mutation-safety).

* fix(web): consume items_bulk_updated SSE event per Codex review (round 2)

The bulk endpoint emits one items_bulk_updated event, but the SSE
service only listened for the fixed ITEM_EVENTS list — so a bulk
mutation left other tabs/sessions stale until an unrelated sync fired.
Route the batch event through the existing sync_required path: it
carries item_ids + a max seq but no per-item field payload, so an
incremental /items-changes delta reconciles every affected row by seq.
Broadcast so peer tabs reconcile too.

* fix(api): scope bulk SSE event per-collection, drop item_ids per Codex review (round 3)

The batch event published with an empty Collection, which the SSE
filter treats as workspace-level: restricted members received bulk
events for hidden collections (leaking item_ids/op/count) while guests
with grants were dropped entirely and stayed stale.

Emit one items_bulk_updated event per affected collection with
Collection set, so the existing visibility filter routes it like any
collection-scoped event. Drop per-item IDs from the SSE payload — a
batch can't be item-grant-filtered for guests on a broadcast bus, so
IDs would leak; recipients reconcile via the /items-changes delta,
which is visibility-filtered server-side (Seq carries the cursor). The
webhook (a trusted workspace integration) keeps the full id list.

Test asserts the event is collection-scoped and carries no item_ids.

* fix(api): bulk collection move notifies both source and target scopes per Codex review (round 4)

A cross-collection move only emitted a batch event for the target
collection, so a restricted member watching the source lane wouldn't
reconcile the item leaving it. Notify both the source and target
collection scopes for moves (still no per-item IDs). Test asserts both
events fire.

* fix(api): suppress itemless batch SSE events for item-grant-only subscribers per Codex review (round 5)

A guest/restricted member with only item-level grants in a collection
could still receive the collection-scoped items_bulk_updated event
(itemless), learning op/count/timing for items they can't see. Extract
the SSE visibility filter into sseEventVisibleFor and add a rule:
itemless collection-scoped events go only to subscribers with FULL
collection access; item-grant-only subscribers reconcile their granted
items via the next resume/reconnect /items-changes sync instead.

Adds a unit test covering the visibility matrix.

* fix(api): validate status override against target schema on bulk collection move per Codex review (round 6)

A status override on a collection move was applied after MigrateFields
but never validated against the target schema, so an out-of-options
value (e.g. status=bogus) could be written. Run ValidateFields on the
final field map before the move. Test asserts the invalid value is
rejected per-row and the item stays put.
2026-05-30 18:41:55 -04:00
xarmian 076fb9b2e7 feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663) (#665)
* feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663)

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

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

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

Parent: PLAN-1662.

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

Now that TASK-1663 populates comments.user_id (FK to users.id),
DeleteAccountAtomic would fail on the FK for any user who authored a
comment. Null comments.user_id for the user before deleting the row —
comments live on in soft-deleted/other workspaces; the display-name
author is preserved and the comment just becomes admin-only to edit.
Regression test added.
2026-05-30 12:38:57 -04:00
xarmian e179c595e4 feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650) (#663)
* feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650)

Comment composers were plain textareas with no upload path, and comment
bodies rendered markdown without an attachment resolver — so a
`pad-attachment:UUID` reference would never display. This wires both
halves end to end:

- Compose: paste or drop files into the comment composer (ItemTimeline)
  and the reply box (TimelineCommentCard). A shared helper
  (commentAttachments.ts) splices an "Uploading…" placeholder at the
  caret, uploads concurrently via the existing attachment API, and swaps
  each placeholder for its `pad-attachment:UUID` markdown ref (image
  syntax for image MIMEs, link/chip syntax otherwise — mirrors the
  editor's split). Submit is gated while uploads are in flight.
- Display: ItemTimeline lazily HEAD-probes each referenced UUID (reusing
  the editor's fetchAttachmentMetadata cache), builds a reactive
  resolver, and threads it into renderMarkdown for comments and replies
  so refs render as inline images / file chips.
- Orphan GC: comment uploads leave attachments.item_id NULL (like the
  editor), but the GC reference scan only checked items.content/fields.
  Renamed AttachmentReferencedInItems -> AttachmentReferenced and
  extended it to scan comments.body, so a screenshot referenced only
  from a comment isn't reclaimed after the grace period. Added
  TestOrphanGC_KeepsAttachmentReferencedFromComment.

Follow-up refinement (thumbnails + click-to-expand lightbox) captured as
IDEA-1660.

* fix(comments): escape markdown-significant chars in attachment filenames per Codex review (round 1)

Filenames containing [ ] or backslash could break the generated
![name](pad-attachment:...) markdown. P2 (grant-aware upload auth) is a
pre-existing endpoint-wide gap shared with the rich editor — tracked as
BUG-1661, not fixed here to keep the PR focused.

* fix(comments): preventDefault on dragover for file drops per Codex review (round 2)

Browsers only deliver a file drop to a custom target if its dragover
cancels the default; without it the page navigates to the file. Gated
on isFileDrag so in-textarea text drag-drop is unaffected.
2026-05-30 10:57:31 -04:00
xarmian 1b1068537c feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653) (#658)
* feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653)

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

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

Parent: PLAN-1652.

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

COUNT(DISTINCT i.id) so an item with duplicate tags (e.g. ["ux","ux"]) is
counted once — the write path doesn't enforce per-item tag uniqueness.
Adds a regression test.
2026-05-29 23:43:02 -04:00
xarmian b68164a714 feat(report): opt-in 'what shipped' completed-items list (TASK-1641) (#651)
* feat(report): opt-in 'what shipped' completed-items list (TASK-1641)

Add ?include_items=true → completed_items[{ref,title,collection,completed_at}]
on the report: items that reached a positive terminal in the window, deduped
by item (newest completion first), capped at 500 with
completed_items_overflow_count. Same positive-terminal source as
totals.completed (joins live items, deleted_at IS NULL), so the list reconciles
with the count. Opt-in so the interactive dashboard stays count-only; the
print/export report (TASK-1642) requests it.

Web ReportData gains completed_items + the api.report.get includeItems flag.
Parent: PLAN-1628.

* fix(report): scope completed-items list to the item's current visible collection per Codex review (round 1)

The list scoped transitions by st.collection_id (visible at completion) but
returned the item's CURRENT title/ref/collection — so an item completed while
visible then moved to a hidden collection could leak its hidden collection
slug/prefix + current title to a restricted caller. Require i.collection_id to
be in the resolved (scoped) collection set on both the count and list queries.
Adds a move-to-hidden-collection visibility test.
2026-05-29 17:47:25 -04:00
xarmian 0d0c660565 feat(insights): navigate to past periods (offset + prev/next) (TASK-1639) (#649)
Add an `offset` to the report (periods back; 0 = current, clamped >= 0):
window becomes [now - (offset+1)*lookback, now - offset*lookback]. Throughput
and cycle-time shift automatically; response echoes `offset` + shifted range.

Backend: ReportOptions.Offset + ReportData.offset; handler parses ?offset=.
Web: api.report.get passes offset; ReportData.offset typed.
Insights page: ◀ Previous / Next ▶ controls (Next disabled at offset 0) + a
period label; offset is session-only (not persisted to the layout); resets on
window or workspace change. Interim: WIP + status-distribution are hidden when
viewing a past period (they're as-of-now) with a note — TASK-1640 reconstructs
them historically.

Parent: PLAN-1628.
2026-05-29 16:59:37 -04:00
xarmian eeff78118b feat(insights): per-user layout customization + persistence (TASK-1634) (#645)
* feat(insights): per-user layout customization + persistence (TASK-1634)

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

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

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

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

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

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

GET /workspaces/{ws}/report?window=week&collections=tasks,bugs returns a
time-bucketed report: created-vs-completed throughput, net flow,
completed-by-collection, and a current status-distribution snapshot.

- Dialect.DateBucket(column, granularity) — day/hour bucketing via fixed-width
  substring on the UTC RFC3339 TEXT (identical + exact on SQLite + Postgres;
  avoids SQLite 'Z'-parsing fragility). Routes all report date math through it.
- store.GetReport: resolves per-collection done field + positive terminals
  (terminal options minus rejected/cancelled/etc.), counts completions from
  status_transitions and created from items.created_at, zero-fills buckets.
- HTTP handler + route; web ReportData type + api.report.get client.
- Tests: throughput/totals, negative-terminal exclusion, status distribution,
  collection filter, non-status done-field, out-of-window exclusion, hourly
  day-window, DateBucket per granularity. Dual-dialect via testStore.

Fixes the response contract that TASK-1632/1633/1635 consume (noted on them).
Parent: PLAN-1628.

* fix(report): scope report to caller's visible collections per Codex review (round 1)

The endpoint sits under RequireWorkspaceAccess (members, restricted members,
guests), but GetReport resolved ALL workspace collections — letting a caller
with access to one collection infer hidden collections' slugs, created/
completed counts, and status distribution. Mirror the dashboard: the handler
computes visibleCollectionIDs() and GetReport restricts to that set
(ScopeToVisible). Empty visible set → empty report. Aggregate reports are a
full-collection-visibility feature; item-level grants aren't surfaced in
workspace-wide counts.

* fix(report): correct visibility scoping for all-access + item-grant callers per Codex review (round 2)

Round 1's scoping had two bugs in how it read visibleCollectionIDs:
1. nil means "all-access" (admin / collection_access=all), but the handler
   treated nil as an empty visible set → all-access users got an EMPTY report.
   Now nil → ScopeToVisible stays false (full workspace report).
2. For guests, visibleCollectionIDs includes collections visible only via
   item-level grants; passing those to the aggregate report leaked the whole
   collection's counts. Now mirror the dashboard: when item-level grants are
   present, scope to fullCollIDs (full-access collections only).

Adds report handler tests (owner full report + default window) alongside the
store-level scoping test.

* fix(report): bearer-aware admin visibility scoping per Codex review (round 3)

visibleCollectionIDs grants ANY platform admin an unrestricted (nil) view, but
RequireWorkspaceAccess suppresses the platform-admin bypass for bearer auth and
falls through to membership (BUG-1616/1617). So a bearer admin (PAT/CLI/OAuth)
who is only a restricted workspace member could read the full workspace report.

Extract reportVisibleCollections(): gate the admin bypass on cookie auth; for
everyone else resolve actual member/guest visibility, and when item-level
grants exist scope to the full-access collection set only. Adds a cookie-vs-
bearer scoping test (cookie admin unrestricted, bearer restricted-member scoped
to the granted collection, end-to-end through GetReport).

* fix(report): exclude soft-deleted items from completion counts per Codex review (round 4)

status_transitions rows survive a soft delete (only a HARD delete cascades
them), so a completed-then-soft-deleted item still counted toward completed /
completed_by_collection while created and status_distribution (which filter
deleted_at IS NULL) excluded it — inconsistent totals. Join live items in both
completed queries. Adds a regression test.
2026-05-29 08:24:05 -04:00
xarmian 08f76f3486 fix(deps): bump go-jose/v3 to v3.0.5 to clear GO-2026-4945 (BUG-1619) (#634)
* fix(deps): bump go-jose/v3 to v3.0.5 to clear GO-2026-4945 (BUG-1619)

GO-2026-4945 — Go JOSE panics in JWE decryption. Reachable via
github.com/ory/fosite v0.49.0 from internal/server/handlers_oauth.go's
handleOAuthAuthorize (govulncheck call chain into jose.ParseSigned /
JSONWebSignature.Verify / etc).

Drop-in dependency bump, no API changes. Post-bump govulncheck reports
0 reachable vulnerabilities.

Discovered during v0.6.0 release pre-flight (PLAYB-1160 step 1) — CI
run 26518095093 failed govulncheck after the most recent main push,
even though nothing about that change touched OAuth. Same shape as
TASK-1583 (the golang.org/x/net bump earlier this cycle).

* test(open-children-guard): filter readParentRef by SourceRef to fix race flake (BUG-1621)

readParentRef was matching the first LinkType=="parent" link in
GET /items/{ref}/links's response — but GetItemLinks returns links
in BOTH directions (WHERE source_id = ? OR target_id = ?). After
TestOpenChildrenGuard_PatchAtomicRejectionPreservesParentLink attaches
a child task to its target plan, the response contains two parent
links: target→oldParent AND child→target. ORDER BY created_at DESC
ties at sub-microsecond resolution; SQLite tiebreaks on rowid; under
race-scheduler CI load the child→target link sometimes lands first,
the helper returns the target's OWN ref as the "parent", and the
setup-sanity assertion fails with "parent should start as PLAN-1,
got PLAN-3" (where PLAN-3 = target itself).

Filter on SourceRef == itemRef so only the item's OUTGOING parent link
matches. Test-file-only change.

Latent since PR #571 (IDEA-1494, e59d390). Caught now during v0.6.0
release pre-flight on PR #634.
2026-05-27 13:21:57 -04:00
xarmian 83716a65fc fix(backlinks): scope cross-workspace admin enumeration to membership for bearer auth (BUG-1617) (#633)
Companion to BUG-1616. The admin platform role granted unrestricted
cross-workspace visibility at the STORE layer too: `GetCrossWorkspaceBacklinks`
ran `ListWorkspaces()` for any user with `Role=admin`, and
`ResolveBacklinksVisibility` short-circuited to `(nil, nil)` for the
same role check. Both fired BEFORE the BUG-1616 middleware gate could
deny the request, so a bearer-borne admin (CLI / PAT / MCP) could
enumerate cross-workspace backlinks from every workspace on the server.

Policy: bearer-borne admin gets STRICT membership enumeration — no
guest-grants fallback. Matches RequireWorkspaceAccess's membership-only
stance from BUG-1616. Cookie-session admin keeps the global view
(preserved web-UI affordance).

Threads `authIsBearer bool` from the HTTP boundary (via the new
isBearerAuth helper) into the store layer:

- `Store.ResolveBacklinksVisibility` — admin bypass now gated on
  `!authIsBearer`; bearer-admin falls through to the regular
  member/grants pipeline. Also tightens the "no visibility" return
  shape from `(nil, nil)` to non-nil empty slices so callers can
  distinguish "unrestricted" from "explicit empty" — closes a
  latent ambiguity that doesn't fire in current callers but would
  if any future caller bypassed the upstream membership filter.
- `Store.GetCrossWorkspaceBacklinks` — new switch:
    - cookie admin       → ListWorkspaces (unchanged)
    - bearer admin       → GetUserMemberWorkspaces (NEW, strict
                            membership; no grants fallback)
    - non-admin          → GetUserWorkspaces (unchanged; memberships
                            ∪ guest-grant workspaces)
- `Server.guestResourceFilterCore` — admin short-circuit now gated
  on `!isBearerAuth(r)`; bearer-admin delegates to the store-side
  helper with the bearer signal threaded through.
- `handlers_backlinks.go` — pass `isBearerAuth(r)` to
  `GetCrossWorkspaceBacklinks`.

New `Store.GetUserMemberWorkspaces` helper — the first half of
`GetUserWorkspaces` without the UNION-with-grants block. Used by the
bearer-admin path; existing callers continue to use `GetUserWorkspaces`
unchanged.

Tests:

- `wiki_links_xws_test.go`:
  - Updated `TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces` to
    cover both `authIsBearer=false` (cookie, sees all) and
    `authIsBearer=true` (bearer, sees none) cases.
  - NEW `TestWikiLinks_CrossWorkspaceBearerAdminGrantOnlyWorkspaceFiltered`
    — bearer-admin with a guest grant on workspace C still gets ZERO
    cross-ws rows from C (Codex round-2 finding).
  - NEW `TestWikiLinks_CrossWorkspaceBearerAdminSeesMemberWorkspaces`
    — positive control: bearer-admin who IS a member sees the row.
  - Extended `TestResolveBacklinksVisibility_RoleMatrix` with two
    bearer-admin subtests (non-member workspace → empty; member
    workspace → unrestricted).
- NEW `handlers_backlinks_admin_bearer_test.go::TestCrossWorkspaceBacklinks_AdminBearer_OnlySeesMembershipWorkspaces`
  — full HTTP integration test, both cookie and PAT-bearer subtests.
- All existing callers updated to pass `false` for `authIsBearer`
  (preserves current cookie-session / non-admin behavior).

Verification: full `go test ./...` green; `make lint` clean;
Codex round 2 review CLEAN.

🤖 BUG-1617
2026-05-27 10:37:30 -04:00
xarmian f48c99e421 fix(auth): scope admin platform role to cookie session auth (BUG-1616) (#632)
The admin platform role granted owner-level access to every workspace on
every surface, including bearer-borne callers (PATs on /api/v1, CLI
session bearers, PATs/OAuth on /mcp). A user with "All current
workspaces" consent on an MCP client — or a leaked admin token of any
kind — could reach data the admin never joined.

Policy: the admin global bypass now fires only for cookie session auth
(web UI / SPA / /console/admin). Bearer-borne callers fall back to a
strict workspace_members check (membership-only; no guest-grants
fallback either).

Gated four sites in lockstep:

- RequireWorkspaceAccess (internal/server/middleware_auth.go) — covers
  /api/v1/* routes; emits the existing not_a_member MCP authz denial
  metric on bearer-admin denials.
- handleSSE entry (internal/server/handlers_events.go) — adds an
  explicit GetWorkspaceMember check for bearer-borne admin after
  resolveWorkspace's global slug lookup.
- sseSubscriberStillHasAccess (internal/server/handlers_events.go) —
  per-tick revalidation now matches entry-time policy.
- computeSSEVisibility (internal/server/handlers_events.go) —
  bearer-admin gets a real VisibleCollectionIDs filter instead of
  "no filtering"; a bearer-admin who's a member with
  collection_access=specific is now correctly scoped.
- authorizeCollabAccess (internal/server/handlers_collab.go) —
  WebSocket collab upgrade gate; same membership-only stance.

New shared helper isBearerAuth(r) folds two signals (Authorization:
Bearer header OR ctxIsAPIToken stash) so MCP-dispatcher synthesized
requests and CLI session bearers are both covered. Mirrors the dual
check middleware_csrf.go already uses.

Tests (9 total):

- middleware_auth_admin_token_gate_test.go (5) — PAT denied/allowed
  permutations, CLI session-bearer denial, cookie-session bypass
  preserved.
- handlers_admin_bearer_gate_test.go (4) — SSE revalidation,
  visibility filter, collab WebSocket auth.

Companion BUG-1617 (store-layer admin bypass in backlinks visibility)
tracked separately.
2026-05-27 10:09:30 -04:00
xarmian 225fb4a53f Wire upgrade CTAs with Stripe-ready billing flow (TASK-800) (#629)
* feat(billing): add billing_available session flag gated on PAD_BILLING_AVAILABLE (TASK-800)

Add Server.billingAvailable field set by SetBillingAvailable(), called from
cmd/pad/main.go when PAD_BILLING_AVAILABLE=true|1. Expose the flag as
billing_available in both the setup-state and authenticated session payloads
(value: cloudMode && billingAvailable) so the web UI can gate Stripe CTAs
without a code change at deploy time. False by default.

* feat(billing): wire upgrade CTAs, checkout POST flow, plan section, clickable limit toasts (TASK-800)

Frontend prep work gated on authStore.billingAvailable (from billing_available
session field). When false, upgrade buttons remain hidden and the "coming soon"
note stays in place — flip PAD_BILLING_AVAILABLE=true at deploy time.

Changes:
- client.ts: add billing_available to AuthSession; add api.billing.createCheckoutSession()
  (POST /billing/checkout → parse {url} → caller does window.location.href)
- auth.svelte.ts: billingAvailable getter
- console/billing: replace STRIPE_AVAILABLE=false with $derived(authStore.billingAvailable);
  fix GET→POST on upgrade buttons; add ?checkout=cancelled banner; add cancelled style
- console/settings: new cloud-mode-gated "Plan" section with current plan + upgrade/manage link
- All 11 limit-hit sites: replace plain-text '/console/billing' appendage with
  toastStore.show(msg, 'error', 6000, '/console/billing') so the toast is clickable

* docs(billing): document pad-cloud CSRF and error-envelope contract divergences in createCheckoutSession (TASK-800)
2026-05-25 13:18:43 -04:00
xarmian 342679a364 Standardize plan-limit error envelope across HTTP/MCP/CLI/UI (TASK-788) (#628)
* fix: limit-hit responses were actively broken — garbled toasts, no upgrade signal

The limit enforcement responses (plan_limit_exceeded on 403) used a flat
body shape {"error": "plan_limit_exceeded", ...} that is incompatible with
every consumer: the frontend PadApiError parser, the CLI parseError path,
and the MCP classifyHTTPStatusKind all expect {"error": {"code": ...,
"message": ...}}. As a result, hitting any of the 5 plan limits (items,
members, workspaces, api_tokens, webhooks) produced garbled toasts with
undefined message text and zero upgrade signal.

Fix:
- writePlanLimitError now emits the standard nested error envelope with a
  human-readable message sentence and limit details in error.details.
- CLI parseError now correctly surfacing the message (net positive, no
  code change needed).
- MCP classifyHTTPStatusKind: adds ErrPlanLimitExceeded to the taxonomy
  and the allowedStructuredErrorCodes whitelist so 403 plan-limit errors
  pass through with code + details rather than collapsing to
  ErrPermissionDenied (TASK-788).
- Frontend: exports isPlanLimitError() type-guard and planLimitMessage()
  formatter from client.ts; all 4 limit-hit write call sites (item create,
  member invite, workspace create, token create) now branch on the code and
  show an upgrade-signal message pointing at /console/billing.
- Test: updates handlers_workspace_cap_test.go to the new body shape; adds
  TestPlanLimitError_ResponseShape covering members_per_workspace limit hit.

TASK-788

* fix(R1): cover MCP stdio path, 5 more item-create sites, polish message wording

Finding A — MCP stdio transport was missing plan-limit coverage:
- cli/client.go: add PlanLimitDetails struct, AsPlanLimit() helper, and
  WritePlanLimitError() that emits the pad-structured-error/v1 marker so
  the MCP stdio classifier can lift code + details instead of falling
  through to ErrServerError.
- cmd/pad/main.go: wire the WritePlanLimitError branch into all three
  CreateItem call sites (item create, convention activate, playbook activate).
- internal/mcp: add TestClassifyHTTPStatus_PlanLimitPreservesCodeAndDetails,
  TestClassifyHTTPStatus_Generic403FallsToPermissionDenied,
  TestClassifyExecError_PlanLimitMarkerLiftsStructuredPayload, and
  TestClassifyExecError_PlanLimitWithoutMarkerFallsThrough.

Note: extractUpstreamErrorEnvelope already parses details (json.RawMessage
field) — the codex concern about it being silently empty was a false alarm;
no fix needed there.

Finding B — 5 more item-create entry points were unguarded:
- EditorBubbleMenu.svelte (inline wiki-link capture)
- Sidebar.svelte (quick-add)
- roles/+page.svelte (board new-item, was console.error only; adds toastStore)
- conventions/+page.svelte
- playbooks/+page.svelte (both create and duplicate paths)

B1/B2 polish — server message is now statement-of-fact only, no doubled
upgrade verb. planLimitMessage() drops "Upgrade to Pro to add more." (each
surface appends its own CTA). limitStr uses hyphenated adjective form
"3-member" / "10-item" (compound modifier before "limit").

TASK-788

* feat(task-788): extend MCP-stdio plan-limit coverage to workspace, invite, webhook

Wire WritePlanLimitError into three additional CLI command error paths so
the MCP stdio classifier surfaces ErrPlanLimitExceeded with details instead
of falling through to ErrServerError:

- workspaceCreateCmd: check before fmt.Errorf wraps the APIError
- inviteCmd: check before returning the raw error
- webhooksCreateCmd: check before returning the raw error

Add TestClassifyExecError_PlanLimitWorkspaceCreate to exercise the full
workspace-create stdio round-trip through classifyExecError, asserting
ErrPlanLimitExceeded code, feature="workspaces", limit, and upgrade_url.

Token create intentionally left bare (agents don't drive token creation).
2026-05-25 11:46:41 -04:00
xarmian a7fb14ee14 Reduce free-tier workspace cap from 5 to 3 (TASK-1609) (#627)
Lower DefaultFreeLimits.Workspaces from 5 to 3 to sharpen the
price-discrimination delta between Free and Pro ahead of Stripe
Live (PLAN-1570 Phase a). Add IDEA-1611 comment on the soft-deleted
workspace count behavior. Add store-level and handler-level tests
covering the new boundary, pro-tier bypass, override path, and
self-hosted no-limit path.
2026-05-25 09:24:57 -04:00
xarmian 3a04c06684 fix(backlinks): suppression must query item_links, not items.parent_id (TASK-1607 followup) (#626)
* fix(backlinks): suppression must query item_links, not items.parent_id (TASK-1607 followup)

The initial TASK-1607 fix routed the parent↔child "Mentioned in"
suppression through `items.parent_id`. That column is empty in
production — 0 of 3,923 items in a live workspace had it set —
because the API path (handlers_items.go::handleCreateItem) writes
parent relationships via Store.SetParentLink, which only touches
the `item_links` table with link_type='parent' (source=child,
target=parent — see migration 023). The deprecated parent_id
column is vestigial. Result: the filter was a no-op everywhere
the user actually encountered the duplication.

Caught when testing on fhir-core/TASK-397 — a task with ~20
children all wiki-linking it, none filtered. Direct sqlite
inspection confirmed: parent_id NULL on every row, but 2,022
'parent' rows in item_links workspace-wide.

The fix:

- Replace the SQL predicate in GetBacklinks and CountBacklinks
  with two NOT EXISTS subqueries against item_links — one for
  "source is not a child of target", one for "source is not the
  target's parent". Both use idx_links_source / idx_links_target
  for O(1) lookups per candidate row.
- Drop the TargetParentID field from BacklinksVisibility. The
  store no longer needs the handler to plumb it through — the
  parent relationship is queried from item_links directly. Same
  pattern as children-suppression: both are unconditional and
  self-contained.
- Update handlers_backlinks.go to remove the dead plumbing.
- Rewrite createChildItem test helper to use SetParentLink (the
  production path) instead of CreateItem with ParentID (the dead
  column path). The original test helper made all the
  suppression tests pass falsely against the wrong storage.
- Add TestWikiLinks_SuppressionUsesItemLinksNotParentIDColumn:
  sets up the parent ONLY via SetParentLink, asserts parent_id
  column stays NULL, asserts suppression still works. Regression
  guard against routing the filter back through the dead column.

The existing 4 suppression tests still pass against the corrected
mechanism (they now exercise SetParentLink under the hood).
Cross-workspace path remains untouched — item_links is workspace-
scoped same as parent_id was, so the doc comment on
GetCrossWorkspaceBacklinks stays valid.

Refs IDEA-1601.

* fix(backlinks): consult items.parent_id alongside item_links for suppression (Codex P2)

Codex review of the prior commit raised a P2: ItemCreate.ParentID
and ItemUpdate.ParentID still write to items.parent_id directly
without creating an item_links row (handlers_items.go only calls
SetParentLink for the `parent` field path, not for direct
ParentID JSON). The HTTP/CLI path is unaffected — empirically
zero items in production have parent_id set — but a direct
store-API caller (test, future import path, or any code that
bypasses the handler) could leave the suppression invisible
to one of the two storage shapes.

Belt-and-suspenders: the relClause now AND's two pairs of
predicates so suppression triggers whichever shape the parent
relationship lives in.

- Children-suppression: NOT EXISTS item_links AND items.parent_id
  comparison
- Parent-suppression: NOT EXISTS item_links AND a correlated
  subquery against items (NOT EXISTS items t WHERE t.id = ?
  AND t.parent_id = s.id) so we don't have to plumb target's
  parent_id through

New regression test (TestWikiLinks_SuppressionFallsBackToItemsParentIDColumn)
exercises the column-only path: CreateItem with ParentID, NO
SetParentLink, asserts items.parent_id IS set and item_links is
empty, then asserts suppression still works in both directions.

CountBacklinks updated identically to stay in lockstep with
GetBacklinks pagination math.

* fix(backlinks): suppression must cover childLinkTypes ('parent' AND 'implements') (Codex round 2)

Codex round 2 caught: the Child Items panel and GetChildItems
both inflate `childLinkTypes = {"parent", "implements"}` (see
items.go:18). My filter only suppressed link_type='parent'. An
'implements' child therefore still appeared in Mentioned in
even though it was visually duplicated in the Children section
above.

Use childLinkTypeSQL() in both NOT EXISTS subqueries (children
direction and parent direction) so the filter stays in lockstep
with the canonical inclusion rule. If a future link type is
added to childLinkTypes, the suppression picks it up automatically.

New regression test
TestWikiLinks_SuppressionCoversImplementsChildLinkType wires a
child via CreateItemLink(link_type=implements) and asserts the
mention is suppressed — pins the lockstep with childLinkTypes.

This was the actual mechanism for the original fhir-core/TASK-397
report: TASK-441 et al. carry "**Implements:** [[TASK-397]]" in
their bodies AND are linked as 'parent' (Pad uses 'parent' for the
explicit hierarchy in that workspace), but a sibling case where
only 'implements' was set would have leaked through round 1's fix.
2026-05-24 21:27:38 -04:00