mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 11:52:08 +00:00
7c0b13767f03ca6ea0341d1acbb1d8a81042b721
468 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
010af13abd |
fix(ci): gofmt internal/models/workspace.go to unbreak Go job (BUG-1911) (#784)
The mid-struct doc comment added in #781 split the Workspace struct into two gofmt alignment groups; the file landed without re-running gofmt, leaving golangci-lint red on main and every PR since. Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS |
||
|
|
e32bf9289b |
fix(cli): detect machine-level agent tools, not just project-local (BUG-1156) (#783)
DetectTools() only checked project-local dirs (.codex, .claude, etc.), so a machine with Codex installed but no project-local dirs was invisible to `pad init` — only the force-included Claude skill got installed. Widen detection to OR three signals per tool: project-local dir (existing), a home-relative dir, and a binary on PATH. Machine-level signals are only populated for claude and agents (codex/cursor/windsurf); copilot/amazon-q/ junie keep project-local-only detection since their binaries/dirs are too ambiguous to trust as machine-wide signals. |
||
|
|
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 |
||
|
|
df3cf55d8f |
fix(mcp): stop cloud session-workspace bleed across users (BUG-1865) (#780)
The cloud /mcp transport constructed a single process-global WorkspaceState shared across every OAuth user and MCP session. pad_set_workspace mutated it, and env.Dispatch injected the shared value into any tool call without an explicit `workspace` — so one session's selection bled into another's (cross-user, and across a single user's concurrent sessions). Not a cross-tenant write: BUG-1616's bearer-auth membership gate already 403s a non-member. The real harm is workspace-slug leakage, confusing "not a member of <someone else's ws>" errors, and wrong-destination reads/writes among a user's OWN accessible workspaces. Fix: add NewSharedWorkspaceState() whose ResolveDefault() always returns "". The cloud mount uses it, so the shared value is never injected as a per-call default — resolution falls back to explicit workspace= (or the per-user maybeInjectWorkspace default), never cross-user shared memory. pad_set_workspace on a shared state no longer persists and returns status=not_persisted. Local `pad mcp serve` (single-user-per-process) is unchanged. Also make the agent-facing surfaces honest per-deployment: the pad_set_workspace tool description, the pad_item workspace param, pad_meta tool-surface, and the embedded instructions.md no longer promise session defaulting on multi-user/remote servers, and the two "workspace is required" hints drop the stale pad_set_workspace reference. Regression guards in internal/mcp/bug1865_test.go. Full suite green. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
b7bedc89df |
fix(web): resolve item-detail wiki-links via local-first index, not full /items (#770)
Detail pages loaded the full content-bearing /items (~4.7MB) just to resolve [[wiki-links]], stalling/timing out the page; list pages were fine because they use the local-first localIndex read model. Move the detail page + editor [[ picker onto localIndex (getAll accessor; zero extra fetch on warm nav). Harden SQLite: bound the connection pool + periodic wal_checkpoint(TRUNCATE). Codex review clean (P1 collab-flush ws, P2 inline-create ws — both fixed). Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
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 |
||
|
|
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 |
||
|
|
665f1918a7 |
feat(cli): headless admin bootstrap via --email/--name/--password (BUG-988) (#761)
* feat(cli): headless admin bootstrap via --email/--name/--password (BUG-988) Adds non-interactive flags to `pad auth setup` and `pad init` so agents running inside Claude Code or other non-TTY environments can bootstrap a fresh Pad instance without hitting interactive prompts that block forever. - New flags --email, --name, --password on both commands; all three must be supplied together when any one is present (clear error naming the missing flag otherwise). Checked before the remote-mode guard so the headless path works on any server host — the loopback gate is enforced server-side. - runHeadlessSetup() drives the existing POST /api/v1/auth/bootstrap endpoint directly, saves credentials, and respects --format json (emits a LoginResponse-shaped object with user + token). Already-initialized conflict produces a structured JSON error object under --format json. - `pad init` slots headless bootstrap into the bootstrap step only; the rest of init (config, workspace creation, skill install) continues. - Hardens readPassword() with an early non-TTY guard (generic message). - Hardens promptAndBootstrap() with a bootstrap-specific non-TTY guard (points at --email/--name/--password flags) so --cli-prompt on a pipe exits immediately rather than blocking. - Extends `pad init` non-TTY error message to mention the new flags. - Five new tests in cmd/pad/setup_headless_test.go covering success, missing-flag validation, non-TTY guard, already-initialized conflict, and the full init flow including workspace creation. NOTE: --password is visible in process listings (inherent to flag-based injection). Env-var bootstrap (PAD_ADMIN_*) is the tracked follow-up. Claude-Session: https://claude.ai/code/session_01WK9cUjxniBBAihGD5ygjDr * fix(cli): thread bootstrap token, factor shared core, restore readPassword fallback Round-1 codex findings: 1. Bootstrap token not sent on headless path (BLOCKER) Add BootstrapWithToken(email, name, password, token) to cli.Client that sets X-Bootstrap-Token when token is non-empty. Export ReadBootstrapToken from internal/cli/bootstrap.go (was readBootstrapToken) so cmd/pad can call it. Extract doHeadlessBootstrap(cfg, client, email, name, password) as the shared core for both setupCmd and padInitCmd: reads the on-disk token best-effort (absent → empty → loopback gate still covers that case), calls BootstrapWithToken, saves credentials, sets auth token on client. Both headless paths now go through this single function — no divergence. 2. readPassword bufio fallback removed by accident (REGRESSION) Restore the pre-round-1 bufio fallback in readPassword so piped-password flows (e.g. pad auth login --interactive in CI) keep working. The bootstrap wedge is already prevented by the top-of-promptAndBootstrap TTY guard; the generic readPassword fallback is only reached by non-bootstrap callers. 3. Shared core (CLEANUP) padInitCmd now calls doHeadlessBootstrap instead of duplicating Bootstrap + saveCredentials + SetAuthToken. The --format json asymmetry (init vs setup) is resolved by design: pad init is a multi-step flow; for machine-readable bootstrap output agents should use `pad auth setup --email … --format json`. Documented in the inline comment on the headless branch in padInitCmd. Tests added: TestHeadlessSetupSendsBootstrapToken, TestHeadlessSetupNoTokenFileOK, TestReadPasswordFallback. Update internal/cli/bootstrap_test.go for the rename. Claude-Session: https://claude.ai/code/session_01WK9cUjxniBBAihGD5ygjDr * BUG-988 round-2: surface token-read errors, wrap 403 with hint, rescope readPassword test doHeadlessBootstrap: distinguish os.ErrNotExist (absent token → best-effort empty, proceed without header) from other read errors (permissions, etc. → surface with the file path so operators can diagnose rather than silently hitting a confusing 403). Wrap 403/forbidden from BootstrapWithToken with an actionable multi-bullet hint covering loopback gate, token-file path, and PAD_BYPASS_SETUP_TOKEN. ReadBootstrapToken (internal/cli/bootstrap.go): add %w to the ErrNotExist branch so errors.Is(err, os.ErrNotExist) propagates to callers; existing tests and --cli-prompt hint text preserved. TestReadPasswordFallback → TestReadPasswordBufioFallback: rescoped to assert readPassword isolation only; added comment citing BUG-1886 (pre-existing doInteractiveLogin double-bufio.Reader bug). BUG-1886 filed in docapp. |
||
|
|
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).
|
||
|
|
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 |
||
|
|
bf40cc9946 |
feat(artifact): MCP pad_item export/import actions (#758)
* feat(artifact): MCP pad_item export/import actions Phase 5 of PLAN-1867. Adds export/import to the pad_item MCP tool: - action=export — forces stdout (--output -), returns the artifact text. - action=import — accepts the artifact body via a new `artifact` param, writes a temp file, dispatches `item import`, returns ref+warnings. Bumps ToolSurfaceVersion 0.6 → 0.7 (additive, backwards-compatible) and updates the CLAUDE.md MCP version reference. Implements TASK-1881, TASK-1882. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ * fix(artifact): wire MCP export/import over the HTTP transport Addresses Codex Phase-5 review: the new pad_item export/import actions worked over local stdio (ExecDispatcher) but not the HTTP MCP transport. - item export → routeSpec GET /items/{ref}/export (models item show). - item import → custom dispatchItemImport sending the raw artifact as a text/markdown body to /import-artifact (RouteMapper only sends JSON bodies), reusing buildAuthedRequest (scope check) + packageHTTPResponse. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
285a58e40e |
feat(artifact): pad item export/import CLI commands (#756)
* feat(artifact): pad item export/import CLI commands Phase 3 of PLAN-1867. - pad item export <ref> [-o file] — writes a playbook/convention as a portable <slug>.pad.md artifact (or stdout via -o -). - pad item import <file> — POSTs the artifact (or stdin via -), prints the new draft ref + slug and any server warnings (coerced fields, renamed slug). Adds ExportItemArtifact/ImportArtifact client methods. Implements TASK-1876, TASK-1877. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ * fix(artifact): harden CLI export file write Addresses Codex Phase-3 review: - filenameFromContentDisposition reduces to filepath.Base with safe fallbacks — a hostile Content-Disposition can't traverse/abs-write. - export writes atomically (temp + Sync + Rename) like attachment download, so a failed write can't truncate/leave a partial artifact. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
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
|
||
|
|
7ea552fa9b |
feat(artifact): MD+frontmatter export/import core for playbooks & conventions (#754)
* feat(artifact): add MD+frontmatter export/import core for playbooks & conventions Pure-Go internal/artifact package: Encode/Decode a single playbook or convention item to a portable Markdown + YAML-frontmatter artifact with deterministic key order and a provenance block. Field-key tables map item fields to frontmatter per kind; FieldKeysForKind is exported for the server layer. Round-trip + golden + determinism + error-case tests. Server-side validation, forgiving coercion, slug-collision handling, and YAML input limits are deferred to Phase 2 (HTTP boundary). Implements Phase 1 of PLAN-1867 (TASK-1868, TASK-1869, TASK-1870). Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ * fix(artifact): tolerate CRLF line endings on decode Normalize CRLF to LF at Decode's entry point so artifacts authored or transported on Windows decode identically (fence detection + body preservation operate on canonical LF). Addresses Codex P2 round 1. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
db1472123e |
test(collections): drift-guard for NL-canonical playbook invocation (TASK-1862) (#752)
Backstops PLAN-1858 / IDEA-1846 against regression: a denylist test that
fails when agent-facing copy reframes `/pad <slug>` as THE invocation form.
internal/collections/invocation_framing_test.go scans SKILL.md, the MCP
server instructions, and the four rendered seeded playbook bodies (plan /
decompose / onboard / ship) for canonical-framing phrasings ("maps directly
to /pad", "invoke via /pad", "say /pad …", "directly invokable as /pad",
"canonical /pad", "dispatches /pad", "Playbooks available: /pad"). It also:
- asserts the NL-canonical principle stays stated in SKILL.md (so the scan
can't pass vacuously after a deletion), and
- self-checks that each banned regex matches its own representative example
(so a typo can't silently neuter a pattern).
Deliberately a low-false-positive backstop, not a generator: labeled
shortcuts ("/pad ship in Claude Code") and slug-routing examples are fine.
The companion convention CONVE-1863 (created as workspace data) covers the
surfaces this test doesn't mechanically scan (MCP catalog/prompt Go consts,
the web UI).
Parent: PLAN-1858 (final task).
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
|
||
|
|
a2827ecb33 |
feat(playbooks): NL-canonical invocation in seeded bodies + CLAUDE.md (TASK-1861) (#751)
De-hardcode `/pad <slug>` as the primary invocation form across user/agent- facing copy: - Seeded playbook bodies (plan / decompose / onboard): the cross-references and recap lines now lead with intent / the playbook name, with `/pad` labeled as the Claude-Code shortcut where shown. - CLAUDE.md: the invocation_slug description, the library section (now also reflecting the `▶ <slug>` chip from TASK-1860), the onboarding sections, and the needs_onboarding nudge quote (updated to the shipped NL-canonical active offer from PLAN-1847). Left Go `//` dev comments as shorthand — not user-facing, and out of scope for the drift-guard (TASK-1862 scopes to body strings + markdown). Parent: PLAN-1858. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
7ec19b5f7f |
feat(skill): NL-canonical playbook invocation in SKILL.md + MCP catalog (TASK-1859) (#749)
* feat(skill): NL-canonical playbook invocation in SKILL.md + MCP catalog (TASK-1859) Generalizes PLAN-1847's onboarding treatment to every invokable playbook. The invocation-model intro now establishes that natural language is the canonical way to invoke a playbook and `/pad`·`$pad`·`pad_playbook run` are per-surface shortcuts; the greeting, the rendered intent-match message, the examples preface, the plan/decompose routing entries, the "creating a playbook" section, and the Planning/Decomposition workflow subsections all lead with intent and label the slug forms as shortcuts. catalog_playbook.go's tool description gets the same reframing. Left untouched: the skill's own `/pad <anything>` entry-point syntax (that's Claude Code skill invocation, not a playbook slug) and onboarding (already NL-canonical from PLAN-1847). The plan/ideate/retro MCP prompts had no `/pad <slug>` references. Parent: PLAN-1858. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ * fix(skill): use pad_playbook tool's action/ref form, not CLI-style 'run', per Codex review (round 1) Round-1 review noted that 'pad_playbook run <slug>' reads as a non-existent CLI command — pad_playbook is an MCP tool invoked with action: run, ref: <slug>. Reworded all MCP-shortcut mentions in SKILL.md and the catalog tool description to the structured tool-call form so agents don't try a bogus command. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
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
|
||
|
|
09335c4916 |
feat(onboard): active "want me to set it up?" offer on needs_onboarding (TASK-1850) (#742)
Convert the passive needs_onboarding nudge into an active, lead-with-it offer, mirrored across both agent-instruction surfaces: - SKILL.md: the nudge rule now leads with "Want me to set it up?" and codifies offer-not-auto-run + respect-a-decline-for-the-session. - internal/mcp/instructions.md: previously had NO needs_onboarding rule at all, so pure-MCP agents got the bootstrap flag but were never told to act on it. Added a "New workspace: offer to set it up" section with the same offer wording, pointing at the pad_onboard prompt / pad_playbook get ref:onboard to actually run onboarding once accepted. Agent offers, never auto-runs. Agent-instruction surfaces only. Parent: PLAN-1847. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ |
||
|
|
7962fabaeb |
feat(onboard): make natural language the canonical onboard trigger (TASK-1849) (#741)
* feat(onboard): make natural language the canonical onboard trigger (TASK-1849)
The onboard procedure already single-sourced to the seeded playbook body —
both SKILL.md and the MCP pad_onboard prompt deferred to it rather than
restating it. But two gaps remained:
- Both hardcoded `/pad onboard`, a Claude-Code-ism. SKILL.md installs into
Codex (`$pad`) and others too, so the nudge/routing copy was wrong off
Claude Code. Reframe NL ("set up my workspace") as the canonical trigger;
`/pad onboard` · `$pad onboard` · the `pad_onboard` MCP prompt are now
per-surface shortcuts into the same playbook.
- The MCP prompt resolved the playbook via CLI commands (`pad playbook
list`/`show`) that a shell-less MCP client can't run. Rewrite it to use
the `pad_playbook` tool (action:list / action:get ref:onboard); CLI form
kept as a secondary note. This closes the real single-source gap.
Agent-instruction surfaces only (SKILL.md + MCP prompt text); no CLI/MCP
catalog changes (CONVE-1741).
Parent: PLAN-1847. Absorbs cancelled TASK-1848.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* fix(onboard): use pad_library activate for shell-less onboard recovery per Codex review (round 1)
Round-1 review flagged that the MCP prompt's missing-playbook fallback sent
agents to the web UI even though pad_library action=activate is callable from
a shell-less MCP client. Activate "Onboard a workspace" via the tool, then
re-list, before deferring to the user.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
|
||
|
|
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). |
||
|
|
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 |
||
|
|
248f7c5ede |
feat(items): expose item restore via CLI + MCP (TASK-1828) (#734)
Adds the agent-facing restore surface so an archived item discovered via
`pad item list --all` can be recovered without dropping to the web UI. The
server already had restore end-to-end (Store.RestoreItem + handleRestoreItem
at POST /items/{ref}/restore, used by the web UI and bulk ops); this wires
the two missing surfaces:
- CLI: `pad item restore <ref>` (cli.Client.RestoreItem → the existing
endpoint, which resolves the ref include-deleted server-side). Mirrors
`pad item delete`'s structured JSON envelope: {ref, title, restored: true}.
- MCP: pad_item action=restore via passThrough(["item","restore"]). Restore
is non-destructive, so it's safe to expose. The action auto-joins the
schema's action enum (derived from the Actions map) and is documented in
the tool description.
Conflict case (slug/invocation_slug reclaimed while archived) is already
handled by handleRestoreItem (409) and surfaced by the client's
handleResponse.
Tests: restore endpoint already covered (handlers_items_test.go); restore
added to the MCP catalog<->cmdhelp bijection + dispatch tests. Child of
BUG-1791 (TASK-1827 shipped in #733).
|
||
|
|
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). |
||
|
|
03d73478ed |
fix(store): allow NULL api_tokens.workspace_id on SQLite for account tokens (#732)
Rebuild api_tokens on SQLite with workspace_id nullable, matching Postgres and the Go contract. Fixes the 500 on POST /api/v1/auth/tokens (workspace-agnostic account-token creation) on SQLite deployments. Fixes #731. Co-authored-by: b4rk13 <b4rk13@users.noreply.github.com> |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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). |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
db3917f6d2 |
feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733) (#701)
* feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733)
New full-viewport graph page at /{username}/{workspace}/graph rendering
the TASK-1731 endpoint via 3d-force-graph. Three.js loads only through
a dynamic import inside onMount, landing in its own ~1.3MB chunk
referenced solely by the graph route node — entry bundle unchanged.
Node color = collection (local hex palette; the chart PALETTE's CSS
vars can't reach WebGL), node size = 1 + 2×child_count, blocks edges
red with directional arrows, structural links brighter than soft ones.
Click navigates to the item page (ResolveItem accepts refs in the slug
param). Active items by default with a "Show completed" toggle that
refetches in place; workspace switches refetch with a stale-response
guard. Teardown via _destructor + ResizeObserver disconnect.
Nav: 'graph' added to destinations.ts (NavKey, RESERVED_SLUGS, primary
destinations, getActiveKey) and a Sidebar entry after Insights — the
mobile More sheet picks it up from the shared source automatically.
Parent: PLAN-1730.
* fix: clear stale canvas on workspace switch + reserve 'graph' collection slug per Codex review (round 1)
1. The renderer-sync effect now pushes empty graphData when the
reactive payload is null (workspace switch in flight / load error)
instead of early-returning — the previous workspace's nodes no
longer linger behind the loading overlay.
2. 'graph' added to reservedCollectionSlugs so a collection can't
shadow the /{username}/{workspace}/graph route, matching the
frontend's RESERVED_SLUGS.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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.
|