mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
d2ee26cccd70dc117260ce166a0f45ed7136efcf
2238 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d2ee26cccd |
fix(auth): platform administration refuses an API token (BUG-3361)
BREAKING for anyone driving /api/v1/admin/* or admin account creation with a personal access token: those now answer 403 session_required. Use a `pad auth login` CLI session, or on self-host set PAD_ADMIN_API_TOKENS=allow. An admin's PAT carried full platform authority. codex reproduced the chain during BUG-3336's review: register an account with a chosen password, promote it to admin, sign in as it, and reset any account's password. A leaked admin token therefore escalated to interactive sessions for any account. - refuseAPITokenForAdmin guards the whole /api/v1/admin route group, so an admin route added later is refused by construction. TestBUG3361_EveryAdminRouteRefusesAnAdminPAT walks the router to hold it. - handleRegister refuses admin-create signup with an API token. - PAD_ADMIN_API_TOKENS=allow is the self-host escape hatch. It is read at startup, logs a WARN, and is never honoured in cloud mode. Documented in docs/deployment.md, Security. - A browser session and a CLI session (padsess_) are unaffected. So is the Pad Cloud sidecar, which authenticates with the cloud secret, never a PAT. /audit-log already refused a PAT (BUG-3334). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
9e886ddc00 |
fix(web): a link that opens elsewhere never keeps window.opener (BUG-3359) (#1739)
Raw HTML in markdown bypasses the safe link renderer, and both sanitizers
kept the author's target and rel, so <a target="_blank" rel="opener">
reached {@html} with window.opener live in the new tab. An
afterSanitizeAttributes hook in sanitizeMarkdownHtml and sanitizeHtmlBlock
now drops the opener token and forces noopener noreferrer on any element
whose target is not _self, keeping the author's other rel tokens.
|
||
|
|
c52f64e263 |
fix(netpolicy): one outbound address policy for URL import and webhooks (#1738)
* fix(urlimport): one address policy for URL import and webhooks (BUG-3358) URL import refused only loopback, RFC 1918, link-local and CGNAT, so it reached 198.18.0.0/15, 0.0.0.0/8, 240.0.0.0/4 and the other reserved ranges webhooks already refused, and the import door returns the fetched body. Both now call internal/netpolicy.Blocked, checked at dial time against the address being connected to, which also adds IPv6 forms that embed IPv4 (NAT64, 6to4, IPv4-compatible) and the remaining special-purpose IPv6 blocks. Regression legs cover a literal address, a DNS answer and a redirect. * fix(netpolicy): registry gaps, public exceptions, NAT64 by embedded address, malformed input (BUG-3358) Codex r1: add 192.88.99.0/24, 100:0:0:1::/64, 3fff::/20 and 5f00::/16; allow the globally reachable exceptions inside 192.0.0.0/24 and 2001::/23; judge the NAT64 well-known prefix by the IPv4 address it embeds instead of refusing the prefix; refuse any slice that is not 4 or 16 bytes. The redirect test now requires the policy refusal, and the dialer's literal screen has its own leg. |
||
|
|
d212eb6503 |
fix(members): keep a workspace's owner and at least one owner member (#1737)
* fix(members): a workspace keeps its owner and at least one owner member (BUG-3355) Role changes and member removals checked nothing about ownership: an owner could demote themselves or remove the last owner, leaving zero owners, and demoting the canonical owner split authority from workspaces.owner_id, which sets the URL, restore rights and plan limits. The store now refuses, in the write's own transaction, demoting or removing the canonical owner (409 canonical_owner) and leaving no owner member (409 last_owner), locking the owner membership rows FOR UPDATE on Postgres. An unknown role is a 400 instead of the CHECK constraint's misleading 404. * fix(members): anchor the owner invariant on the canonical owner, without row locks (BUG-3355) Codex r1: locking the other owners' rows FOR UPDATE neither serialized a concurrent promote-then-demote under READ COMMITTED nor kept a lock order account deletion agreed with (40P01). The canonical owner is an owner member in every live workspace and owner_id cannot change, so refusing that one row keeps an owner whatever runs concurrently; the last-owner count stays as a best-effort guard for legacy rows. Store test covers all three doors, including removal without revoking grants. |
||
|
|
3fe40aeff7 |
fix(import): bound the work a single bundle import can do (#1735)
* fix(import): bound a bundle's decompressed bytes and entry count (BUG-3354) The 2 GiB cap applied to the compressed body only. The tar stream now counts every decompressed byte against 4x the body cap plus room for both metadata files, refuses an entry declaring more than the budget has left before inflating it, and stops after 100,000 entries. Each answers 413 bundle_too_large through the existing reject path, which rolls back a workspace the bundle had already minted. * fix(import): charge sparse entries logically and count extension header blocks (BUG-3354) Codex r1: a sparse entry's holes are synthesized above the counted stream, and PAX/GNU extension headers are consumed inside Next where the entry count cannot see them. Declared sizes now draw on their own logical budget, the header blocks each Next reads are capped, and skipped non-regular entries are drained by the loop so that count stays headers only. * fix(import): refuse a bundle entry with a negative declared size (BUG-3354) Codex r2: archive/tar accepts a negative base-256 size on some entry types, and charging one raised the logical budget it was charged to. * fix(import): enforce the tar header limit inside each Next call (BUG-3354) Codex r3: tar.Reader.Next walks a whole PAX/GNU extension chain before it returns, so a header-block limit checked afterwards refused the bundle only after all of that work. The counting reader now carries a header window armed around each Next and fails the read that would pass it. * fix(import): the decompressed-size 413 states its limit and the setting that raises it (BUG-3354) |
||
|
|
551a338cbd |
fix(auth): a user-less legacy workspace token reaches only its own workspace (BUG-3353) (#1734)
A legacy workspace token (an API token with no user) carries one workspace and nobody's authority, but RequireAuth admitted it on every route. On global routes it acted with no user: - POST /workspaces minted a workspace with any slug, attributed to the body's owner_id, with no plan limits and no member row. That let it plant workspaces on a victim and fill their cloud quota. - POST /workspaces/import minted unowned workspaces without limit. - GET /workspaces fell into the fresh-install branch and listed every tenant on the instance. The fix is deny-by-default. RequireAuth admits such a token only on workspace-scoped routes, where RequireWorkspaceAccess pins it to its own workspace, and on a short allowlist of global routes that are either confined to the token's workspace (GET /workspaces, /search, /events, /collab) or static. Anything else answers 403 legacy_token_not_allowed. TestBUG3353_EveryGlobalRouteRefusesALegacyToken walks the router, so a route added later fails closed by test. - GET /workspaces with such a token lists its own workspace only. The no-user bootstrap branch now needs a user count that reads zero. - Minting: the owner is always the authenticated user and a body owner_id is ignored. beginWorkspaceMint refuses a user-less caller unless the instance has no users and there is no token. That settles the open question BUG-2809's trail left. - A token pointed at a foreign workspace now answers exactly what a missing one does: the workspace 404 on RequireWorkspaceAccess and /events, and the item 404 on collab. The 403 told the token which slugs and items exist. (Real users' BUG-3069 contract is unchanged, per the lead's ruling.) - TokenAuth never downgrades a user-owned token to a user-less one: a user that cannot be read is a 500, one that is gone a 401. Before, the request went on with only the token's workspace, which is a legacy token's reach. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
3d21213b57 |
fix(account): account deletion needs an interactive, recently signed-in session (BUG-3336) (#1733)
* fix(account): an API token cannot delete the account; confirm-only is for passwordless cloud accounts (BUG-3336) POST /auth/delete-account deletes the user and soft-deletes every workspace they own, and two holes let it go through with less proof than the caller should have to show. 1. Confirm-only (no password) was accepted from ANY cloud account. The path exists for OAuth-only accounts, which have no password to give, but it never checked HasPassword, so a cloud session alone could delete an account that has one. It now requires !HasPassword. This matches the web client, which offers confirm-only only when password_set is false. 2. A PAT was accepted like a session, so a leaked write token could delete the account (and, with the password, on self-host too). An API token is now refused with 403 session_required, before the body is read, so it cannot probe the password either. This is the line BUG-2890 drew for token minting. A browser or `pad auth login` CLI session still works. No CLI or MCP command deletes an account. TOTP is unchanged and still runs after either identity check. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(account): confirm-only deletion needs a recent sign-in; rotation keeps the sign-in time (BUG-3336) codex review of the first commit: password_set=false does not prove an account has no password. An account that signed up with a password before migration 043 and later linked OAuth reads false and still holds a real hash, and CreateOAuthUser stores a hash too (a random placeholder), so the data cannot tell the two apart. A session alone could still confirm-delete such an account. Confirm-only now also needs a session signed in within accountDeleteReauthWindow (10 minutes, measured from sessions.created_at). Otherwise it answers 403 reauth_required, "For your security, sign in again to delete your account", and the settings page offers a "Sign in again" button: it signs out and returns to /login?redirect=/console/settings, because /login sends a signed-in visitor straight on. A credential-change rotation (password change, 2FA, OAuth unlink) no longer restarts the clock. CreateSessionIssuedAt gives the new session the replaced one's created_at, which also stops a rotation from extending SessionMaxLifetime (measured from created_at); its expiry is capped there. ValidateSession now returns CreatedAt. TestWorkspaceAccessChanged_AccountDeletion deleted through a PAT, the path the first commit refuses; it now uses an interactive session. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(auth): no session-minting door hands out a fresh sign-in without one (BUG-3336, BUG-3349a) codex review, round 2: the re-auth window could be beaten by minting a session whose created_at reads as new. - CLI approval minted a 30-day session from any authenticated caller. It now refuses an API token (403 session_required), and the CLI session it mints carries the APPROVING session's sign-in time (CreateSessionIssuedAt). An approver whose session cannot be read is refused. - requestSessionInfo read `Bearer padsess_` literally and missed a padded header that TokenAuth accepts after trimming. It now reads the header the way TokenAuth does, and a bearer of any kind is the credential. - Rotation (rotateSessionsAfterCredentialChange) fell back to a fresh created_at when it could not find the request's session. Every caller is a session-only door, so it now revokes every session and asks for a new sign-in instead of minting one. - oauth-unlink and PATCH /auth/me with a password change refuse an API token, so rotation is reachable only from a session (TOTP disable already was). A PAT can still edit the profile. These three gates are BUG-3349's part (a), moved here because the re-auth window depends on them (lead's ruling). Legs: CLI approval from an old session or a PAT, the padded bearer, rotation with no session, and the SessionMaxLifetime cap on rotation. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(auth): admin password reset refuses an API token; the request's session is the one that authenticated it (BUG-3336) codex review, round 3. - An admin's PAT could reset its own account, get a temp_password back, and sign in as a fresh session. handleAdminResetPassword now refuses an API token (403 session_required). - requestSessionInfo re-read the Authorization header. An invalid bearer on /auth/* falls through to the cookie in TokenAuth, so a valid cookie session beside a stale PAT header read as "no session": CLI approval answered 401, and the rotation doors revoked every session and answered 500. It now follows ctxAuthKind, the credential the middleware actually used (as credentialLiveness does): a session bearer is read from the header, an API token is no session, and anything else from the cookie. - Legs added: - the password-change, oauth-unlink and admin-reset PAT gates, each refusing with nothing changed or revoked behind the refusal; - a stale bearer beside a valid cookie; - a rotation with no session revoking every session. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
4b53142589 |
fix(server): match accounts by email only once the address is verified (#1732)
* fix(server): by-email access doors require a verified account (BUG-3348) * fix(server): an invitation code does not verify an address (BUG-3348) The inviter receives the same code in the invite response, so accepting by code no longer verifies the account, and on cloud with a deliverable verification email an invited signup starts unverified and is sent the link. Self-hosted is unchanged. * fix(server): mint the verification link before an invited signup's membership writes (BUG-3348) Codex r2: after the membership row exists DeleteUser cannot roll the account back, and the invitation path's kept-account return skipped the later send. |
||
|
|
68a7e279a7 |
fix(leases): bind item leases to the claiming account (#1731)
* fix(leases): claiming takes edit permission, and a lease is bound to its claimer (BUG-3341) Item claim and release checked only that the caller could SEE the item, so a viewer or a view-only guest could claim an item (blocking others from working it) or release someone's lease. Both now take requireEditPermission, grant-aware like any item edit. The holder is a free-form label (an agent name, "sweep-runner") and it alone authorized refresh and release, so any editor could end or extend another user's lease by naming its label. Migration 104 (SQLite) / 078 (Postgres) adds items.lease_user_id: a claim records the signed-in user, and refresh and release require the same user AND the same label; any other caller gets the existing 409 lease_held. Leases claimed before the migration have no user recorded. They keep refreshing and releasing on their label alone until they expire (at most 24h), so a deploy breaks no live lease; the first refresh by a signed-in caller binds it. Tests: a viewer and a view-only guest are refused, an editor and an edit-grant guest allowed; another editor naming the label can neither release nor refresh; a pre-binding lease works on its label and binds on refresh. Each goes red with its fix removed. Co-Authored-By: Claude <noreply@anthropic.com> * fix(leases): classify items.lease_user_id in the NUL-column census (BUG-3341) Codex (no P0/P1, no lease-authorization defect) P2: the new column joined the schema without a census entry, which TestNULColumnCensus refuses. It holds the authenticated caller's server-generated user id, written only by ClaimItemLease from the session, so it is recorded as an exclusion with that reason. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
1f391d9591 |
fix(auth): store errors on access decisions fail closed (BUG-3334) (#1730)
A user count that cannot be read is no longer an empty instance. The fresh-install bypass is granted only when the count reads zero WITHOUT an error. Before this, RequireAuth let an anonymous request through, RequireWorkspaceAccess granted owner, CSRFProtect skipped its check, the SSE recheck answered "everyone has access" and collab granted write. On error each now goes on to its normal principal checks, so a signed-in caller keeps working through a count fault and an anonymous one is refused. Six item handlers ignored a visibleCollectionIDs error and built the response with nil, which means unrestricted, so a restricted member was shown a hidden parent's title (get, update, restore) and progress counted hidden children. - get and progress now answer 500. - update and restore resolve visibility BEFORE the write, so an error is a 500 with nothing written, not a committed write reported as failed (create has no idempotency key). - create and move reuse the resolution they already checked before their write. /audit-log refuses a PAT with 403 session_required. The platform-wide log belongs to the admin's interactive session (cookie or CLI session, so `pad workspace audit-log` keeps working), the line BUG-2890 drew for token minting. Legs inject real faults where one can reach the site (a renamed table). Two test-only seams cover the sites an earlier check masks: - visibleCollectionIDsFault: requireItemVisible runs the same query first. - userCountFault: the workspace lookup also reads users. Both are nil in production, which a test asserts. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
1f982df402 |
fix(sse): an item event without a collection is not workspace-level (BUG-3343) (#1729)
The comment-update, reply and reaction publishers resolved the item's collection AFTER the write with GetItem, which skips archived items, so an archive racing the write left the event's collection empty. The SSE filter treats an empty collection as a workspace-level event and delivered it to every member, so a collection-restricted member received the hidden item's ID and title. - sseEventVisibleFor: for a restricted subscriber, an item event with no collection is refused (it names one item, and there is no way to confirm it is visible). Workspace-level events without an item are unchanged. - The three post-write lookups use GetItemIncludeDeleted, so the event keeps its collection and still reaches those who may see it. Each test fails with its own fix reverted. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
30ba2ed304 |
fix(api): body-supplied IDs must name something in the authorized workspace (BUG-3346) (#1728)
Three request-body IDs were never checked against the workspace the route authorized: - A top-level comment's parent_id: the store's parent check was `WHERE id = ?`, so a comment in one workspace could hang off another workspace's comment (an existence oracle on its IDs, a changed delete behaviour for its owner, and a foreign key that can block its purge). The check now requires the parent to be on the same item in the same workspace; anything else is the not-found answer, as the reply route already enforced. - An item's raw top-level parent_id on create and update, written to items.parent_id as given (the store trusts its caller to have checked). It must now name a live item in the workspace, else 400 validation_error, identical for nothing and for another workspace's item. - A personal token's workspace_id: a token could be pinned to a workspace the caller is not in. It must now be a live workspace they belong to, else the workspace 404 (also replacing a 500 for a nonexistent one). Each test fails on main and with its own fix reverted. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
7ee0bddc45 |
fix(webhooks): whole-workspace access to manage; no delivery for a deleted workspace (#1727)
* fix(webhooks): whole-workspace access to manage them; none for a deleted workspace (BUG-3340) A webhook receives item snapshots from every collection, but creating or test-firing one required only the owner role, so an owner restricted to specific collections could route every collection's content to a URL of their choosing. Create and test now require unrestricted workspace access, through the same check full export uses (BUG-1922), factored into requireUnrestrictedAccess. Delete stays open: it is revocation. ListWebhooks now returns nothing for a soft-deleted workspace. Both dispatcher delivery paths (Dispatch and DeliverEvent) list through it, so a deleted workspace's events are dropped instead of delivered during the restore window; a restore brings the webhooks back. Co-Authored-By: Claude <noreply@anthropic.com> * fix(webhooks): re-check the workspace before every send; hide URLs from restricted owners (BUG-3340) Codex r1 (no P0): - P1: ListWebhooks checked deletion once, but a delivery reuses the hook list and payload across inline retries, later hooks in the fan-out, and redirect hops; codex reproduced POSTs after the delete committed. The dispatcher store gains WorkspaceLive, checked before every attempt (a new deliverySuppressed result: nothing sent, not the endpoint's failure, not counted as matched) and before every redirect hop (the request carries the hook's workspace). A lookup error is transient, so a blip delays rather than drops or sends unchecked. - P2: the list still showed restricted owners every destination URL, and a URL can be the credential to a receiver's stored request bodies. A restricted owner now sees each webhook without its URL (the id is kept, so they can still delete it). Tests: retry, later hook and redirect after a mid-delivery deletion each send nothing (all three red with the checks disabled); the list hides URLs from the restricted owner only. Co-Authored-By: Claude <noreply@anthropic.com> * fix(webhooks): check the workspace on every connection; defer on lookup errors (BUG-3340) Codex r2 (no P0): - P1: net/http can send more than once inside one Do (it re-dials and replays the body after a reused connection fails with nothing written), and a deletion can land during dial or TLS setup, both after the per-attempt check. An httptrace GotConn hook now re-checks the workspace on every connection the request is about to be written on, after TLS and before writing, and cancels the request if it is gone. - P2 (two): a liveness lookup ERROR was treated as a deletion at the redirect check (the outbox then acked an undelivered event) and as an endpoint failure before a send (charged to the hook, which could auto-deactivate it). A new deliveryDeferred result sends nothing, keeps the event owed (Transient, so the outbox does not ack), and charges nothing to the hook. Suppression is reserved for a confirmed deletion. Tests: deletion before the connection is ready sends nothing; a pre-send lookup error and a redirect lookup error each defer without sending or charging. Each goes red when its own fix is reverted. Co-Authored-By: Claude <noreply@anthropic.com> * fix(webhooks): close the connection in GotConn, a real write barrier (BUG-3340) Codex r3 (no P0/P2): cancelling the request context in GotConn is not a write barrier for HTTP/1: the transport queues the request before it checks the context, so its writer could still send the whole request (codex measured 10/10,000 in the zero-send test). GotConn now also closes the connection it was handed when the workspace is gone or its liveness unknown: nothing can be written to a closed socket, and the cancel stops any transport retry on a fresh one. Measured here: the cancel-only version fails the zero-send test 2/10,000; with the close, 0/10,000 for both the deletion and lookup-error tests, and the webhooks package passes under -race. Co-Authored-By: Claude <noreply@anthropic.com> * fix(webhooks): the connection check decides the outcome even if Do returns a response (BUG-3340) Codex r4 (no P0/P1) P2: after GotConn closes and cancels, net/http can still return an already-buffered response, and the suppressed/deferred flags were only consulted when Do returned an error, so such a response counted as a delivery. The flags are now checked right after Do, whatever it returned, closing any response body first. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
daf94614ca |
fix(documents): restore only a document of the URL's workspace (BUG-3335) (#1724)
handleRestoreDocument called store.RestoreDocument(id), which read and un-archived the document by ID alone and committed; only afterwards did the handler compare workspaces, and then "compensated" with a separate DeleteDocument. An editor of one workspace could restore another workspace's archived document by ID, exposing it until the re-delete and leaving its status, updated_at and attachment-reference stamps changed. RestoreDocument now takes the workspace and scopes both its SELECT and UPDATE by workspace_id, so a foreign document is sql.ErrNoRows and is never written; the handler answers 404 for that, 500 for any other error, and no longer compensates. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
6e30a549a1 |
fix(share-links): a link stops resolving while its workspace is deleted (#1726)
* fix(share-links): a link stops resolving when its workspace is deleted (BUG-3339)
DeleteWorkspace only sets deleted_at (the purge runs after the 30-day
restore window), and GetShareLinkByToken never checked the workspace, so
/s/{token} and /s/{token}/attachments/{id} kept serving a deleted
workspace's content for the whole window. The token lookup now joins
live workspaces, so such a link answers exactly like an unknown token;
restoring the workspace brings it back, since the link was never
destroyed. Both public share routes resolve the token through this one
lookup.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(share-links): the share resolver is never cacheable (BUG-3339)
Codex r1 (no P0/P1) P2: handleResolveShareLink emitted no Cache-Control,
so a heuristic cache could keep serving a deleted workspace's content
without reaching the token lookup, and a cached 404 could outlive a
restore. It now sets private, no-store on every answer, as the
attachment route already does; the test asserts it on 200 and 404.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
3b27a138e4 |
fix(grants): an item grant never opens a deleted collection (BUG-3333) (#1725)
VisibleCollectionIDsQ promotes the collection of an item a restricted member is granted into their nav-only visible list, without checking the collection is live; GuestVisibleResourcesQ drops grants in deleted collections. After the collection was soft-deleted the member's grant list came back empty, the item handlers fell back to the visible list, and served every live item in the deleted collection (/items, /items-index, /items-changes, /tags, dashboard, graph, checkItemVisible). That promotion now requires a live collection. Assigned collections and whole-collection grants are unchanged: they already gave the whole collection, and they keep a grant on a deleted collection revocable. The delta-sync variant, GuestVisibleResourcesIncludeDeletedQ, also kept grants on deleted collections, so a guest still holding another live grant received the deleted collection's items as live upserts. It now keeps live collections only, like the live path; clients learn the collection went away from access_epoch (IDEA-2898). Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
38c591673f |
fix(search): scope workspace search to visible items (#1723)
* fix(search): scope workspace search to visible items (BUG-3331) /api/v1/search is mounted outside RequireWorkspaceAccess and decides access itself. A caller with no visibility in a named workspace reached the store with an empty permission filter, which the store read as no filter at all. Store: the permission filter is fail-closed. SearchParams gains an explicit Unrestricted flag; empty CollectionIDs and ItemIDs (nil or not) now match nothing unless it is set. One helper applies the predicate on every query path (ref lookup, number lookup, FTS, count, facets) and adds a match-nothing predicate when a restricted filter is empty. Handler: searchAccessFor resolves each workspace the way RequireWorkspaceAccess does (fresh-install window, user-less workspace token limited to its own workspace, consent allow-list, cookie-only admin bypass, full and restricted members, grants for non-members), for both a named workspace and the unscoped fan-out. No access is an empty result, not a 403. Collections: a caller who sees a collection only through an item grant gets item_count and active_item_count computed from the granted items, not the whole collection. Co-Authored-By: Claude <noreply@anthropic.com> * test(search): cover the restricted number lookup, total, mixed fan-out and active count (BUG-3331) Codex r1 (gpt-6-luna, max scrutiny) found no P0/P1. Its P2 (a restricted member's direct grant to a soft-deleted collection now hides that collection's items from search while the item list still shows them) is search being the stricter of two doors; the item list's side is BUG-3333. Its test gaps are closed here: the bare-number lookup and the filtered total for an item-grant guest, a fan-out over workspaces with different access, and the active count following a closed granted item. Also: the named-workspace consent check is visible in handleSearch again (the allow-list coverage guard scans the handler; searchAccessFor still re-checks), and the now-unused removeString is gone. Co-Authored-By: Claude <noreply@anthropic.com> * fix(search): check the consent allow-list first in searchAccessFor (BUG-3331) Codex r2 (no P0/P1): the allow-list check came after the no-user branch, the reverse of RequireWorkspaceAccess, so a context with a mismatching allow-list and a matching legacy workspace token would have read its workspace. No current auth flow produces that combination, but the order now matches the middleware and the coverage claim is exact. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
6f6e7ba176 |
chore(nix): bump package version to 0.18.0 ahead of the v0.18.0-rc.1 tag (#1721)
Claude-Session: https://claude.ai/code/session_01J9famMX8HvArgW2uo8mPRTv0.18.0-rc.1 |
||
|
|
82064526e3 |
fix(brand): name the entity as Perpetual Software LLC everywhere (TASK-3321) (#1722)
Dave's ruling: the legal entity is Perpetual Software LLC and there is no dba, so "Perpetual Software" never names the entity without "LLC". Fixed: the ChatGPT plugin's author and developerName, both Claude plugin manifests' author/owner, the brand spec's copyright format, the cloud email footers (HTML and plain), and the auth-page footer. The guard test now pins the ChatGPT package's developerName and author to the full name, and the email tests expect it. LICENSE already said "Perpetual Software LLC"; goreleaser, the Dockerfiles and Nix carry no entity name. The self-hosted email test keeps asserting the bare substring is ABSENT: the shorter string is the stricter check there. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
26fd8a6a46 |
feat(mcp): ChatGPT tools declare their OAuth scope; a missing scope answers the sign-in challenge (TASK-3321 U2c) (#1720)
* feat(mcp): ChatGPT tools declare their OAuth scope; a missing scope answers the sign-in challenge (TASK-3321 U2c) Every ChatGPT catalog tool now carries securitySchemes, an oauth2 scheme naming the one scope it needs: pad:read for a read tool and pad:write for anything else. The Apps SDK reads it at the tool's top level and keeps _meta.securitySchemes as a mirror. mcp-go's Tool marshals a fixed set of keys, so the tool carries the _meta copy, and WithChatGPTToolSchemes adds the top-level copy to a single tools/list JSON response on the ChatGPT mount. Every other request and response passes through byte for byte. When a write tool is called with a read-only grant, the handler answers before dispatching anything: an isError result whose _meta["mcp/www_authenticate"] is a Bearer challenge with error="insufficient_scope" and an error_description, naming the protected-resource document of the mount the request arrived on (server.MCPResourceMetadataURLFromContext). That is what makes ChatGPT offer to reconnect with the wider grant. The wire golden is built through the production transport (newChatGPTTransport). Only the ChatGPT golden moved; /mcp is byte-identical. ChatGPTToolSurfaceVersion stays 0.1: it is unstable and unpublished. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(mcp): ChatGPT tools/list is a JSON body with top-level securitySchemes when asked the way ChatGPT asks (TASK-3321 U2c) Streamable HTTP upgrades a response to an event stream only by flushing notifications mid-request (the remote transport has no event store), and WithChatGPTToolSchemes hands the transport a writer that cannot flush. So tools/list requested with Accept: application/json, text/event-stream is a JSON body, and every tool carries securitySchemes at the top level. One leg pins that through the production transport, and another pins that the buffered writer never implements http.Flusher. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
1339916b9c |
feat(chatgpt): serve OpenAI's domain challenge at /.well-known/openai-apps-challenge (TASK-3321 G4) (#1717)
OpenAI verifies a plugin's domain by fetching the token its dashboard issued from /.well-known/openai-apps-challenge on the MCP host, and the body must be the token alone. PAD_OPENAI_APPS_CHALLENGE (toml openai_apps_challenge) resolves into config.MCPEndpoints, so it reaches the server through the existing SetMCPConfig binding. A value with inner whitespace or a control character, or over 1024 bytes, is refused into Problems() and logged at startup rather than served. The route answers 200 text/plain with exactly the token only on cloud, only with a token set, and only on the MCP URL's host. Every other case is the U0a JSON 404, byte-identical to an unowned well-known path. It is not behind the MCP or OAuth availability gates, because verification happens before the plugin server is switched on. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
6118f7065c |
feat(mcp): mount the ChatGPT catalog at /mcp/chatgpt, OAuth-only, off by default (TASK-3321 U2b) (#1719)
The ChatGPT projection (U1) gets its own transport at <MCP URL>/chatgpt, behind PAD_CHATGPT_MCP_ENABLED=true and served only while OAuth is available. It is its own OAuth resource (U2a's second canonical): its path-aware protected-resource document is /.well-known/oauth-protected-resource/mcp/chatgpt, its 401s name that document, and tokens do not cross between the two mounts. Personal access tokens are refused there with a 401 naming OAuth-only, ahead of the PAT branch that would accept them without the audience check. The consent screen names the ChatGPT surface when the requested resource is the ChatGPT one. The mount is wired only while the door versions content (ChatGPTDoorVersionsContent, held to the behaviour by a test) and every catalog tool has a response shape (ChatGPTCatalogReady); otherwise startup logs and skips it. /mcp is byte-identical. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
d15b31077a |
feat(chatgpt): a reset-and-verify script for the reviewer's demo workspace (TASK-3321 G8) (#1718)
scripts/chatgpt-review-seed.sh resets the Acme Launch workspace to the state the plugin's review test cases expect (DOC-3328 section 4), through the pad CLI as whichever account it is signed in as. It creates no account. It archives live items outside Conventions and Playbooks, creates the workspace and a Bugs collection if missing, re-creates the seed items, links and comments, then checks every test case's precondition; --verify-only runs just the check. It refuses without --yes, and refuses any slug but acme-launch without --allow-other-slug. Running it against a throwaway server corrected two assumptions: full-text search does not stem, so the login bug's body says "timing out" (the query "login timing out" found nothing before), and what_next recommends the in-progress onboarding task before the open high-priority one, so P1's expected behaviour now names both, and the Dark mode idea is seeded "new" (an exploring idea ranks as in progress). Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
2dc5ac7058 |
feat(mcp): ChatGPT item_history names who made each change; email-shaped names are masked (TASK-3321) (#1716)
#1714 put actor_name on every history summary row. The ChatGPT item_history allow-list now keeps it, and the description says "who" again. Per the lead's ruling, a display name that is (or contains) an email address reaches ChatGPT as "a Pad user" in every person field (author, agent_name, actor_name, assigned_user(_name), owner_username, assigned_users, and user.name), never as the local part, which would be a new identifier. Other names (workspace, collection, role) are untouched. The leak sweep now runs twice, the second time with an owner whose display name is an email address. It fails with the mask off, as does the history leg. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
5c53aae306 |
feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a) (#1715)
* feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a) Ruling (i) on TASK-3321: the ChatGPT catalog is served at its own MCP URL, which is a canonical OAuth resource of its own, and its tokens are not interchangeable with /mcp's. - config derives ChatGPTResourceURL from the /mcp resource: its MCP path plus /chatgpt, so https://mcp.getpad.dev becomes https://mcp.getpad.dev/mcp/chatgpt. - The authorization server issues tokens for AllowedAudience plus AdditionalAudiences. A request must name exactly ONE of them, and every requested audience must be the same resource. A request for both, or for a foreign one, is refused before a code is issued, and the client's hydrated audience must include the one requested. A request naming no resource is still bound to /mcp's, the documented default. - MCPBearerAuth checks the MOUNT's canonical. A route stamps it with WithMCPResource (never from the request); an unstamped route is /mcp's. A /mcp token is refused at the ChatGPT mount and the reverse. Nothing is mounted yet (U2b). The cross-refusal test covers both directions and the no-resource token, and goes red when the middleware ignores the mount or the strategy allows two resources. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(oauth): a refreshed token keeps its one resource (TASK-3321 U2a review) Codex confirmed fosite's refresh flow restores the original granted audience; this pins it end to end: refreshing a ChatGPT token (with no resource, or asking for /mcp's) yields a token refused at /mcp and accepted at its own mount, and the test fails if no refresh succeeded at all. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
809d497399 |
feat(chatgpt): the Pad plugin package for OpenAI's plugin directory (TASK-3321 U4) (#1712)
* feat(chatgpt): the Pad plugin package for OpenAI's plugin directory (TASK-3321 U4) integrations/chatgpt holds the Agent Plugins 1.0.0 package OpenAI's submission takes: plugin.json (listing metadata plus exactly 5 positive and 3 negative review test cases), mcp.json (the one MCP server, the /mcp/chatgpt catalog), an MCP-only Pad skill naming only the 21 ChatGPTCatalog tools, and icon assets. Local files only: nothing is submitted or registered, and there is no challenge file. internal/mcp/chatgpt_plugin_package_test.go keeps the package tied to the catalog: every tool the skill or a test case names must be a catalog tool, the skill must carry no CLI or filesystem marks, and the documented submission limits must hold (field lengths, case counts, square icons, brand contrast, one server, no apps or hooks). The submission metadata draft, reviewer demo account seed and gap list are DOC-3328. Co-Authored-By: Claude <noreply@anthropic.com> * fix(chatgpt): the plugin's MCP server is mcp.getpad.dev/mcp/chatgpt (TASK-3321 U4) Ruled on TASK-3321: the ChatGPT catalog mounts on the MCP host, beside the canonical /mcp resource. mcp.json, the guard test's URL and the README follow; links to the Pad app stay on app.getpad.dev. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
d3b1209d04 |
feat(mcp): ChatGPT responses are minimized through per-tool allow-lists (TASK-3321 U3) (#1713)
* feat(mcp): ChatGPT responses are minimized through per-tool allow-lists (TASK-3321 U3)
Ruling R2: ChatGPT tool responses carry refs, titles, statuses, fields,
names and user-meaningful dates, and no UUIDs, emails, counters or
internal codes. Before this, the workspace overview carried the caller's
email, every full item carried assigned_user_email, and search, create,
update, restore and get_playbook returned raw items.
Each tool's response goes through an ALLOW-LIST shape in the ChatGPT
handler: a key nobody listed is dropped, so a new field cannot reach
ChatGPT by default. The text fallback is rewritten from the projected
value. Relation values in fields become refs, and an id no ref replaced
is dropped. Comment id and parent_id stay as reply_to handles, and seq
stays for expected_seq. list_collections' schema is parsed to an object.
A plan-limit refusal loses its upgrade text and billing link.
Surface fixes: ready_items drops a limit the server ignored; item_history
no longer claims "who"; archive_item answers {ref, archived: true}; the
set_workspace exclusion reason is corrected (#1710 wired the bootstrap).
/mcp is unchanged: its goldens do not move.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(mcp): ChatGPT errors and non-JSON results are minimized too (TASK-3321 U3 review)
Codex round 1, four P2s:
- Error results passed through unprojected. They now keep only code,
message, hint, field, expected and workspace slug and name, with ids in
the prose masked; a source Go error becomes a plain failure. A raw
validation hint that quotes an unresolved id was the case that
reproduced it; the error-path test fails without the projection.
- A non-JSON success passed through. It now fails closed.
- archive_item echoed the caller's ref even when it was an id. An
id-shaped ref is no longer echoed.
- The sweep now checks the text fallback with the same rules, and covers
error results.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(mcp): mask ids in an error's field and expected too, and pin available_workspaces (TASK-3321 U3 review)
Codex round 2: field and expected were kept but not masked, and nothing
pinned that available_workspaces (slug, name, default) survives the error
projection. The envelope test now sets an id in every text member and fails
when only message and hint are masked.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
0c0874dc10 |
feat(mcp): pad_item.history rows name who made the change (ToolSurface 0.63) (#1714)
Every pad_item.action=history summary row, on both transports, gains an omitempty actor_name: the display name of the user who made the change. Rows said only created_by "user" or "agent", although the server joins the name on every version read. Empty for rows with no user (recovery writes, versions from before migration 103). ADDITIVE, on the v0.53 and v0.28 grounds; no name, enum or param moved. The CLI's projection, which stdio returns as it stands, moves into toItemVersionSummaries so it can be tested. Each transport has a leg that fails without its copy. Requested by the lead as its own PR before TASK-3321 U2, so the ChatGPT item_history tool can say who changed an item again. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
bc2a982591 |
feat: every content change through the ChatGPT door is versioned first (TASK-3321 U1b) (#1711)
* feat(items): every content change through the ChatGPT door is versioned first (TASK-3321 U1b)
Ruling (b) on TASK-3321: ChatGPT's update_item is non-destructive because
every content change can be undone from History. An in-process context
marker (server.WithChatGPTSurface, unreachable from the wire) marks the
ChatGPT door. For a content write through it, the update handler sets
ForceVersion past the 1h throttle, labels the version "chatgpt", and sets
RefusePendingEdits: a tab's unflushed edits refuse the write with 409
content_pending_flush rather than be missing from the version and lost to
an undo. That refusal speaks to a chat user ("being edited in Pad right
now; ... Try again shortly") and names no API override. Field-only updates
write no version. A plain API body may not claim the chatgpt label. With a
tab open and caught up, the version is written through BUG-3327's
ExternalContent.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* feat(mcp): the ChatGPT catalog marks its door, so update_item versions every content change (TASK-3321 U1b)
Every ChatGPT tool call now reaches its source handler with
server.WithChatGPTSurface on the context. The HTTP dispatcher builds its
in-process request from that context, and the item update handler saves a
version before every content change from that door. The equivalence test
now checks the one intended difference: every ChatGPT dispatch carries the
marker (all 21 tools fail without it) and no /mcp dispatch does.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(items): a collab-snapshot flush cannot wear the chatgpt label; simplify the strip (TASK-3321 U1b review)
Codex round 1 suspected a ?source=collab-snapshot flush could keep a
body's chatgpt label. It cannot: the collab-snapshot branch overwrites the
label first. That made the strip's collab guard dead code, so it is
removed, and the case is pinned by a test leg.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
3901101c81 |
fix(mcp): remote pad_set_workspace embeds the workspace bootstrap (BUG-3326, ToolSurface 0.62) (#1710)
* fix(mcp): remote pad_set_workspace embeds the workspace bootstrap (BUG-3326) registerRemoteMCP passed no BootstrapFetcher, so over /mcp the tool returned only a not_persisted note: remote clients started sessions without the conventions and playbooks stdio clients get. HTTPResourceFetcher gains Bootstrap, delegating to its existing in-process bootstrap resource read, which runs as the token's user through the dispatcher's scope/consent perimeter. registerRemoteMCP builds the fetcher once and hands it to both Register and RegisterResources. Tests go through wireMCP over /mcp with PAT bearers: the embed is present for a member, and absent for a non-member while the owner of that workspace gets it. Co-Authored-By: Claude <noreply@anthropic.com> * chore(mcp): ToolSurfaceVersion 0.62 for the remote set_workspace bootstrap embed (BUG-3326) ADDITIVE bump (v0.53 / v0.28 grounds): remote pad_set_workspace gains the `bootstrap` member stdio always carried. No name, enum or param moved; the tools/list goldens are unchanged, the initialize goldens move only the version and the instructions header. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
d5ca8a7eb0 |
fix(versions): an applier-path body write leaves a version row (BUG-3327) (#1709)
* fix(versions): an applier-path body write leaves a version row (BUG-3327) A content write that takes the designated-applier path (any API, CLI or MCP body edit while a browser tab has the item open) wrote no version: applierFirstWrite runs the row write with Content nil, and the store's version write lives only on the content path. An agent's edit made while someone had the item open was missing from History. The tab's later flush could cover it only partly: attributed to the tab's user, and throttled away entirely when that tab had flushed within the hour. ItemUpdate.ExternalContent (internal-only) now carries the body the applier sends to the live document. The store versions it as it would Content (same throttle, the writer's attribution) in the row write's own transaction, and never writes it to items.content. That version stores the FULL replaced body, not a reverse patch: patches resolve backwards from the row, and on this path the row keeps the old body until the flush lands. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(versions): the tab flush after an applier edit does not store the replaced body twice (BUG-3327 review) Codex round 1, P2: the tab's flush that lands an applier edit in the row wrote its own version of the row's still-old body. That duplicated the applier row and put the agent's change in History under the tab user's row, leaving the agent's own row an empty change. An unforced version write now skips a body the newest version already holds verbatim as a full-body, non-create row. Create rows are excluded: the first edit still owes its own row. Forced writes (restore) are unaffected. In a throttled sequence (A->B versioned, B->A throttled, A->C) the skip loses nothing: the body is already in that newest row. The test now covers the flush: no second row, the chain still resolves, the agent's row reads previous -> next, and the oldest version resolves. A failed apply after the row write leaves a version of a body that did exist (round 1 P2 #1), which is accepted rather than compensated. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(versions): only a flush skips a duplicate version (BUG-3327 review) Codex round 2, P2: the duplicate skip matched any unforced write. After a user's A->B (versioned) and a throttled B->A, a different writer's A->C was skipped and its change read as the first writer's. The skip now applies only to a collab-snapshot flush, the one writer that follows an applier edit. A new test leg fails without that limit. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(versions): recognise the applier row positively before a flush skips its duplicate (BUG-3327 review) Codex round 3, P2: limiting the skip to flushes was not enough. An ordinary flush after a throttled revert to an earlier versioned body matched on content alone and lost its version. The row an applier edit wrote is now recognised positively: full body, not a create row, equal to the row's body, and not older than the item's content_flushed_at. Every direct content write bumps that timestamp in the same write as its version, so only an applier row can postdate it. The comparison is not-before because both are stored to the second; the residual (a versioned edit and its throttled revert inside one second, then a flush) is written down at the check. A new leg fails without the timestamp test. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(versions): no content_flushed_at is no evidence for the flush dedupe (BUG-3327 review) Codex round 4, P2: a NULL or empty content_flushed_at (migration 052 leaves it NULL on items with op-log rows) made the applier-row check answer true without any comparison, so an ordinary flush on such an item could lose its version. A missing timestamp now means the skip does not apply. The cost is a duplicate row, never a lost one. A store test covers both arms and fails on the old answer. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
39a5259551 |
feat(mcp): the ChatGPT catalog, a one-tool-per-operation projection of /mcp (TASK-3321 U1, stacked on #1707) (#1708)
* feat(mcp): the ChatGPT catalog, a one-tool-per-operation projection of /mcp (TASK-3321 U1) A second tool surface for OpenAI's plugin directory: 22 tools, each naming the /mcp (tool, action) it stands for and executed by that tool's own fan-out handler, so validation, dispatch and every server rule are the ones /mcp runs. An entry adds only its outward shape: a name, a description, a narrowed parameter list (types and enums from the source), its own hints, Fixed inputs (get_item always asks for the agent view), and the irreversibility justification the submission form asks for. Guards: - every /mcp operation and pad_set_workspace is an entry or an exclusion with a reason, never both or neither, with no stale names; - the hints match the ruling, written out independently; - the validator refuses each defect it exists for; - every exposed param has a ChatGPT-own description, and no description uses /mcp's action vocabulary; - each ChatGPT tool dispatches exactly as its source (CLI path, args and dispatch input) through a recording dispatcher over the real cmdhelp doc; - an undeclared param is refused before dispatch; - a new golden pair pins the ChatGPT initialize and tools/list. /mcp is byte-identical: its golden files do not move. NewServer gains optional Instructions and Experimental overrides, defaulting to /mcp's. Not mounted: U2 serves it at its own URL behind a setting, after U1b (update_item's forced version row, which its description already promises) and U3 (response minimization). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(mcp): ChatGPT catalog keeps field number literals; set_workspace is an exclusion (TASK-3321 U1 review) Codex round 1: - P1: the projection re-marshalled the DECODED arguments into RawArguments, so a fields number had already lost its literal (9007199254740993 -> ...992, 1.10 -> 1.1) before the source handler's BUG-3217 path. The caller's raw bytes now go through, with the action and Fixed inputs spliced in; a test fails on the old code. - P2: set_workspace promised an overview it cannot give. On the shared server it persists nothing, and the remote transport has no BootstrapFetcher, so it returns only a not-persisted note. It is now an exclusion with that reason, and get_workspace_overview is the connect step. - P2: link_type routing among the link commands is one disclosed operation (relate two items); noted at the entry. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(mcp): every ChatGPT tool but list_workspaces requires the workspace (TASK-3321 U1) There is no session workspace on the shared ChatGPT server, so an optional slug would fall back to nothing. Pins the lead's check on #1708; red when item_history's workspace is made optional. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
eb987af709 |
feat(mcp): pad_workspace destructive + open-world (invite emails), pad_playbook open-world (TASK-3321 U0b, ToolSurface 0.61) (#1707)
* feat(mcp): pad_workspace is destructive and open-world, because invite sends email (TASK-3321 U0b) pad_workspace advertised destructiveHint:false (all writes "additive") and every tool advertised openWorldHint:false. But its invite action sends an email to an address the caller names: a send cannot be taken back, which is what the MCP spec and OpenAI's plugin review mean by destructive, and an arbitrary recipient is outside the server's own data, which is what openWorldHint means. invite leaves additiveWriteActions. A new openWorldActions set, guarded by a stale-entry test, sets openWorldHint per tool. The wire goldens move for pad_workspace only, on both transports. ToolSurfaceVersion is now 0.61 (an annotation-only behaviour bump on the v0.20 grounds). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(mcp): pad_playbook is open-world, because match sends text to the decision provider (TASK-3321 U0b review) Codex round 1, P2: pad_playbook.match posts the caller's free text to the configured decision provider, a third-party model API. It stays read-only and non-destructive, but it is open-world. Open-world is about where data goes, not whether anything is written, so the stale-entry test no longer forbids a read in openWorldActions. The goldens now move for pad_workspace and pad_playbook only, and the 0.61 entry names both. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
ed7219e2e7 |
feat(oauth): RFC 9207 iss on every authorize redirect; JSON 404 for unowned /.well-known (TASK-3321 U0a) (#1706)
* feat(oauth): RFC 9207 iss on every authorize redirect, and a JSON 404 for unowned /.well-known paths (TASK-3321 U0a) Two OAuth conformance fixes for every MCP client, ahead of the ChatGPT catalog: - Every authorization response that redirects to the client now carries iss=<issuer>, exactly the metadata issuer: an approval, a denial, and a fosite validation error alike. fosite v0.49 does not add it, so its WriteAuthorizeResponse and WriteAuthorizeError go through a writer that appends it to the Location (query, or fragment in fragment mode). authorization_response_iss_parameter_supported is now advertised; it was pinned absent while the parameter was not sent. - /.well-known/ paths no route owns answer a JSON 404. They fell through to the SPA catch-all, which answered 200 text/html, so a client probing openid-configuration got a success it could not parse. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(oauth): refuse response_mode=form_post, whose page carries no iss (TASK-3321 U0a review) Codex round 1, P2: fosite answers form_post with an HTML page that POSTs the response, which has no Location for the iss writer to extend, so an advertised RFC 9207 would be false in that mode. Pad never needed form_post. Both authorize endpoints now refuse it with 400 before fosite writes anything, and the metadata lists response_modes_supported as query and fragment. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
5d420ef606 |
fix(materialize): the recovery sweep stops re-queuing an exhausted item every tick (BUG-3325) (#1704)
* fix(materialize): the recovery sweep stops re-queuing an exhausted item every tick (BUG-3325) An item whose failure budget was spent stayed a sweep candidate: the budget is in memory, so the candidate query cannot see it, and the sweep queued it on every tick for the worker to refuse. On Pad Cloud that logged "candidates=1 queued=1" every minute, forever. The candidate query now also returns MAX(id) (the same quantity as the worker's cursor), and the sweep holds back an item whose budget is spent for that op-log, through the predicate admit uses. It is reported as exhausted_skipped, written only when that count changes, and offered again once its op-log grows. A backing-off item is unchanged. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): count exhausted items over the budget map, not per page (BUG-3325 review) Codex round 1, P2: exhausted_skipped was counted per keyset page and compared across pages, so a stable population over several pages read as a change on every sweep. The line now carries exhausted_total, counted over the in-memory budget map, and is written when it changes. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVCv0.17.0 |
||
|
|
bb3b587e40 |
chore(deps): npm update devalue to 5.9.4 (unblocks the Web job's audit gate) (#1705)
Six devalue advisories (high) covering <= 5.9.2 were published after #1701, so every PR's Web job now fails audit:ci. devalue reaches us through @sveltejs/kit (^5.8.1) and svelte (^5.9.2); 5.9.4 is inside both, so this is a lockfile-only update of that one entry. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
a4079d12e3 |
chore(deps): npm update undici markdown-it (Dependabot #9, #11-14, #17-18) (#1701)
Lockfile only: undici 7.29.0 -> 7.30.0 (dev-only, via jsdom for vitest) and markdown-it 14.3.0 -> 14.3.2 (runtime, via tiptap-markdown). Both stay inside their parents' ranges, and neither advisory path was reachable: we never use undici's BalancedPool, retry, dump or cache, and linkify is off at every Markdown.configure site. The materializer site, which runs on the server, now says linkify must stay off below markdown-it 14.3.1. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVCv0.17.0-rc.2 |
||
|
|
fc1f4e6181 |
fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323) (#1700)
* fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323) From a trusted peer, TrustedProxyRealIP took X-Real-IP first and otherwise the LEFTMOST X-Forwarded-For entry. On an appending proxy chain both are client-controlled: the leftmost entry is whatever the client sent, and a proxy that only appends to X-Forwarded-For (Azure Application Gateway, many ingress controllers) passes a client's X-Real-IP through. Any client behind such a proxy chose the address pad used for per-IP rate limits, 2FA challenge binding, session IP checks and audit. Now X-Forwarded-For wins when present, walked right to left, skipping hops inside PAD_TRUSTED_PROXIES, taking the first address outside them (nginx real_ip_recursive). ip:port entries are read as their address; an unparseable entry stops the walk. X-Real-IP is used only when there is no X-Forwarded-For. Cloud is unaffected: its router appends the resolved client, which is the right-most hop. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(server): read every X-Forwarded-For field, and document the append requirement (BUG-3323 review) Codex round 1, P2: Header.Get read only the first X-Forwarded-For field, so a proxy appending its hop as a second field left the client in control. All fields are now joined in order before the walk. The other P2 (a proxy that sets X-Real-IP but passes a client XFF through unchanged) is the cost of preferring XFF; the deployment guide now states that every trusted proxy must append to X-Forwarded-For. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
1c6348edc5 |
fix(auth): OAuth login and password reset no longer mint a session past TOTP (BUG-3322) (#1699)
* fix(auth): OAuth login and password reset no longer mint a session past TOTP (BUG-3322)
Two session-minting doors skipped the second factor that password login
enforces:
- POST /auth/oauth-login (pad-cloud's GitHub/Google/Apple sign-in) minted
a full session for a user with TOTP on. It now answers 403
two_factor_required with the same login challenge password login issues
(in details.challenge_token) and no session. A sidecar that predates this
reads the 403 as a failed login, so it fails closed.
- POST /auth/reset-password minted a session after the reset, so access to
the mailbox alone defeated 2FA. A TOTP user now gets the new password,
every prior session deleted as before, and {ok, requires_login} with no
session.
The web login page opens the code step from a #challenge= fragment (kept
out of server logs and Referer), and the reset page sends a TOTP user to
/login. pad-cloud's redirect and IP forwarding are U2.
The other session-minting doors were swept on the item trail: each checks
TOTP, mints for a brand-new account, or inherits an existing session.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(auth): fill a missing avatar only after the oauth-login checks pass (BUG-3322 review)
Codex round 1, P3: the avatar of an existing account was updated before
the TOTP refusal. It now runs after every check, including the second
factor and the disabled-account check.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(auth): the two_factor_required message never asks for a password (BUG-3322 review)
A provider-created account has a random password nobody knows, and TOTP
setup does not require one, so "sign in with your password" was wrong for
it. The message now sends a native shell to the web code step.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
f8470a3762 |
chore(nix): bump package version to 0.17.0 ahead of the v0.17.0-rc.1 tag (#1695)
Main still read 0.15.0: the 0.16.x bumps were made on the release branch.
An RC carries the stable version, as
v0.17.0-rc.1
|
||
|
|
a4f16bfa22 |
fix(web): 44px touch targets round 2: group archive button, board lane +/⋯, card tags (TASK-3311) (#1698)
* fix(web): 44px touch targets round 2: group archive button reachable on touch, board lane + and ⋯, card tags (TASK-3311) * fix(web): card tags are visibly 28 tall with a 16px row gap; the extender gives the 44 (TASK-3311, Dave's ruling) |
||
|
|
c6df08f054 |
feat(attachments): the viewer says when a file was uploaded, and by whom (TASK-3319) (#1697)
* feat(attachments): the viewer says when a file was uploaded, and by whom (TASK-3319) * fix(attachments): the upload caption has its own class, so the type/size line's selector stays unambiguous (TASK-3319) |
||
|
|
d44c4f6545 |
fix(web): one workspace-access stream per browser, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318) (#1696)
* test(e2e): open pad tabs must not starve a new page of HTTP/1.1 connections (BUG-3318, red) * fix(web): one workspace-access stream per browser, not per tab, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318) |
||
|
|
552dee8ded |
feat(web): workspace tabs feel like Chrome's (TASK-3312) (#1676)
* feat(web): workspace tabs feel like Chrome's (TASK-3312) Dave approved all six of the lead's proposals (day 83): - Motion: a tab grows in and collapses out (CSS classes, not Svelte transitions, since the strip is a dndzone). Chrome's close-freeze holds the other widths until the pointer leaves, the window resizes, or the tab set changes from elsewhere. Reduced motion drops the animation but keeps the freeze. - The arrow cursor instead of grab. - The active tab flares into the page. The list now reaches over the bar's border, which overflow-x had kept a tab's own negative margin from doing. - Dividers between inactive tabs, Chrome's rule. Tabs are 38px. - A favicon-like filled square icon in both states. - Middle-click closes a tab, so it no longer opens the workspace in a new browser tab. The × shows on every tab of 150px or wider. The tab-bar e2e helpers move to e2e/lib/workspace-tabs.ts. The TopBar close tests now wait out the 140ms collapse. Two of them kept passing with the delay only because they asserted before the close ran, so they now also assert that the close was called. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * fix(web): no line under the active tab, room for the edge flares, readable tab initials (TASK-3312) The lead's review of #1676: - The 1px line left under the active tab was the connect banner's (and the verify banner's) top border, sitting flush against the bar and doubling its border. On desktop they now sit 8px below the bar as cards; phones are unchanged. Pixel-sampled: the page colour now runs straight down under the active tab in both themes. - The list clipped a first or last active tab's outer flare. It gets 9px of inline padding. - White initials measured 1.39-2.75:1 on the palette. An inactive icon now takes --text-primary, and an active one a fixed dark ink: 4.19:1 at worst. Two e2e legs pin the flare room and the missing line. Both were red on c1661c69 and are green here. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL |
||
|
|
02cbfd5a01 |
fix(web): 44px touch targets at phone width: strip, card ⋯, status chip (TASK-2244) (#1674)
* fix(web): collection strip controls are 44x44 at phone width (TASK-2244 U-a) Dave's ruling (day 83, option 1): at <=768px every control in the collection strip grows visibly to 44x44. A transparent hit area would overlap the neighbour 4px away. Desktop is unchanged, and the e2e pins the base sizes. Co-Authored-By: Claude <noreply@anthropic.com> * fix(web): the mobile view chip drops its visible View: prefix so the strip fits one row (TASK-2244 U-a) The lead's ruling on the 360 checkpoint (option B). The aria-label 'Change view' is unchanged. The e2e pins one row at 390 (list and board) and at 360 (board). It was red on the previous commit's binary at 390 list and 360 board, and it is green here. Co-Authored-By: Claude <noreply@anthropic.com> * fix(web): a mobile card has one 44x44 ⋯ carrying star, copy and reorder (TASK-2244 U-b) Dave's ruling (day 83, option 3). At <=768px the card's star, copy-ref and ⋮ could not each reach 44x44 without overlapping. One ⋯ replaces them on every ItemCard host, including the starred, tags and roles pages, which have no reorder. Star and Copy item ID are its first entries. A starred card shows a passive ★, which is not a control. Desktop is unchanged, and the e2e pins its sizes. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * fix(web): the card status chip gets an invisible 44x44 touch area that spares the tags (TASK-2244 U-c) Dave's ruling (day 83): the status chip gets an invisible ::after extender at <=768px. It grows upward from the chip, into the title (the card link), because a centred one landed 20 of the first tag's 40 sampled points on the chip. The e2e probes both sides with elementFromPoint: the 44x44 area hits the chip, and no point of a tag or of the card ⋯ lands on it. The card legs now seed a collection of their own, because the shared Tasks lane put seeded cards out of view. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * test(web): desktop legs pin only CSS-fixed sizes (TASK-2244) CI failed the desktop legs on text- and glyph-sized widths, which depend on the machine's fonts (view 77.6 vs 75, + New 101.1 vs 93.8, ⚡ 34.4 vs 41, ⋮ 18 vs 8, star 11.9 vs 13). Pinned now: every strip height (28), the three icon buttons' widths (30), and the copy button (22x22). The glyph-sized card controls must stay below 44. Run with the phone rule in force, the same assertions fail on every control. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * fix(web): the phone realtime dot is a badge on the collection menu button, so the strip is one row at 360 in both views (TASK-2244, Dave's gate) --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
4f367adc8d |
feat(history): one History tab replaces Activity and Versions (PLAN-2348 U3) (#1692)
* feat(timeline): serve body_edited rows and describe collapsed autosave runs (PLAN-2348 U3) * feat(history): group timeline rows into events; version card shows its own edit (PLAN-2348 U3) * feat(history): History tab replaces Activity and Versions (PLAN-2348 U3) * fix(history): diff labels, verb casing, create row, filter chip height (PLAN-2348 U3) * test(history): e2e specs and prose follow the History tab (PLAN-2348 U3) * test(history): re-review identity-fence rows after the tab change; HistoryView joins the child tables (PLAN-2348 U3) * fix(history): create versions render; a throttled edit shows its diff against the current body (PLAN-2348 U3) * fix(history): the since-now row is labelled Since then (PLAN-2348 U3) * fix(history): the since-now diff says it is cumulative (PLAN-2348 U3, lead ruling) * feat(timeline): a kinds filter, so a view pages through only what it renders (PLAN-2348 U3) * fix(history): History fetches only history kinds and Details only comments, so neither pages through the other (PLAN-2348 U3) * fix(timeline): an empty filtered page is [] not null; the History feed hears its item's updates (PLAN-2348 U3) * test(history): the recovery attribution test follows the attribution to HistoryView (PLAN-2348 U3, TASK-2198 U4) |
||
|
|
15c872bfed |
fix(materialize): recovery never writes a blank body over a stored one (BUG-3316) (#1693)
The pre-rollout dry run of op-log recovery (TASK-2198 U4) over the live DB's 175 candidates found one that would have been wiped. A load-test item's op-log of 90 tiny rows, with no base document, replays to an EMPTY document, and recovery would have written "" over its 1,285-byte body. A tab never does this: its lazy seed (TASK-1261) sees the empty fragment and seeds FROM items.content. MaterializeFlush now refuses a blank recovered body over a non-blank stored one, decided in the precheck against the row locked for the write, as the new outcome empty_document. Nothing is written: no body, version, seq or watermark. Recovery treats it as terminal until the op-log changes (exhaust, which now takes its kind), so the sweep does not replay it every minute, and it logs the one exhaustion WARN with last_error_kind=empty_document. Stated divergence: a tab refuses only when the fragment is EMPTY, so a user who truly cleared an item's text and then crashed would have flushed "". Recovery cannot see the fragment, so it keeps the old body there too, which is the conservative side. No shrink threshold, by ruling: a tab would flush a shorter body, and the dry run found no such case. Gate: the dry run re-run on this branch, flushing into a disposable COPY of the same snapshot. All 175 items produce the same recovered body and class as the first run. TASK-1270 is refused (empty_document) with its body intact; 156 are stamped, 17 applied, and 1 (a deadline failure) is never flushed. Legs: store (blank and whitespace-only, nothing written) and recovery (refused, terminal, one WARN, reset when the op-log grows). Mutants: a guard that never fires (2 red), no exhaust (1), wrong kind (1). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
cc2194552b |
test(materialize): two CI reds were test instruments, not the product (TASK-2198) (#1694)
main was red at
|
||
|
|
cb99879d53 |
feat(materialize): recover unsaved editor sessions into items.content (TASK-2198 U4) (#1683)
* feat(materialize): recover unsaved editor sessions into items.content in the background (TASK-2198 U4) Wires the U3 supervisor in. A collab room reclaimed at the end of its grace TTL with content-bearing rows above the flush watermark (T3), or a dormant pending item found by a one-minute sweep (T1), is queued; one worker goroutine materializes the item's whole content-bearing op-log with the whole-workspace link index and lands it through store.MaterializeFlush, under the collab item lock with no room open. The write is a flush: cursor == MAX(op-log id), rows pending, no set-aside rows, checked in the write's own transaction; watermark to the cursor only; seq bumped; one version row attributed to system/recovery; the op-log is never pruned. PAD_MATERIALIZE=off disables it entirely. A per-item in-memory failure budget (3 failures, doubling backoff, reset by a new op-log row) keeps untrusted input from being retried in a loop. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): recovery watermark is the cursor, not MAX at UPDATE time (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): a second recovery still writes its own version row (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(mcp): op-log recovery bounds the stale-body window; tool surface v0.60 (TASK-2198 U4) content_state applied_pending_flush now clears on its own after the item's room closes, unless recovery fails or PAD_MATERIALIZE=off. Every sentence that called the window unbounded, or said nothing server-side moves op-log content into items.content, is corrected (CLAUDE.md, instructions.md, README, docs/deployment.md, docs/backup.md, code comments). BEHAVIOR bump to 0.60; goldens regenerated. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): count ErrNoMemoryCap silently against the per-item budget (TASK-2198 U4) U3 now refuses every job with ErrNoMemoryCap when no memory cap can be established, and warns once itself. The recovery worker counts it like any failure but logs it at debug, not per item. Its log lines go through a logger seam so the test can tell. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * style: gofmt (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(materialize): one log line per budget exhaustion; count set-aside skips on the sweep line (TASK-2198 U4) An item that uses up its failure budget logs exactly one WARN naming the item, the failure count and the last error kind (DEBUG when the kind is no_memory_cap, which the supervisor already warned about once), and logs again only after op-log growth resets the budget and it is exhausted anew. The schema-version skip goes through the same line. Items skipped for set-aside rows are counted and reported on a new single sweep summary line, written only when it has news; never per item. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): keyset-page the recovery sweep so no candidate starves (TASK-2198 U4, codex r1) The sweep read the oldest 200 candidates from the start every tick, and the room check and the failure budget filtered them afterwards. Once 200 sat in backoff or exhausted, every sweep returned the same 200 and the 201st was never queued: filtering above a bound is starvation. Each tick now reads one page strictly after an in-memory keyset cursor (newest op-log created_at, item id) and wraps to the start after a short page, so every candidate is offered within ceil(N/limit)+1 ticks, whatever sorts ahead of it. Keyset over NOT IN: no parameter-count limit, and it also covers the room check, which an exclusion list would not. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(materialize): the sweep cursor wraps; docs for the exhaustion line, the set-aside count and paging (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(deployment): reflow the recovery budget paragraph (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(materialize): recovery strips the follows-title marker as a tab's save does (TASK-2198 U4, BUG-3315) Since #1688 a tab loads a link that follows its target's title with the follows-title marker in the link's title attribute, so the marker lives in Y.Docs that recovery replays. Three fixture cases (a follows-title link; the same link after a rename; a URL edited to an external address) are generated from documents that really hold the marker, and the existing 8 are unchanged. A new leg recovers each through the production path (whole-workspace index) and through the runner with a restricted and an empty index, and asserts no 'pad-follows-title' reaches the body. It goes red in all 7 ways when the bundle's marker strips are disabled. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): a recovery version row carries #1691's history data (TASK-2198 U4) Rebased onto PLAN-2348 U2 (#1691), which adds user_id, line counts and a create marker to version rows. Recovery writes its row through the same UpdateItem insert, so it gets them automatically: no user_id (a system row), not a create, and line counts equal to diff.LineCounts of the stale and recovered bodies. The leg pins that. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
7ba823004c |
feat(history): version rows carry their writer, line counts and create marker, plus a per-version diff endpoint (PLAN-2348 U2) (#1691)
* feat(history): version rows carry their writer, line counts and create marker; a diff endpoint pairs each row with its own change (PLAN-2348 U2) * test(history): server legs, export/import leg; web type and API method for the version diff (PLAN-2348 U2) * docs(history): the version diff endpoint and row fields in CLAUDE.md; FormatDiffSummary names what it counts (PLAN-2348 U2) * fix(history): user_id already existed on item_versions (migration 012); a diff refuses a body the chain could not rebuild (PLAN-2348 U2, codex r1) |