mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
47dc06cc425ba6bc84eee35e401922a211d80251
931 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
47dc06cc42 |
docs(web): point workspace-delete copy at real recovery surfaces (#832)
The Danger Zone delete copy (TASK-1976) only mentioned the post-delete Undo prompt because the persistent recovery surfaces weren't merged yet. Both are now live: the workspace switcher's "Recently deleted" section and the /console/deleted-workspaces page. Name all three 30-day recovery paths so users know they can restore beyond the Undo toast. Copy-only; no logic change. Keeps the Delete naming, typed-slug confirm, and owner-only gating. Closes TASK-1977 Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
ef5e5d72c7 |
feat(web): post-delete Undo + honest 30-day recovery copy on settings (#831)
After deleting a workspace, the settings page now shows the success toast with an inline Undo action. Undo calls api.workspaces.restore on the deleted slug (captured before the redirect so it works post- navigation) and navigates back into the restored workspace. The toast uses a longer duration and the global toast store survives the post-delete redirect to /console. The Danger Zone delete copy now states the workspace stays recoverable for the full 30-day window (with the Undo prompt right after deleting), making the soft-delete window honest. Copy intentionally avoids naming the persistent recovery surfaces (switcher restore = TASK-1974, console Deleted-workspaces page = TASK-1975) since those land in sibling tasks; those PRs can add the "restore from the switcher" pointer once merged. Typed-slug confirm + owner-only gating (TASK-1967) unchanged. Closes TASK-1976 Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
fa34ac342e |
feat(web): "Recently deleted" section in workspace switcher (#829)
* feat(web): add "Recently deleted" section to workspace switcher Adds a collapsible "Recently deleted" section below the active-workspaces list in WorkspaceSwitcher, populated from api.workspaces.listDeleted() and loaded each time the switcher opens. Each row shows the workspace name, a subtle "N days left", and an inline Restore button that calls api.workspaces.restore() then refreshes both the deleted list and the active workspaces list so the restored workspace reappears; a success toast confirms. The whole section is hidden when there are no deleted workspaces, and fetch failures are swallowed quietly so the switcher never breaks. Renders in both the desktop dropdown and the mobile BottomSheet. Closes TASK-1974 Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST * fix(web): decouple restore success toast from list refresh A failing workspaceStore.loadAll() after a successful restore no longer shows a misleading "Couldn't restore" toast. The restore API call now has its own catch; the post-restore refresh is guarded separately so a reload failure stays silent (the restore already succeeded). Addresses Codex P2. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST * fix(web): sequence deleted-list fetches to avoid stale overwrite Adds a monotonic request token to loadDeleted() (mirroring workspaceStore's membershipSeq) so an older open-triggered listDeleted() response can no longer clobber the fresher post-restore refresh and re-surface a just-restored workspace with a live Restore button. Addresses Codex P2. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
727deb29ea |
feat(web): dedicated /console/deleted-workspaces page (#830)
Add a full-page table of the caller's soft-deleted workspaces (name, deleted date, days-left, Restore per row) backed by api.workspaces.listDeleted / restore. Restore removes the row and toasts success; loading/error/empty states included. Wire a "Deleted workspaces" link into the console layout nav. Closes TASK-1975 Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
7b6739e82a |
feat(web): add workspaces.restore + listDeleted API client methods (#828)
Wire the web TypeScript API client to the TASK-1970 restore endpoints:
- workspaces.restore(slug) -> POST /workspaces/{slug}/restore, returns
the restored Workspace.
- workspaces.listDeleted() -> GET /workspaces/deleted, returns
DeletedWorkspace[] (Workspace + purge_at + days_left).
Uses the shared request() helper and the existing DeletedWorkspace type
(no redefinition). The restore UI that consumes these lands in
TASK-1974/1975/1976.
Closes TASK-1971
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
f5b437a65f |
feat(server): workspace restore + deleted-list endpoints (TASK-1970) (#827)
Foundation for PLAN-1969 (user-recoverable workspace soft-delete). A
workspace delete only stamps workspaces.deleted_at; items/collections/
members are untouched, hidden transitively. Restore clears deleted_at so
everything re-surfaces intact.
Store (internal/store/workspaces.go):
- RestoreWorkspace(slug): UPDATE ... SET deleted_at = NULL WHERE slug=?
AND deleted_at IS NOT NULL. Returns sql.ErrNoRows (-> 404) when no
soft-deleted row matched (already live or purged).
- ListDeletedWorkspaces(userID, cutoff): owner-scoped, deleted_at within
the window, ordered deleted_at DESC. Account-deleted workspaces have no
live owner, so they never leak.
- GetDeletedWorkspaceBySlug(slug): resolves a soft-deleted row (the normal
resolvers filter deleted_at IS NULL) so the handler can tell 403 from 404.
- Dual-dialect via s.q/s.dialect; no migration (deleted_at already exists).
Handlers (internal/server/handlers_workspaces.go):
- POST /api/v1/workspaces/{slug}/restore: owner-only; 404 not-restorable,
403 non-owner, 200 + restored workspace; logs a "restored" activity.
- GET /api/v1/workspaces/deleted: owner-scoped list with per-entry
purge_at + days_left, both derived from workspacePurgeRetention so
restore and the purge sweeper share ONE 30-day window (no drift).
- Both routed outside the /{slug} RequireWorkspaceAccess subrouter (which
resolves only live workspaces); restore enforces owner authz inline.
CLI client (internal/cli/client.go): RestoreWorkspace + ListDeletedWorkspaces.
TS type (web/src/lib/types/index.ts): Workspace.deleted_at + DeletedWorkspace.
Tests: store (resurface-intact; double-restore/live -> ErrNoRows; window
boundary 29d IN / 31d OUT + owner-scoping) and handler (owner-only 403,
404 live/unknown, 200 restore, owner-scoped deleted-list). Green on
SQLite and Postgres (make test-pg); golangci-lint clean.
Closes TASK-1970
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
9efc1d3bf9 |
fix(web): make workspace Danger Zone honest — it deletes, not archives (#826)
The workspace Danger Zone said "Archive" and "The data is preserved but no longer accessible." That is false: the action soft-deletes (workspaces.deleted_at) and TASK-1966's sweeper hard-purges any deleted_at workspace after 30 days. Rename the action to "Delete" across the heading, buttons, in-flight label, and confirm warning; rewrite the body copy to state it hides the workspace + all contents immediately and permanently deletes it 30 days later; update the success/failure toasts. Typed-slug confirm + owner-only gating unchanged; copy/toasts only. A workspace restore path (within the 30-day window) is planned as a follow-up, so the copy states the 30-day finality without claiming the workspace is unrecoverable. Closes TASK-1967. Follows TASK-1966 (the purge sweeper). Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
b73ba63752 |
feat(store): hard-purge soft-deleted workspaces after 30 days (TASK-1966) (#825)
The /privacy policy promises owned workspaces are removed from live systems within 30 days, but DeleteAccountAtomic and DeleteWorkspace only SOFT-delete (workspaces.deleted_at) and nothing ever expunged them — a right-to-erasure gap. Add a scheduled sweeper that hard-purges workspaces soft-deleted longer than a named 30-day retention constant. - Store: ListPurgeableWorkspaces (soft-deleted + past cutoff; never touches live rows), WorkspaceAttachmentBlobs, CountAttachmentsForHash- OutsideWorkspace (content-addressed dedupe guard), and PurgeWorkspace- Data — a transactional cascade that deletes every workspace-scoped child row in FK-dependency order (items/comments/versions/links/ reactions/stars/yjs op-log/wiki-links/grants/transitions/moves/views/ collections/documents+versions/agent_roles/webhooks/invitations/ templates/share_links+views/oauth join rows/report layouts/members/ member access/api tokens/attachments/activities), de-identifies mcp_audit_log, and refuses to touch a non-soft-deleted workspace. - Server: a periodic sweeper modeled on the orphan GC — captures blob keys before the purge, cascades the DB rows, then reclaims blobs through the attachment store abstraction (FS + S3 safe) with the orphan GC's cross-workspace dedupe + in-flight-upload guards. Failure isolated per workspace; idempotent. - Dual-dialect (SQLite + Postgres); partial index on workspaces(deleted_at) — migrations/073 + pgmigrations/051. Both delete paths (account + manual workspace delete) purge on the same 30-day clock: identical deleted_at mechanism, both owner-initiated, and the orphan GC already reclaims their attachment blobs at 30 days. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
86a952768e |
test: cover account delete + export contracts and Danger Zone e2e (TASK-1963) (#824)
Backend contract tests (internal/server):
- delete-account success-body: pin the exact {ok:true} envelope the UI
consumes (the cascade/skip tests asserted only status 200).
- export happy-path: decode the artifact and assert the exact
`attachment; filename="pad-export.json"` header, application/json type,
and the top-level {user, workspaces} shape with inline collections/items
(fills the TASK-508 gap; complements the BUG-1945 gate smoke test).
- TOTP paths (enabled+valid/missing/invalid, non-TOTP) were already added
in TASK-1958 — confirmed, not duplicated.
Web e2e (web/e2e, Playwright): settings Danger Zone —
- export download (filename + success line),
- delete password branch (real register→login→delete; admin user search
confirms the row is gone),
- delete cloud OAuth-only typed-confirm branch (session/me flags patched;
delete transport stubbed since a self-host server requires a password),
- post-delete redirect to /login.
Delete specs run desktop-only (viewport-agnostic; avoids doubling
IP-rate-limited /auth/login + /auth/register hits that flaked the suite
under parallel load).
No product code changed.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
d53262ca6c |
feat(web): add delete-account confirm flow to settings Danger Zone (#823)
Adds a two-step delete-account flow to the console settings Danger Zone, cloning the inline TOTP-disable reveal pattern. Branches on auth method: password re-entry for email/password + self-host accounts, typed confirmation (email or DELETE) for cloud OAuth-only accounts, plus a required TOTP code when 2FA is enabled (server re-verifies). Shows the three account-deletion warnings (irreversible; cancels paid subscription with no refund; shared workspaces are deleted and members lose access), nudges users to export their data first, and renders the server's billing_cancel_failed / partial_delete messages verbatim. On success it clears the auth store and hard-redirects to /login with no further API calls. Focus-on-reveal, Escape-to-cancel, aria-describedby. Closes TASK-1962 Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
022280a1ee |
feat(server): require TOTP re-verification to delete account (#822)
handleDeleteAccount previously verified only the password (or, in cloud mode, a confirm-only session) and never re-checked TOTP even when the account had 2FA enabled. That made an irreversible account destroy a lower bar than login for 2FA users — a hijacked live session (cloud confirm-only needs no password) could wipe the account. Add an optional totp_code to the delete-account request body. When the authenticated user has TOTP enabled, require and verify the code server-side (via the existing totp.Validate path used by login and 2FA disable) AFTER the password/confirm identity check and BEFORE the Stripe cancel + local delete — so neither the password nor the cloud-confirm path can bypass it, and a failed code never leaks a cancel RPC. Missing code → 400 totp_required; wrong code → 401 totp_invalid. Users without TOTP are unaffected (no code required, same behavior as before). Tests cover TOTP-enabled + valid code (success), missing code (totp_required), invalid code (totp_invalid), and non-TOTP (unaffected), reusing the fakeSidecar + bootstrapAccountDeleteUser + deleteAccountReq harness. Closes TASK-1958 Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
366d4fb7e5 |
fix(store): harden account-deletion FK cascade (TASK-1959) (#821)
* fix(store): harden account-deletion FK cascade (TASK-1959)
DeleteAccountAtomic could 500 with nothing deleted when a user had rows
referencing them via foreign keys with no ON DELETE action — notably
activities.user_id (the audit/history log, including the session_ip_changed
rows the auth middleware writes on the very request that deletes the
account). The delete-account tests only passed by working around this
(pinning RemoteAddr to loopback, scrubbing activities.user_id).
Audit every table with a FK to users(id) and handle each in the delete
transaction:
- de-identify (UPDATE ... SET NULL) audit/history rows: items
created/modified, comments, comment_reactions, item_links,
item_versions, share_link_views
- delete owned/transient/audit rows: sessions, api_tokens,
workspace_members, sent invitations, password/email tokens, issued
grants, created share links, mcp_audit_log, oauth_connections
- rely on existing ON DELETE CASCADE / SET NULL for item_stars,
user_report_layouts, {collection,item}_grants.user_id,
items.assigned_user_id
Migrations 072 (SQLite) / 050 (Postgres) give activities.user_id an
ON DELETE SET NULL FK so the highest-write-frequency audit table can't
block a delete via a row written concurrently during the request. SQLite
rebuilds the table (022_audit_trail pattern); Postgres never had the FK,
so it is added after an orphan scrub so validation passes.
Remove the test work-arounds now that the cascade holds: deleteAccountReq
deletes from a changed IP so the session-IP-change audit row exercises the
fix, and the partial-delete test injects its post-cancel failure via the
sidecar hook instead of the (now-fixed) FK gap. Add a store-level test that
deletes a fully-populated user atomically.
Closes TASK-1959
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(store): harden account-deletion FK cascade (TASK-1959)
DeleteAccountAtomic could 500 with nothing deleted when a user had rows
referencing them via foreign keys with no ON DELETE action — notably
activities.user_id (the audit/history log, including the session_ip_changed
rows the auth middleware writes on the very request that deletes the
account). The delete-account tests only passed by working around this
(pinning RemoteAddr to loopback, scrubbing activities.user_id).
Audit every table with a FK to users(id) and handle each in the delete
transaction:
- de-identify (UPDATE ... SET NULL) audit/history rows: items
created/modified, comments, comment_reactions, item_links,
item_versions, share_link_views
- delete owned/transient/audit rows: sessions, api_tokens,
workspace_members, sent invitations, password/email tokens, issued
grants, created share links, mcp_audit_log, oauth_connections
- rely on existing ON DELETE CASCADE / SET NULL for item_stars,
user_report_layouts, {collection,item}_grants.user_id,
items.assigned_user_id
Migrations 072 (SQLite) / 050 (Postgres) give activities.user_id an
ON DELETE SET NULL FK so the highest-write-frequency audit table can't
block a delete via a row written concurrently during the request. SQLite
rebuilds the table (022_audit_trail pattern); Postgres never had the FK,
so it is added after an orphan scrub so validation passes.
Log the account_deleted audit row with an empty user_id (deleted id kept
in metadata): the user row is already gone by then, and the new
activities.user_id FK would otherwise reject the insert and silently drop
the row. This makes the account_deleted event actually recorded on both
dialects.
Remove the test work-arounds now that the cascade holds: deleteAccountReq
deletes from a changed IP so the session-IP-change audit row exercises the
fix, and the partial-delete test injects its post-cancel failure via the
sidecar hook instead of the (now-fixed) FK gap. Add a store-level test that
deletes a fully-populated user atomically.
Closes TASK-1959
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
87f8192515 |
feat(web): add Danger Zone section with Export my data button (TASK-1961) (#820)
Add a new ungated Danger Zone card as the last section of the account
settings page, with an "Export my data" button wired to
exportAndDownloadAccountData(). Export works self-host too, so the
section is not gated on cloudMode.
The button holds a persistent disabled + in-flight ("Exporting...")
state for the whole request (server has a 60s deadline) so the user is
never left without feedback. Inline error rendering (.error) surfaces
the restricted-owner 403 (PadApiError.message) and network/timeout
failures; a .success line confirms the download.
Reuses .danger-btn/.error/.success; the section header + border are red
per the existing danger palette (#ef4444). Follows the page's
per-section $state trio convention (exportSaving/exportMsg/exportError).
Closes TASK-1961
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
1d8b04e279 |
feat(server): expose password_set on /auth/me + TS User type (#819)
The delete-account UI must branch between a password prompt (self-host or any user with a password) and a confirm-only flow (OAuth-only users with no password). The client had no signal for this: oauth_providers is not a valid proxy since a user can have both a password and linked OAuth. Add "password_set": user.HasPassword() to the /auth/me response map and password_set?: boolean to the TS User interface. A handler test asserts the field for both a password user (true, via bootstrap) and an OAuth-only user (false, via CreateOAuthUser). Closes TASK-1957. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
0faa481e82 |
feat(web): add deleteAccount + exportAccountData API client methods (#818)
Wire the existing account delete/export endpoints into the web API
client (TASK-1960). Neither was callable from the web today.
- api.auth.deleteAccount(opts) — POSTs /auth/delete-account through the
shared request() helper so X-CSRF-Token + credentials are attached
(a hand-rolled fetch would 403). Optional password/confirm/totp_code.
- api.auth.exportAccountData() — modeled on exportItemArtifact: bare
credentialed fetch, 401 -> /login, non-OK -> PadApiError (surfaces the
restricted-owner 403, BUG-1945), Content-Disposition filename with a
pad-account-export.json fallback. Returns { filename, text }.
- exportAndDownloadAccountData() util pipes the bytes into
downloadTextFile as application/json.
Closes TASK-1960
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
e6eec608e0 |
feat(web): default agent-connect modal to MCP setup, steer zero-grant users (#817)
Reorder ConnectWorkspaceModal so MCP setup is the default and first tab — most users landing here have never connected an agent, so the OAuth "fresh agent" path is their real first step. CLI is second; the claim code moves to a third "Connect code" tab, reframed as a scoped-grant add-on rather than the (misleading) "recommended" default it was. Close the zero-grants dead end: add Store.HasActiveConnectionForUser and surface has_any_connection on the claim-code endpoint, so a user who opens the Connect-code tab with no connected agent gets steered to set one up first instead of a live-looking but unredeemable code. Hide the MCP + code tabs on self-host deployments without a public MCP URL (both depend on the remote OAuth server), leaving CLI as the sole, default path there. Verified: go build ./..., go test ./internal/server/ ./internal/store/, web npm run check (0 errors), and a Codex review all pass clean. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3STv0.9.1 |
||
|
|
bcc4a69225 |
fix(web): dedupe collab flush in editor-markdown space (BUG-1941) (#816)
* fix(web): dedupe collab flush in editor-markdown space (BUG-1941) A no-edit view's flush re-serialized the editor markdown through the flush-time wiki-link index before comparing to the loaded baseline; when that index differed from the seed-time index (or the stored link was already non-canonical), the dedupe missed and a spurious PATCH bumped updated_at, floating the card in Manual-sorted boards (regression of BUG-1899). Capture the exact markdown seeded into the Y.Doc and short-circuit the flush in editor-markdown space, before markdownToWikiLinks ever runs, scoped to sessions with no prior flush so revert-after-edit still saves. Also give the Manual sort comparator a deterministic tiebreak so a stray updated_at bump can't reorder untouched cards. * fix(web): eagerly compute collab-flush seed for every tab (BUG-1941 follow-up) Only the multi-tab-election winner captured a seedMd via the lazy-seed effect, leaving a second tab on the same item — or simply reopening an item that already has collab history — without editor-space dedupe coverage, falling back to the pre-fix spurious-PATCH behavior. Compute a best-effort seedMd for every tab at context-creation time by projecting the same item.content baseline the storage-space dedupe already trusts through the identical wiki-link transform; a mismatch here can only cause a missed dedupe (falls through to the existing compare), never a false one, so it's safe even when the projection goes stale. The winning tab's precise lazy-seed capture still overwrites this value unchanged.v0.9.0 |
||
|
|
7eff417d83 |
fix(server): deny restricted owners from escalating collection access (BUG-1925) (#815)
handleSetMemberCollectionAccess gated only on requireRole(r, "owner"), with no validation of the caller's own visibility. member_collection_access has no role exclusion (BUG-1920), so a workspace-role owner independently restricted via collection_access="specific" could PATCH themselves (or any other member) to mode="all", or grant a collection outside their own visible set — defeating the entire BUG-1917/1918/1920/1921 visibility family plus the BUG-1922 export gate in one authenticated request. requireCallerCanSetCollectionAccess now denies a restricted caller from setting mode="all" for any target, and validates every requested collection ID against the caller's own visible set, narrowed to guestResourceFilter's fullCollIDs (mirroring requireCollectionFullyVisible, BUG-1920 codex R2) so an item-level grant can't be escalated into collection-wide access. Unrestricted callers are unaffected. |
||
|
|
84637ba6cb |
fix(server): deny account export to restricted owners (BUG-1945) (#814)
handleExportAccount (/auth/export) dumped full collections/items for every workspace a user owns without checking collection_access, bypassing BUG-1922's workspace-export gate via a different route. A restricted owner (collection_access="specific") could exfiltrate hidden collections through the account-export affordance instead. Mirror BUG-1922's outright-deny: before any response byte is written, scan every owned workspace with visibleCollectionIDs and refuse the whole export with 403 if any owned workspace is restricted, rather than silently omitting it from an unflagged partial dump. |
||
|
|
b633144009 |
fix(server): deny workspace export to restricted owners (BUG-1922) (#813)
Workspace export (both the JSON and tar.gz bundle forms of GET
/workspaces/{slug}/export) streamed the full unfiltered workspace
regardless of the caller's collection_access, letting a restricted
owner exfiltrate collections hidden from them. Per Dave's ruling,
export is a backup/portability affordance rather than a
visibility-scoped view, so a restricted caller is denied outright
(403) instead of receiving a filtered subset.
|
||
|
|
b3cb11c3c8 |
fix(web): add /verify-email/[token] route to consume verification tokens (BUG-1942) (#812)
Cloud self-registration mails a link to /verify-email/<token> but no SvelteKit route existed there, so every self-registered user 404'd and could never verify (blocking mutate/invite under DR-1 model b). Add the route to auto-consume the token on mount, plus a client method for the existing POST /auth/verify-email consume endpoint (distinct from the admin force-verify). Success refreshes the session so emailVerified flips and redirects to /console; failure offers resend (or sign-in). Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS |
||
|
|
d36f27c29f |
fix(server): auth-perimeter hardening — B6–B9 from the IDEA-1927 audit (TASK-1932) (#811)
* fix(server): stop autoCreateWorkspace from swallowing member-add errors (B6, TASK-1932) A failed AddWorkspaceMember after workspace creation used to be silently discarded, leaving a workspace that's completely unreachable (owner_id alone grants no access) and invisible in the console forever. Retry once, then clean up the orphaned workspace and log loudly on continued failure so on-call can act on it. * fix(server): fail fast when cloud mode runs without secure cookies (B7, TASK-1932) SetCloudMode never forced secureCookies on, so PAD_CLOUD=true without PAD_SECURE_COOKIES was an unenforced ops contract: OAuth's __Host-prefixed session cookie is silently invisible to pad's own cookie reader without Secure set, producing a "logged in but appears logged out" failure mode. Add Config.ValidateCloudSecureCookies and check it at server startup, next to the existing PAD_CLOUD_SECRET requirement, so the misconfiguration is a startup error instead of a runtime mystery. * fix(server): align OAuth session TTL with web session TTL (B9, TASK-1932) handleOAuthLogin minted a 30-day session while every other web login used the 7-day webSessionTTL. createAuthSession derives the store session row, session cookie MaxAge, and CSRF cookie MaxAge all from one ttl argument, so the longer OAuth cookie outlived its own server-side session — the browser kept presenting a cookie whose session had already expired, producing silent 401s. Use webSessionTTL for OAuth logins too. * fix(server): narrow the /api/v1/auth/* CSRF exemption to anonymous endpoints (B8, TASK-1932) The CSRF middleware exempted the entire /api/v1/auth/ prefix, which also covered mutating cookie-authenticated endpoints: PATCH /me, oauth-unlink, 2FA setup/verify/disable, delete-account, token create/delete/rotate, CLI- session approve, and logout. Replace the prefix bypass with an exact-path allowlist of the endpoints that are genuinely pre-session (login, register, bootstrap, password reset, verify-email, resend-verification, 2FA login challenge, CLI session create) or authenticate purely via a cloud secret rather than a cookie (oauth-login, oauth-link — never touch the session, so CSRF isn't a meaningful threat model for them and the sidecar has no CSRF cookie to send). Everything else now requires the double-submit token like any other authenticated mutation; the web client already sends it on every non-GET/HEAD request, so no frontend change is needed. * docs(server): pin the deliberate CSRF-cookie legacy-fallback asymmetry (TASK-1932) Codex review (round 1) flagged that SessionAuth falls back from the __Host-pad_session cookie to the legacy unprefixed name, but the CSRF cookie lookup has no equivalent fallback — meaning a browser holding pre-secure-cookies-flip legacy cookies stays authenticated but gets 403'd on B8's newly CSRF-required endpoints until it re-logs-in. This asymmetry is deliberate, not a bug: the session cookie's value is an unguessable secret regardless of which name carries it, but the CSRF cookie's security property depends on the attacker being unable to set the cookie itself — an unprefixed name is settable from a sibling subdomain, which is exactly the hole __Host- exists to close. Restoring "symmetry" here would silently reopen it. Document the reasoning at the cookie lookup so a future maintainer doesn't "fix" it, and add a pinning test that exercises the exact scenario (secureCookies=true, legacy session + CSRF cookies, CSRF-required endpoint) end to end. * fix(server): require CSRF for session-authenticated requests to exempt auth paths (TASK-1932) Codex round 2 found a P1: handleRegister has an admin-session branch (an already-logged-in admin can create a verified account with no invitation code), but /api/v1/auth/register was unconditionally CSRF-exempt by path. A cross-site POST could ride the admin's cookie into that branch with no CSRF token — the same class of hole as the oauth-unlink case B8 already closed, just missed because register's other paths are genuinely anonymous. Fix generically rather than register-specifically: gate the authCSRFExemptPaths exemption on currentUser(r) == nil. SessionAuth runs before CSRFProtect, so a request that resolved to a real session falls through to the normal double-submit check instead of the early exemption, while a genuinely anonymous request keeps it. This also covers any future session-authenticated branch a handler on this list grows, with no handler changes. Bearer/PAT and cloud-secret (oauth-login/oauth-link) callers are unaffected — they have their own unconditional exemptions later in the same function. * fix(server): require validated Bearer/cloud-secret auth for CSRF exemption (TASK-1932) Codex round 3 found that CSRFProtect's Bearer and X-Cloud-Secret exemptions fired on header/marker PRESENCE, not validation. TokenAuth deliberately falls through (rejectInvalidBearer) instead of 401ing invalid Bearers on /api/v1/auth/* paths to support CLI-token recovery, so a cross-site request carrying a victim's real session cookie plus a garbage Bearer header could ride the cookie past CSRF on any newly-CSRF-required endpoint. The same presence-only pattern in the X-Cloud-Secret exemption is concretely exploitable too: handleSetPlan (and similarly-shaped handlers) accept an admin cookie session as an alternative to the secret, so a garbage X-Cloud-Secret plus a stolen admin cookie could set an arbitrary user's plan with no CSRF token at all. Add ctxValidatedSessionBearer (set by TokenAuth only on successful ValidateSession for CLI session-bearer tokens) alongside the existing ctxIsAPIToken, and a combined isValidatedBearerAuth() helper. CSRFProtect now exempts unconditionally only on validated Bearer auth; an unvalidated Bearer header or cloud-secret marker is exempt only when no session was also resolved for the request (currentUser(r) == nil), preserving the CLI-recovery contract (stale token, no cookie -> 401 from auth, not csrf_error) while closing the cookie-riding case. * fix(server): split CSRF auth-exempt allowlist by session sensitivity (TASK-1932) Codex round 2 gated the entire authCSRFExemptPaths allowlist on currentUser(r) == nil to close handleRegister's admin-session branch, but that gate applied to every anonymous endpoint on the list, not just register. CI's E2E suite caught the regression: the harness bootstraps an admin (minting a session cookie) then POSTs /login to re-authenticate, and the ambient cookie stripped /login of its exemption, producing a spurious 403 csrf_error. login/bootstrap/forgot-password/reset-password/local-reset/verify-email/ resend-verification/2fa-login-verify/oauth-login/oauth-link/cli-sessions- create derive their authority entirely from the request body (credentials, a token, a shared secret), never from the ambient cookie, and pad mints the CSRF cookie AT LOGIN — a pre-session endpoint categorically cannot require a token that doesn't exist yet. Split the allowlist: authCSRFUnconditionalExemptPaths (everything above, exempt regardless of cookie) and authCSRFSessionGatedExemptPaths (register only, exempt only when currentUser(r) == nil, since it alone has a session-privileged admin-account-creation branch). The round-2 security property (admin session + register + no CSRF -> still blocked) and round-3's Bearer/ cloud-secret validated-vs-present composite are unaffected. |
||
|
|
a04091517c |
feat(web): email verification banner + resend + register success (TASK-1940) (#810)
Wave 5 of PLAN-1933 (DR-1 model b) — surfaces the unverified-email state in the web UI and makes it actionable. - AuthSession user type gains `email_verified` (owns the session user-type change); register response user type gains it too. - authStore.emailVerified getter, default TRUE (mirrors `emailConfigured ?? true`) — a missing field or a self-host instance must never show the banner. - VerifyEmailBanner rendered in the workspace layout above ConnectBanner, shown only when `cloudMode && user && !emailVerified`, with a Resend button hitting POST /auth/resend-verification and a "sent" confirmation (enumeration-safe, always 200). - api.auth.resendVerification client method. - /register shows a "check your email to verify your account" state after a cloud self-serve signup returns an unverified user, instead of navigating in and implying full access; includes resend + continue actions. Gates: make check (lint + go test + govulncheck + web-check) green; cd web && npm run check → 0 errors. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
f34e66254b |
feat(admin): admin force-verify email override (TASK-1939) (#809)
Wave 4 of PLAN-1933 (DR-7). Adds a web-console-only admin override to
force-verify a locked-out unverified account. No CLI, no MCP (matches
the no-auth-mutation-on-MCP rule).
Server:
- POST /api/v1/admin/users/{userID}/verify-email — admin-only, mirrors
handleAdminEnableUser. Reuses the existing SetUserEmailVerified store
method (added in Wave 3b) and audits with the distinct
ActionEmailVerifiedByAdmin action (separate from the self-serve
ActionEmailVerified — a force-verify is an operator security action).
Idempotent (already-verified returns 200 no-op).
- Surface email_verified_at in the admin list + get-user JSON so the
console knows verified state (Wave 1 only added the store-level scan).
Web:
- adminVerifyEmail client method (api.admin.verifyEmail) confined to the
admin section of client.ts.
- "Mark email verified" action in the admin user panel (UserSettingsForm),
shown only when the target user is unverified.
- email_verified_at added to the AdminUser type.
Tests: admin force-verifies an unverified user (flips email_verified_at +
audits ActionEmailVerifiedByAdmin, not the self-serve action) and the
now-verified session is unblocked; non-admin -> 403 with no side-effect.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
4a7c054223 |
feat(server): cloud email self-registration + verify-email/resend endpoints (TASK-1938) (#808)
Wave 3b of PLAN-1933 turns ON Pad Cloud email/password self-registration with mandatory email verification. - DR-6: relax handleRegister to allow self-serve signup when cloudMode && emailConfigured. emailConfigured = s.email != nil AND a USABLE public base URL (non-empty, not a 0.0.0.0/:: bind-all host), so no unverifiable user is ever created. Self-serve is the ONLY path that writes email_verified_at = NULL (UserCreate.Unverified); admin-created and invited signups stay verified. Mints + sends a verification email. - DR-5: POST /auth/verify-email (ConsumeEmailVerification → flips email_verified_at → returns fresh user) and POST /auth/resend-verification (always-200, enumeration-safe; minting a new token invalidates the prior one). Both wired into the rate-limiter path switch (PasswordReset bucket). - DR-1: handleAcceptInvitation verifies an unverified account on accept (email-bound invite proves email control), via new store method SetUserEmailVerified. - DR-11: keep the existing clear 409 on duplicate email at signup. Session freshness: currentUser is re-read fresh from the DB per request (ValidateSession → GetUser), so flipping email_verified_at unblocks the same session's subsequent mutations immediately under RequireVerifiedEmail (Wave 3a) — no session-row rewrite needed. Test covers verify → same-session mutation succeeds. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
31053086ee |
feat(server): RequireVerifiedEmail enforcement across all mutation perimeters (TASK-1937) (#807)
PLAN-1933 DR-4 (Wave 3a). Enforce, on Pad Cloud only, that an
authenticated-but-unverified user cannot mutate content or mint
credentials. Cloud-only + unauthenticated + verified are all no-ops, so
this is inert in production until Wave 3b starts creating unverified
users; the tests drive the state directly via the store's explicit
UserCreate.Unverified control.
Core rule: block only when cloudMode && currentUser != nil &&
!IsEmailVerified(), on mutating methods (POST/PATCH/PUT/DELETE). Returns
403 email_not_verified. The middleware does NOT inherit CSRFProtect's /
RequireAuth's blanket /api/v1/auth/* exemption — it decides for itself.
Perimeters gated (systematic DR-4 audit — one test each):
- /api/v1 core writes (session AND PAT) — RequireVerifiedEmail method
gate mounted after RequireAuth (server.go).
- Authenticated /auth/* mutations — token create/rotate/delete, PATCH
/me, 2FA setup/disable, OAuth link/unlink, and cli-session approve —
all fall through the method gate (no auth exemption); logout,
verify-email, resend-verification, delete-account allowlisted.
- Collab WS GET-upgrade — authorizeCollabAccess (a GET the method gate
can't catch; it persists Yjs edits).
- Remote MCP write path — dispatcher RequireVerifiedEmail hook fired in
buildAuthedRequest (the single chokepoint every synthesized write
passes through), wired in cmd/pad/main.go.
- OAuth-provider authorize + authorize/decide — emailUnverifiedBlocked
checks (mounted outside /api/v1; decide mints the auth code).
- POST /api/v1/import/url — SSRF/abuse surface, method gate.
Carve-outs: POST /api/v1/invitations/{code}/accept stays open for
unverified invitees (DR-1); legacy no-user workspace PATs are
intentionally ungated (currentUser==nil). Self-host (!cloudMode) is a
full no-op.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
b0eeef16ce |
feat(store): email_verification_tokens + SendEmailVerification + token reaper (TASK-1936) (#806)
Wave 2 of PLAN-1933 — verification-token infrastructure (pure infra; no endpoint consumes it until Wave 3). - Migration 071 (SQLite) / 049 (Postgres): email_verification_tokens table, cloning the password_resets shape (id/user_id FK/token_hash/expires_at/ used_at/created_at + token_hash + user_id indexes), per-dialect created_at default. - Store email_verification.go: 256-bit crypto/rand token, padver_ prefix, SHA-256-at-rest, non-destructive Lookup, atomic UPDATE...RETURNING Consume. Deltas from password_resets (DR-2): 24h TTL, keep invalidate-prior-on-mint (resend burns the old link), consume side-effect sets users.email_verified_at (RFC3339-with-Z, same format Wave 1's migration used) in one transaction — no password reset, no session mint. - Email SendEmailVerification: clones SendPasswordReset, "1 hour" -> "24 hours". - Token reaper (DR-5): lifecycle-safe background sweep (mirrors orphanGC/opLogGC — self-registers on Server.bg, context-cancellable via stop channel, started only from cmd/pad/main.go so unit tests don't leak goroutines) calling the four previously-unwired CleanExpired* methods (email verifications, password resets, sessions, CLI auth sessions) hourly. Adds CleanExpiredEmailVerifications. - Audit consts ActionEmailVerified + ActionEmailVerifiedByAdmin. Gates: make check + make test-pg green (store + migration on both dialects). Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
6a63fba188 |
feat(store): add users.email_verified_at column + model plumbing (TASK-1935) (#805)
Wave 1 of PLAN-1933 (email verification). Pure infra — nothing reads the column until Wave 3, so this is behaviourally a no-op and mergeable early. - Migration 070 (SQLite) / 048 (Postgres): add nullable email_verified_at TEXT, mirroring disabled_at. UNCONDITIONALLY backfill every existing row to verified (RFC3339 'Z'-suffixed) so no existing / OAuth / self-host account is write-locked on deploy (inverted vs password_set's conditional backfill). SQLite ALTER without IF NOT EXISTS; Postgres with it. - SAFE default = verified (DR-3): CreateUser / CreateOAuthUser write a verified timestamp unless UserCreate.Unverified is explicitly requested (only the future cloud self-serve branch will set that). A missed call site fails SAFE (verified), not write-locked. - models.User.EmailVerifiedAt + IsEmailVerified() (mirror IsDisabled). - Update userColumns + BOTH scan sites (scanUser AND the inline SearchUsers scan) so the admin user list keeps working. - Expose derived email_verified bool in sessionUserPayload for a later wave. Gates: make check + make test-pg both green (dual-dialect verified). Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
6c0e336d9c |
fix(web): carry the invitation through OAuth signup on /join (BUG-1931) (#804)
On Pad Cloud, an invitee who signed up via Google/GitHub from /join was never added to the inviting workspace: /join rendered no OAuth buttons, and the OAuth round trip dropped the pending invitation. Surface the shared AuthOAuthButtons on /join (cloud-mode only, matching /login and /register) and thread the invite code through the provider link's ?redirect= as /join/<code>. OAuth returns via a full-page nav to that URL, where onMount's existing session probe sees `authenticated` and calls acceptInvitation(code) to finish the join. The redirect stays same-origin (validateRedirect); the accept remains email-bound server -side (403 invitation_email_mismatch on a mismatched OAuth email), whose message already surfaces in the page's error state. Single-repo per PLAN-1933 DR-8 (option A) — no pad-cloud change. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
111e43d27f |
fix(server): invitation-preview endpoint + read-only email prefill on /join (BUG-1934) (#803)
On Pad Cloud the /join/[code] page never showed the invited email, so a
mistyped address hit a confusing 403 invitation_email_mismatch. Add a
non-consuming, public, always-200, rate-limited preview endpoint and wire
the join page to prefill the invited email read-only.
- GET /api/v1/invitations/{code}/preview returns {found,email,workspace_name,
has_account}. Reuses store.GetInvitationByCode (never accepts/consumes the
invite). Invalid/expired/missing codes and dangling-workspace codes all
return 200 {found:false} — no 404 status signal (enumeration safety). A
genuine DB fault still 500s (code-independent, leaks nothing).
- Public/pre-auth: added to isPublicAPIPath (matches only the trailing
/preview segment, so /accept stays auth-gated).
- Dedicated per-IP rate limiter (20/min, burst 20) wired into the RateLimit
switch so the endpoint can't be used to enumerate invite codes.
- TS client: api.members.previewInvitation + InvitationPreview type.
- /join page calls preview on mount, prefills + locks the invited email, and
defaults register-vs-login by has_account. Keeps the mode-switch affordance
and BUG-1930's register default when preview is unavailable.
- Tests: non-consumption, has_account, always-200 on unknown code, rate limit.
Composes with BUG-1930 (register default). Wave 0 of PLAN-1933 / IDEA-1927 §B5.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
|
||
|
|
ae62c097ca |
refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916) (#802)
* refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916) dispatchProjectNext/Standup/Changelog were a second server-side copy of the next/standup/changelog reshaping contract, written before TASK-1894 shipped dedicated REST endpoints for the same data. Replace the ~200 lines of duplicate reshaping with thin proxies that validate workspace (preserving the pad_set_workspace hint) and forward to GET /next|/standup|/changelog, relying on packageHTTPResponse's existing array-wrap (BUG-985) and the REST handlers' own days-default and per-status best-effort semantics rather than replicating them. dispatch_http_slice4.go is deleted; its unrelated dispatchLibraryActivate moves to dispatch_http_library.go now that the file's other three methods are gone. KEEP IN SYNC comments across handlers_project_intel.go/server.go/tests collapse from "three reproductions" to "CLI + REST, MCP proxies to REST." * fix(server): pass nav-lenient visibleIDs to changelog's parent enrichment (codex R1 P1, TASK-1916) handleGetProjectChangelog passed guestResourceFilter's narrowed collIDs into enrichItemsWithParent instead of the nav-lenient visibleCollectionIDs set handleListItems uses for the same enrichment call. For a guest whose granted item's parent lives in an item-grant-only collection (nav-visible but excluded from the narrowed full-access set), this silently dropped the parent link fields, causing itemMatchesParentFilter to exclude the item from ?parent= results even though the guest can otherwise see it. The root cause predates TASK-1916 (introduced alongside the REST endpoint in TASK-1894), but this consolidation imports it into MCP wire behavior via dispatchProjectChangelog's proxy, so it's in scope to fix here. projectIntelVisibility now returns the unnarrowed visibleCollectionIDs result (navVisibleIDs) alongside the existing (collIDs, itemIDs) pair; handleGetProjectChangelog uses navVisibleIDs for enrichItemsWithParent while keeping collIDs for the list query, mirroring handleListItems' pattern exactly. handleGetProjectStandup and handleGetProjectNext have no parallel enrichItemsWithParent call (verified by reading both, and buildDashboardResponse) so neither needed the same treatment. Added TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection, confirmed to fail against the pre-fix code and pass against the fix. |
||
|
|
4429af5e76 |
fix(web): default logged-out /join visitors to register mode (BUG-1930) (#801)
A never-registered invitee previously saw the auth form defaulted to login, tried to sign into a nonexistent account, and dead-ended. The register path already exists and auto-accepts the invitation via the invitation code, but was hidden behind a "Create one" toggle. Default to register instead; the "already have an account? sign in" switch still works both directions. |
||
|
|
a3d0b02d23 |
fix(web): don't mask/redirect on 401 from auth-form submissions (BUG-1929) (#800)
* fix(web): don't mask/redirect on 401 from auth-form submissions (BUG-1929) The client's global 401 interceptor hard-redirected to /login and threw a hardcoded "Authentication required" message for every 401, including bad-credentials responses from login, register, and 2FA verification — masking the server's real error and stranding invitees mid-join. Auth-form 401s now surface the server's message inline instead; session-expiry 401s elsewhere are unchanged except that the redirect now preserves a ?redirect= return-to path. * test(web): pin hostile ?redirect= values as rejected (BUG-1929 follow-up) Codex R2 found no live browser-exploitable bypass in validateRedirect, but two of the five hostile forms it checked (percent-encoded slash/ backslash, e.g. /%2Fhost) passed through unrejected today — not exploitable via normal navigation, but a validator gap nonetheless. Reject percent-encoded slash/backslash defensively and add unit pins for all five hostile shapes so a future regression here can't be silent. |
||
|
|
f3335adea2 |
fix(server): filter handleListUserGrants through caller visibility (BUG-1928) (#799)
handleListUserGrants returned a target user's raw collection/item grants (including collection_id/item_id) to any workspace owner unconditionally, letting a restricted owner (collection_access="specific") enumerate hidden-resource IDs — the disclosure half of the primitive BUG-1923's handlers closed the action half of. Filter the response through the caller's visibility when caller != target: collection grants against guestResourceFilter's strict full-access set (same set requireCollectionFullyVisible narrows to — item-grant-only collections don't qualify), item grants via a bulk item_id->collection_id lookup (GetItemCollectionRefs, state-agnostic so soft-deleted parents stay listed) plus the existing isItemVisibleToGuest set-membership check. Self-queries and unrestricted callers stay unfiltered, the latter via a cheap short-circuit. GetDeletedItemsWithCollection's query had no deleted_at filter despite its name; renamed the shared implementation to GetItemCollectionRefs and kept the old name as a wrapper for its existing delta-sync caller. |
||
|
|
22ac55929a |
fix(server): gate ID-keyed grant/share-link handlers on target visibility (BUG-1923) (#798)
* Gate ID-keyed grant/share-link delete and view-history handlers on target visibility handleDeleteCollectionGrant, handleDeleteItemGrant, handleDeleteShareLink, and handleShareLinkViews operated on a grant/link ID directly with only a requireMinRole(owner) check — a restricted owner who knew an ID could revoke a grant or share link (or read its view-history) on a resource hidden from them by collection_access="specific". Unlike their slug-resolving create/list siblings (fixed in BUG-1920), these handlers never resolved the parent item/collection, so the visibility gate never ran. Resolve the grant/link's parent before acting: collection-scoped records go through the strict requireCollectionFullyVisible (no item-grant promotion, matching the minting/listing gates), item-scoped records through requireItemVisible. Item lookups use GetItemIncludeDeleted so a grant/link on a trashed-but-visible item stays revocable, matching handleListItemGrants' existing ResolveItemIncludeDeleted precedent. Fixes BUG-1923. * Fix soft-deleted-collection regression in BUG-1923's visibility gates Codex round 3 found that the BUG-1923 fix used GetCollection to resolve the parent collection for collection-scoped grant/share-link operations. GetCollection filters deleted_at IS NULL, but DeleteCollection soft-deletes and does NOT cascade-delete collection_grants or share_links — so the moment a collection was archived, its grants/share-links became permanently un-revocable and un-inspectable via the API (parent resolution 404'd before the visibility check even ran). Switch both call sites to the existing GetCollectionAnyState store method (no deleted_at filter; already used by the open-children guard for the same reason) instead of adding a new near-duplicate getter. Applied consistently to both handleDeleteShareLink and handleShareLinkViews via the shared requireShareLinkTargetVisible helper, so view-history stays readable for revocation decisions on an archived collection's link, not just the delete path. Confirmed via store trace: an unrestricted owner (visibleCollectionIDs == nil) is unaffected either way and is the main regression case, now fixed. A restricted owner's member_collection_access-derived visibility also survives the parent's soft-delete, since that lookup reads the raw table with no deleted_at join. |
||
|
|
f29fb65bc0 |
fix(collab): dedup binary frames by content in replay-cursor test (BUG-1924) (#797)
TestRoomManagerCursorSuppressedDuringReplay flaked with "binary frames seen: want 4, got 5" because runConn deliberately starts the writer before replayTo, so a live op appended during the replay window can be legitimately delivered twice (once live, once via replay), tolerated by Yjs idempotency. Count distinct op payloads instead of raw frames so the designed duplicate is tolerated while a truly dropped op still fails the assertion. |
||
|
|
0c0a71f96b |
fix(server): bearer-gate canEditComment's admin bypass (BUG-1919) (#796)
canEditComment's unconditional u.Role == "admin" bypass let a bearer-authed (PAT/CLI/MCP) platform admin edit or delete any user's comment in a workspace where they're a member, contradicting the BUG-1616/1617 bearer-suppression intent (same family as BUG-1917/1918). Gate the bypass on !isBearerAuth(r), mirroring the idiom already used in handlers_collab.go's authorizeCollabAccess. Cookie-session admins keep the existing behavior, including editing empty-user_id legacy comments; bearer-authed authors can still edit their own comments. |
||
|
|
d78efab167 |
fix(server): gate collection update/delete on visibility (BUG-1921) (#795)
handleUpdateCollection and handleDeleteCollection resolved a collection
by slug and gated only on requireMinRole("owner"), with no visibility
check afterward. A workspace-role owner independently restricted via
collection_access="specific" (member_collection_access has no role
exclusion, per BUG-1920) could rename, re-schema, or delete a
collection hidden from them.
Adds requireCollectionFullyVisible (added in BUG-1920 for the
share-link/grant minting twins) to both handlers, immediately after
the existing "collection not found" nil-check and after the pre-
existing requireMinRole 403 gate. This is the STRICT full-collection-
access variant, not handleGetCollection's nav-lenient
visibleCollectionIDs+isCollectionVisible check: an item-level grant on
a single item inside a hidden collection must not qualify as authority
to mutate the entire collection record. Restricted owners (session or
bearer) now get 404 on PATCH/DELETE of a hidden collection;
unrestricted owners are unaffected.
Downstream-consumer audit: every other collSlug-resolving handler
(views, artifact import, checkbox/child progress, item list/create,
bulk/single item move, grants, share-links) already gates on
visibility or edit permission. handleUpdateCollection/
handleDeleteCollection were the only ungated collection-record
mutation path.
Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
|
||
|
|
d9b9dd9499 |
fix(server): gate share-link and grant minting on item/collection visibility (BUG-1920) (#794)
* fix(server): gate share-link and grant minting/listing on item/collection visibility (BUG-1920)
A workspace-role "owner" can be independently restricted via
collection_access="specific" (handleSetMemberCollectionAccess has no
role exclusion), but handleCreateItemShareLink, handleListItemShareLinks,
handleListItemGrants, and handleCreateItemGrant gated only on
requireMinRole("owner") with no visibility check afterward — letting a
restricted owner (any auth class) mint a public share-link token or a
grant for an item in a collection hidden from them, an exfiltration path
since share links are public-read. The collection-level twins
(handleCreateCollectionShareLink, handleListCollectionShareLinks,
handleListCollectionGrants, handleCreateCollectionGrant) had the same gap.
Adds requireItemVisible (existing, bearer-aware post BUG-1917/1918) to
the four item-resolving handlers, and a new requireCollectionVisible
helper (mirroring handleGetCollection's visibleCollectionIDs +
isCollectionVisible idiom) to the four collection-resolving handlers.
Restricted owners (session or bearer) now get 404 minting/listing
share-links or grants for hidden items/collections; unrestricted owners
and non-owners (403 via the pre-existing requireMinRole gate) are
unaffected.
Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
* fix(server): require full-collection access for share-link/grant minting (BUG-1920 R2)
Codex R2 caught a gap in the collection-level half of the previous
commit: VisibleCollectionIDs (used by requireCollectionVisible) folds in
collections that are visible ONLY via an item-level grant, "so the
collection appears in navigation" — intentional for handleGetCollection,
but it let a restricted owner holding nothing more than an item grant on
one item inside a hidden collection mint/list a share link or grant for
the ENTIRE collection.
Renames requireCollectionVisible to requireCollectionFullyVisible and,
mirroring reportVisibleCollections' fullCollIDs narrowing
(handlers_reports.go), restricts the acceptable set to full-collection-
access collections (collection grants + member_collection_access +
system collections) whenever the caller holds any item-level grants —
an item-grant-only collection no longer qualifies for collection-wide
minting/listing. handleGetCollection is untouched; its nav-lenient
check is intentional for metadata viewing. Item-level requireItemVisible
call sites are unchanged — an item grant legitimately entitles the
holder to act on that item.
Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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.
|
||
|
|
7bf4679408 |
fix(web): harden WebMCP scalar param readers against malformed types (TASK-1895) (#790)
* fix(web): harden WebMCP scalar param readers against malformed types (TASK-1895) str/num/bool now throw a precise tool error on a present-but-wrong-typed value (e.g. a number where a string is expected) instead of silently returning undefined, closing the scalar-validation residual from TASK-1893's array hardening (strArray). Absent (undefined/null) and empty-string params remain non-erroring, preserving existing optional-arg semantics. * fix(web): guard the action read inside dispatch()'s try/catch (TASK-1895 R2) The action lookup (str(args, 'action')) sat outside the try/catch, so a malformed action (e.g. action: 1) made str() throw unhandled and the dispatch() promise rejected instead of resolving to an error envelope, breaking register.ts's assumption that dispatch() always resolves. Widen the try/catch to cover the action read through handler execution. |
||
|
|
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.
|
||
|
|
4e4d4edc86 |
fix(ci): raise race-step go test timeout to 45m (BUG-1913) (#787)
The internal/server suite's aggregate runtime under -race crossed the 30m budget (~30.3m on a fast local machine, 734 tests, none slower than 14s — growth, not a hang), making the Go jobs fail most main runs with 'panic: test timed out after 30m0s' on whichever test happened to be running. 45m restores headroom; genuine deadlocks still produce the goroutine-dump panic, up to 15m later. Real fix (cheaper suite) stays tracked on BUG-1913. Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS |
||
|
|
f235a04316 |
docs(readme): reflect Pad Cloud + remote MCP as shipped (IDEA-1790) (#785)
README still framed Pad as strictly local-only ('no cloud, no accounts
required', 'never leaves your laptop') even though Pad Cloud and the
remote MCP server at mcp.getpad.dev are both live. Adds a hosted-option
section under Installation, a remote-MCP pointer in the MCP section, and
softens the local-only absolutes — while keeping the local-first
identity and self-hosted-first-class framing intact.
CLAUDE.md and getpad.dev docs were verified already up to date; this
closes the last stale surface from IDEA-1790.
Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
|
||
|
|
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. |