mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
7396ec42bbdfd5902ada9b202a983ff8f4059209
2279 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7396ec42bb |
feat(oauth): delegated sign-in for installed apps through the issuance barrier, shipped closed (TASK-3399, SPEC-6 U5b-1) (#1779)
* feat(oauth): delegated install grants through the issuance barrier: code, PKCE, access and refresh for a live human member, the consented access bound and capped by the manifest (TASK-3399, U5b-1) * feat(oauth): delegated app sign-in: a fixed-workspace consent page, code+PKCE for install clients, the person's access choice in the grant, and an ownership check before fosite on code and refresh (TASK-3399, U5b-1) * feat(oauth): app grants are not MCP connections: the readers skip install chains, the console lists and revokes them through the binding, the sweep clears orphan bindings, and revoking a person reaches their delegated codes (TASK-3399, U5b-1) * test(server): the console lists an app grant on its own, refuses every connection mutation on it, and revokes it through the binding (TASK-3399, U5b-1) * test(server): the console revoke ends an app grant through its binding (TASK-3399, U5b-1) * feat(web): Connected apps lists installed apps acting as you, read-only plus revoke (TASK-3399, U5b-1) * test(oauth): pin the binding's person and a resource-less sign-in; name the fosite layers behind the consent gate's PKCE and audience checks (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r1: a delegated binding remembers the person's credential epoch and membership and is tombstoned on revoke; issuance share-locks the person on Postgres; app sign-in and grant management work with MCP off (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r2: only a delegated grant's code writes its binding; member removal, disable and claim end a person's delegated grants through their installs; users before installs before tokens in every path (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r3: delegated revocation takes no install lock (the person's row orders it against issuance) and writes child rows in teardown's order, closing two deadlocks with owner-account deletion (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r4: install teardown and a person's delegated revocation lock their bindings in request_id order before any child row (TASK-3399, U5b-1) * feat(store): bounded retry on a Postgres deadlock for delegated-grant revocation, member removal, account disable and claim, and the OAuth sweep, with a per-site metric (TASK-3399, U5b-1, lead ruling (a)) * fix(oauth): U5b-1 codex r6: the console does not list an app grant the install's epoch has moved past (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r7: a consent carries the person's credential epoch and the barrier refuses it after a disable/re-enable; the console lists a grant not yet exchanged (TASK-3399, U5b-1) * feat(oauth): delegated app sign-in stays closed (access_denied, not yet available) behind one named guard until U5b-2 opens it with the app API (TASK-3399, U5b-1, lead ruling) * fix(oauth): U5b-1 codex r8: a consent carries the install epoch and membership its gate read, so a disable/re-enable or removal/re-add after the gate refuses the code (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r9: a delegated grant needs a live workspace, at the consent gate and the barrier (TASK-3399, U5b-1) * docs(oauth): name the accepted one-second residual of the consent's membership check (TASK-3399, U5b-1) * test: census the delegated consent's form readers, the app-grant revoke's user agent, and four binding columns that carry no caller text (TASK-3399, U5b-1) |
||
|
|
aadf3c09d6 |
feat(apps): app-owned webhooks, held until redeem hands out the secret (TASK-3408, SPEC-6 U10a) (#1778)
* feat(apps): app-owned webhooks, held until redeem hands out the secret (TASK-3408, SPEC-6 U10a)
One webhooks row per install (app_install_id), created from the manifest
at provisioning, kept in step by a reviewed upgrade, deleted by uninstall.
Its signing secret is handed to the app only by redeem, which rotates it
and stamps secret_delivered_at; until then the hook is HELD (lead ruling,
day 86) and the owner sees "needs a new install code to activate".
Installs provisioned before U10 are backfilled at start, held.
App hooks stay off every owner door: ListWebhooks (the owner list and the
owner dispatcher), the scoped get/test and delete, and the plan count.
Events resolve to THIS install's companions only (via_app).
GET /workspaces/{ws}/apps/{installID} and the lifecycle responses carry
the hook's status. Delivery is U10b.
Migrations 122 / pg 096.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(apps): the webhook backfill re-decides under the install lock from the stored manifest (codex r1, TASK-3408)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(apps): NUL census baseline for the app webhook columns (TASK-3408)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
bec344822a |
feat(apps): app upgrade: staged preview with a classed diff, one-transaction confirm that re-derives it (TASK-3397, SPEC-6 U8b2) (#1777)
* docs(store): the barrier's early state refusal is load-bearing for the uninstall lock order (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(apps): U8b2 upgrade diff: classes, release of removed companions, the additive-field rule (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(apps): U8b2 upgrade preview and confirm: staging with upgrade_of, diff in the preview, one-tx apply that re-derives and compares (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b2 upgrade end to end; null and absent JSON are the same declaration (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b2 upgrade lock order against a fenced write, moved-install refusals, upgrade records never cross the install route (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): U8b2 codex round 1: refuse drifted companions, bump the epoch on restrictive upgrades, check existing data and the done field for additive fields, union duplicate event names (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): U8b2 codex round 2: archived items' values count for the additive-field check (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b2 registers its two schema DEFINITION decodes with the reserved-field guard; NUL baseline for upgrade_of (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(apps): drop the unused buildAppPreview wrapper (lint) (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
37066f97be |
feat(apps): app attachment routes: item-scoped upload, metadata, and a streamed download re-admitted before its first byte (TASK-3401, SPEC-6 U6c) (#1776)
* feat(apps): app attachment routes: item-scoped upload, metadata and a streamed download re-admitted before its first byte (TASK-3401, SPEC-6 U6c) * test(server): an app attachment read refuses a subject whose membership no longer reaches the item (TASK-3401, U6c) * test(server): U6c tests reach the item re-check, the upload's edit right and the download's type decision (TASK-3401, U6c) * test(server): a deletion before the first byte reaches the attachment re-check (TASK-3401, U6c) * fix(apps): U6c codex r1: authorize before storage, a transport write deadline, the credential last in re-admission, and a live companion check in the fence (TASK-3401, U6c) * fix(apps): U6c codex r2: a truncated upload is 400, a re-admission fault is 500 not 401, HEAD on the download, and unknown variants refused (TASK-3401, U6c) * fix(apps): U6c codex r3: the upload re-admits the subject before its row (StageUpload/Insert, no store callback), and variant is null on originals (TASK-3401, U6c) * fix(apps): U6c codex r4: a token-state or settings read failure during admission is a fault (500), not a denial (TASK-3401, U6c) * fix(apps): U6c codex r5: a credential that dies mid-upload answers 401 invalid_token, a permission change 403 (TASK-3401, U6c) * fix(apps): U6c codex r6: the served variant is re-checked at the gate, and a settings read fault at the first gate is a 500 (TASK-3401, U6c) * test(server): a viewer's upload is refused before its body is read (TASK-3401, U6c) * test(server): account for the app upload's raw body reader in the body-reader census (TASK-3401, U6c) |
||
|
|
213ecc0fe3 |
feat(apps): app install lifecycle: two-phase disable and rotate, re-enable, uninstall with the tombstone (TASK-3397, SPEC-6 U8c) (#1775)
* feat(apps): app install lifecycle: two-phase disable and rotate, re-enable, uninstall with the tombstone (TASK-3397, U8c)
Phase 1 (BeginInstallTeardown) holds the install row FOR UPDATE, moves the
install to disabling or uninstalling, bumps auth_epoch and revokes every
client grant; the delivery drain is a TODO(U10) no-op; phase 2 re-takes the
row and requires the phase-1 state, so a repeated call resumes. Rotate mints
a new install code; re-enable never moves the epoch back. UninstallAppTx is
one child-first transaction: the client (connections by request_id first),
the bot's membership and credentials, the bot DISABLED but kept for
attribution (unlike the account-deletion purge), and the install row kept as
the tombstone. Owner doors: POST .../apps/{installID}/disable|enable|rotate|uninstall.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(apps): U8c codex round 1: rotate bumps the epoch again, teardown kills unconsumed install codes, uninstall takes the bot rows first (TASK-3397)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(apps): U8c fence test lives in internal/store, the one package besides appstore that may open a fence; gofmt (TASK-3397)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(apps): U8c issuance racing uninstall cannot deadlock, red if the barrier lock-reads the user before its state check (TASK-3397)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
61f07d39ea |
fix(store): an API token mint or rotation reads its row back inside its own transaction, never panics on a concurrent delete (BUG-3405) (#1774)
CreateAPIToken committed, read the row back on the pool and dereferenced it; a deletion landing between the two (the user's account deletion, a revoke) made the read answer nil and the mint panicked. It now reads the row back on its own transaction, before the commit. RotateAPIToken had the same shape (an autocommitted UPDATE, a pool read, a dereference): its update and read-back now share one transaction, the update is scoped to the owner, and a row gone in between is the clean ErrNoRows a missing token gets. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
5e2accd179 |
feat(apps): app write routes: items create/update, comments create/update/delete, the write rule, SSE and watch parity (TASK-3401, SPEC-6 U6b) (#1773)
* docs(store): the per-call users read in the bot ceiling is accepted; how to remove it if profiled (TASK-3401) * feat(server): app write routes: items create/update, comments create/update/delete, the write rule ahead of the editor fast path, SSE and watch parity (TASK-3401, U6b) * test(server): app write routes: census, read-token refusal, write rule, refusals, disable race, SSE/watch parity (TASK-3401, U6b) * test(server): the write-rule test gives the bot a role and a companion control, so only the rule can refuse (TASK-3401, U6b) * fix(apps): an app write's status notification comes from its own transaction, never the handler's earlier read (TASK-3401, U6b codex r1 P2) * fix(apps): an app item write's response is built from its own transaction: slug, schema, via_app, creator and ETag read before commit, nothing read after (TASK-3401, U6b codex r2 P2 x2) * fix(apps): app comment writes publish the item and actor display their own transaction read, not the handler's earlier read (TASK-3401, U6b codex r3 P1+P3) * docs(server): name the second layer behind the app comment same-item check (TASK-3401, U6b) |
||
|
|
52dc41b96a |
feat(apps): app install provisioning, install codes and redeem (TASK-3397, SPEC-6 U8b) (#1772)
* feat(apps): app install provisioning, install codes and redeem (TASK-3397, U8b) Confirm re-normalizes the staged pack, compares it with the reviewed preview, and provisions in ONE store transaction that re-checks every state-dependent fact: the install row, companion collections (via_app), the bot (editor/viewer, collection_access specific over the companions), the install client, the artifacts as owner drafts stamped with their pack and digests, a one-time install code, and the pending record's deletion. A refusal names the artifact or collection, the field and the holder, and writes nothing. Redeem is mounted outside the auth stack with a 4 KB whole-body cap and per-address and per-install limiters; it consumes the code and rotates the client secret. Owners can issue a new code to recover a lost redeem. * fix(apps): U8b codex round 1: re-check the destination schema in the provisioning tx, a dead install code never charges the install limiter, a reserved companion slug is refused (TASK-3397) * refactor(apps): U8b provisioning re-derives the normalization inside its transaction under locks (TASK-3397) Replaces the per-fact re-checks (codex rounds 1-2 kept finding facts the list missed) with a re-derivation: ProvisionAppInstall takes the pending row, the workspace lock, then on Postgres FOR SHARE on every collection row and the owner's membership row, re-runs the preview normalization on the transaction through the caller's derive function, locks the relation targets it resolved, and derives again; the caller compares each pass with the reviewed digests. SQLite is covered by BEGIN IMMEDIATE. The normalization path takes a Queryer (relation passes, unique check, slug probe, kind lookup); the pool versions delegate to it. Parity is pinned over the 8 importer fixtures plus a relation-bearing case captured before the refactor, the derivation is proven write-free under the write-capture harness, and the lock table is pinned on both dialects. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): U8b codex round 3: compare the reviewed changes list, lock the caller's visibility rows, take items before the membership (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(apps): U8b lock table matches the round 3 order (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * refactor(apps): U8b locks break dependency cycles, not every read (TASK-3397) Lead ruling, day 86, superseding "lock every read": the visibility locks added in round 3 created deadlock cycles with member removal and account deletion and could never cover an inserted grant or a cached role (codex round 4). Provisioning now takes the pending row, the workspace lock and FOR SHARE on the workspace's collection rows, reads the owner role plainly, and derives once. The serializability argument for each raced writer class is in the lock comment. The two deadlock scenarios stay as tests. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): U8b codex round 5: lock the confirming owner's users row first, account deletion is a real cycle (TASK-3397) Account deletion reads the bot and membership provisioning writes and deletes the owner provisioning read, and its FK cascade from users reaches the pending row. Provisioning now takes the owner's users row FOR SHARE before the pending row; deletion's first statement locks the same row, so the two serialize whole in either order. Tested through the real deletion in both orders. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b account-deletion cycle test uses the canonical owner (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(apps): U8b adoption re-stamps via_app, only from an uninstalled install (TASK-3397) Lead ruling, day 86 (from the #1771 review): companions are via_app = install, so an adopted companion is re-stamped to the new install inside the provisioning transaction. Any holder that is not uninstalled is refused (app_already_installed, naming the state). Lock 3 becomes FOR NO KEY UPDATE so the re-stamp never upgrades a share lock. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U8b redeem stays outside the app API token router (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(apps): bot lookups key on app_installs.bot_user_id, the address as the agreement check (TASK-3397) The U5/U8b agreement: whichever landed second switches the issuance barrier, AppPrincipalForInstall and the members list from the bot's address to the bot_user_id provisioning sets, keeping the address as an agreement check. An install without bot_user_id (made before provisioning) falls back to the address. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(apps): drop a dead assignment in diffChanges (lint) (TASK-3397) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
9a1e31d53d |
chore(nix): heal vendorHash for the module set in 282a42f69f (TASK-2954)
|
||
|
|
282a42f69f |
feat(apps): the installed-app API door, ceiling and read routes (TASK-3401, SPEC-6 U6a) (#1771)
* feat(store): a bot's visibility is its install's ceiling, at every caller (TASK-3401) * feat(server): the app API door, route table, ceiling, and read routes (TASK-3401, U6a) * test(server): U6a door, ceiling, DTO census and visibility-caller census (TASK-3401) * test(server): controls for the cross-workspace refusals; role follows access; cross-item parent projects null (TASK-3401) * fix(server): app responses are re-validated against revocation before they are sent; one users read in the bot ceiling (TASK-3401, codex r1) * fix(server): re-admit an app request and replay its authorizations before the response is sent (TASK-3401, codex r2) * fix(server): re-admission rechecks apps availability and fails closed without a re-check holder; collection re-checks memoized (TASK-3401, codex r3) * test(server): re-validation fails closed without a re-check holder (TASK-3401) * docs(server): the re-check memo is sequential, not a snapshot (TASK-3401, codex r4) * chore(server): drop the unused write-rule helper until U6b's first caller (TASK-3401) * style(server): gofmt after dropping the helper (TASK-3401) * fix(server): app item lists page by window (has_more, next_offset), not by page length (TASK-3401, Rook review) |
||
|
|
ca158c22d8 |
feat(apps): app install staging: apps/1 manifest, fetch policy, pending reservations, preview (TASK-3397, U8a) (#1770)
SPEC-6 U8a (DOC-3371 §1, §2 steps 1-5, §9). Strict apps/1 parsing; an https/no-redirect fetch policy screened at dial time with an admin private-origin list; pending-install reservations under the owner lock and the instance advisory lock, holding the full charge until the fetch ends; the owner preview with every artifact normalized by the importer's own code (normalizeArtifact, prepareCreateFields, both extracted behaviour-preserving and pinned by a parity golden); apps_enabled and the admin apps settings; a background sweep of expired pending installs; migration 118/092. The preview is advisory: it may refuse what the import would accept, never the reverse; U8b re-checks atomically. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
e5262160e1 |
feat(oauth): install clients, service tokens and the issuance barrier for installed apps (TASK-3394, SPEC-6 U5a) (#1769)
* feat(store): install clients, the issuance barrier and app_token_bindings (TASK-3394) * feat(oauth): install-client service tokens, in-process app-token introspection, public introspection hardening, A0 token gate (TASK-3394) * test(store): the issuance barrier serializes with disable, rotate and re-enable on Postgres (TASK-3394) * test(server): pin each install-token refusal by its own hint; bearer introspection of another token (TASK-3394) * fix(oauth): a grant carries the epoch read before client auth; the install-caller check reads credentials as fosite does (TASK-3394, codex r1) * fix(oauth): classify the token request from the body fosite parses, multipart included (TASK-3394, codex r2) * test(server): account for the install-client token rules' form readers in the body-reader census (TASK-3394) * chore(store): renumber the install-client migrations to 117 / pg 091 after #1768 (TASK-3394) |
||
|
|
526001c199 |
feat(appstore): app attachment upload, read rule and caps behind FencedTx (TASK-3396, U7a) (#1768)
SPEC-6 U7a (DOC-3371 §4 Attachments, day-85 revisions): the store, blob and appstore layers of app attachments. Constant-work blob writes (FSStore.Stage/Commit/Abort), the upload hash guard moved into attachments.InFlight and shared with the app store, FencedTx upload with exact install caps recounted under the workspace seq lock plus an in-process admission reservation, the app read rule, variant inheritance of uploader and via_app, the AppAttachment DTO, and migration 116/090 (attachments.via_app). The HTTP route lands with U6 or a U7b on top. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
35e4843772 |
feat(appstore): app comment create, edit and delete inside FencedTx (TASK-3391) (#1767)
SPEC-6 U2b (DOC-3371 §4). comments.source accepts 'app' (SQLite 115 rebuild with rowids, indexes, FTS and NUL triggers preserved; PG 089 constraint swap). FencedTx gains CreateComment, UpdateComment and DeleteComment under the workspace seq lock, with item binding before authorship, author-only edits, an item-bound attachment reference check that writes nothing, and a cross-item-thread refusal on delete. appstore exposes them. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
b572f0739c |
feat(auth): the bot principal for installed apps: users.kind, kind gates on every sign-in door and credential, people-only counts (TASK-3392) (#1766)
* feat(store): the bot principal: users.kind, CreateAppUserTx, kind gates on every credential mint and claim, people-only counts, install-gated app projection (TASK-3392) * test(store): record users.kind in the NUL census baseline (store-written, two values) (TASK-3392) * feat(server): kind gates at the sign-in doors, admin 404 and member 409 for a bot, apps array on the member list, reserved domain refused (TASK-3392) * docs+web: the members apps array (TS type, CLAUDE.md); people-only admin member count test (TASK-3392) * test(server): the U3 owner-webhook strip test plants an install (TASK-3392) * test(server): forgot-password and resend refuse a bot before attempting a mint (TASK-3392) * test(server): a usable base URL so the resend door would really mint (TASK-3392) * test(server): the session doors' own kind checks, with the bot already resolved (TASK-3392) * fix: stored reset/verification tokens, PAT rotation, grants and OAuth bearers refuse a bot (TASK-3392, codex r1) * docs: SPEC-6 §11 Q2 is decided (separate Apps section, no seat); the switches stay pinned (TASK-3392) * docs(server): name the two layers in front of CLI approve's kind check (TASK-3392) * test(appstore): the projection's install gate joins the reviewed door helpers (TASK-3392, rebased on #1765) |
||
|
|
a17e2c415e |
feat(appstore): app item create and field update inside the fence (TASK-3390) (#1765)
* feat(appstore): app item create and field update inside the fence (TASK-3390)
SPEC-6 U2a (DOC-3371 v12 §4).
store.FencedTx write methods:
- CreateItem:
- the companion check, then the human create's workspace lock
- the item cap under that lock, only when Options.PlanLimit is set (Pad
Cloud), as the human create does; it refuses with ErrAppItemLimit and no
count
- a random-suffix slug, never a collision scan (a scan would reveal hidden
slugs)
- the row (source app, created_via_app and via_app = the install)
- the initial version (via_app), status transition, item.created event and
decision-job enqueue
- wiki links confined to companion targets
- an in-fence "created" activity
- UpdateItemFields:
- the seq lock FIRST, then the companion and created-by-this-install
checks, then the etag (HMAC over install, item and seq under a
server-only key, compared in constant time)
- a field merge, the status transition, update events and enqueue
- an in-fence debounced "updated" activity
- no content, title, relation, parent or attachment change is reachable
Activities are written on the fence's transaction (seq lock, then the
debounce lock, the order BUG-2777 permits). A wiki link to anything but a
live companion item stays broken.
appstore.CreateItem / UpdateItem validate against the companion schema:
- refused: undeclared, relation, computed, unique-scoped and reserved keys
- coerced and validated as the human path does, with defaults applied
- ANY pad-attachment: token is refused in any spelling, and
[[workspace::REF]] is refused
- the create retries ONLY on the items slug conflict (IsItemSlugConflict),
never on another unique violation
Writer identity: via_app joins the activity debounce key and the version
throttle comparison. The U3 projection block carries creator.via_app.
Migrations SQLite 113 / PG 087: via_app on items, comments, activities,
item_versions and item_links; created_via_app on items. FK to app_installs,
whose rows are never deleted.
Boundary checker: reviewed door helpers (store methods and package-level
functions), each with what it writes, audited by reading every body; only
createActivityQ, enqueueDecisionJobsTx, replaceWikiLinks and writeOutboxTx
write, all on the transaction they are handed. Named function-value entries
(the decision-set resolver, two in-body closures) are keyed by function; a
same-named closure elsewhere is still refused (fixture). stampAttachmentRefsTx
stays unreachable.
Tests:
- write census per mutation on both dialects
- every refusal captures nothing
- the item cap and its self-host exemption
- companion-only links
- interleaved app and human creates never share item_number or seq (red on
Postgres without the lock)
- via_app identity in the debounce and the version throttle
- nine mutants, each fails its test
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): scoped link resolution, defaults under the rules, a required etag key (TASK-3390)
Codex round 1:
- P1: an app link was resolved over the whole workspace and confined
afterwards, so a hidden item sharing a companion's title could win the
match and leave the link broken: an existence signal. Resolution is now
scoped (linkScope) to companion collections in every query, the
number-only and collection-qualified fallbacks included, so a hidden item
cannot influence the outcome. The human paths pass the zero scope and are
unchanged.
- P2: schema defaults are applied after the key checks, so a default could
land a relation, computed or uniqueness-scoped value, or a pad-attachment:
token. Validation now runs against a schema with those defaults removed,
and the effective fields are re-checked for tokens.
- P2: an empty or short etag key made etags forgeable offline. ETag and
UpdateItem now refuse with ErrNoETagKey unless the key is at least 32
bytes.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
6066d42115 |
feat(outbox): freeze an app-projection block into item and comment events (TASK-3389) (#1763)
* feat(outbox): freeze an app-projection block into item and comment events (TASK-3389) SPEC-6 U3. A connected app's webhook payload must describe an event as it was when it happened, built from nothing but what the mutation froze. Every single-subject item and comment event now carries an `app_projection` block, computed on the mutation's own transaction: - items: creator (account id, display name, kind), collection_id, and the field values projected by the event-time schema (declared keys only, never relation or multi_relation); - comments: creator, collection_id, item_id, and a parent_comment_id kept only when the parent is on the same item; - comment.deleted computes its block before reapTombstonesTx can remove the parent, so the parent check sees it. The comment payload is built by marshaling the comment and adding the block as a key: models.Comment has its own MarshalJSON, which an embedding wrapper would promote, silently dropping the block. Account deletion scrubs the block by path: creator.user_id and the display beside it. Projected field values are the item's own data and are not walked. The block is for app delivery only. The outbox drain strips every app_projection key at deliverOutboxUnit, the one place a payload reaches an owner webhook (singles and folded bulk batches alike). The block is written for every event for now; the one-installed-app gate lands with the installs table (DOC-3371, U4/U5). * fix(store): a data-shape problem degrades the app projection to partial, never fails the write (TASK-3389) * fix(store): a fields blob with trailing data projects partial; cap the partial-warning registry (TASK-3389, codex r3) |
||
|
|
85c49cf2fd |
feat(appstore): FencedTx, the appstore skeleton, and the boundary and write-capture guards (TASK-3388) (#1764)
* feat(appstore): FencedTx, the appstore skeleton, and the boundary and write-capture guards (TASK-3388)
SPEC-6 U1 (DOC-3371 v12 §4): the door every app write will go through, and
the two tests that guard it. No app mutations or routes yet; U2 adds them
under these guards.
- store.FencedTx:
- BeginFenced's first statement reads app_installs (auth_epoch, state,
workspace), FOR SHARE on Postgres, and refuses unless the admitted epoch
is current, the install is active and the workspace matches.
- There is no raw Exec and no free-form query. The lock primitives
(LockWorkspaceSeq, NextItemNumber, NextSeq) take the human item path's
own workspace lock.
- Companion guards: requireCompanionCollection, and requireCompanionItem
for derived rows.
- Every field is unexported, and a zero value refuses every method.
- internal/appstore: holds *store.Store concretely; withFence runs one
mutation in one FencedTx; CheckFence is the live path until U2.
- The boundary test (go/packages + go/types) walks every module function
the package can reach. It fails on:
- a *store.Store method off a reviewed allow-list (stronger than the
spec's deny-list)
- database/sql calls outside the door
- Store.db or (*Store).DB
- an interface call or declaration an internal/store type answers
- reflect or unsafe
- the ordinary attachments Put
One fixture per rule under testdata is asserted to be reported.
- The reverse test: no package outside internal/store and internal/appstore
references BeginFenced.
- storetest.CaptureWrites:
- SQLite: modernc's pre-update hook on a single connection, with a
sentinel write proving the hook is still on it.
- Postgres: statement-level audit triggers installed by the harness.
The harness is proven red on a human create (items plus FTS5 shadow
tables) and on a test-only trigger write at depth > 0, and silent on a
read. The blind inventory is asserted exactly: items_fts, comments_fts,
documents_fts and sqlite_sequence.
- guardConn.Unwrap exposes the driver connection to the harness.
- Migration: app_installs (SQLite 112 / PG 086); its columns are added to
the NUL baseline, since no writer exists before U8, which must validate
origin.
Moved to U2, where they are first used: DebounceActivity, CheckItemCap and
the via_app columns.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): close the boundary checker's gaps and the harness's coverage holes (TASK-3388)
Codex round 1. Each gap now has a testdata fixture asserted to be reported,
and the six P1 fixtures were checked against the round-0 checker:
- package-var: package-level var initializers and init functions are walked
for the root and every module package it brings in that the human store's
own closure does not
- generic: instantiated generic methods are normalized through Origin()
- embedded-sql: rules check a method's DECLARING receiver, so a method
promoted through an embedded *sql.DB or *store.Store is caught at the call
- func-field: a call through a function value is refused (dynamic-call)
unless it is a parameter of an unexported root function that every call
site passes a literal or a named function
- linkname: a go:linkname directive or an unsafe import in a walked package
is refused
- whole module: the walk covers the whole module, not only internal/
- embedded-store: the old checker caught this only by walking into
DeleteItem's body; it is now caught at the call
Against the round-0 checker, all six P1 fixtures fail (pkgvar, helperinit,
generic, embeddedsql, funcfield, linkname); the extra embeddedstore fixture
passed there, for the reason above.
Harness:
- SQLite: PRAGMA data_version, read on the hooked connection at the start
and end, refuses a capture during which another connection committed. A
replaced connection is refused at the sentinel. Both branches are tested;
the data_version test passes silently without the check.
- Postgres: triggers are ensured at every capture, so tables created later
are covered; a table created during a capture is refused; captures are
serialized per database.
- The Postgres lock tests wait until pg_stat_activity shows a backend
waiting on a Lock, instead of treating 500ms as proof of blocking.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): refuse every function value instead of verifying callbacks; working-trigger check (TASK-3388)
Codex round 2 found five P1s in the checker and one P2 in the harness.
Four of the P1s were bypasses of one mechanism: verifying where a callback
parameter came from. They were reassignment, aliasing through a pointer, a
method expression shifting the argument index, and interface dispatch past
the call-site census. This is a guard, not a proof, so the mechanism is
removed rather than patched again:
- withFence(fn) is gone; app mutations open and commit their FencedTx
directly.
- Every call through a function value is refused (dynamic-call).
- Any use of a variable, parameter or field of function type is refused
(func-value). This also closes the fifth P1: an exported function-typed
package var in the store's own import closure, run through
sync.Once.Do, whose initializer the walk excludes.
Interface checks now judge the INSTANTIATED receiver, since Mutator[string]
can be implemented where Mutator[T] is not; Origin() is used only for
indexing.
Harness P2: a pad_write_audit_t trigger counts only when it is enabled,
statement-level, AFTER, on INSERT/UPDATE/DELETE, and calls
pad_write_audit_fn. Otherwise it is dropped and recreated.
Fixtures genericiface, callbackparam, methodexprcallback and oncevar each
pass against the round-1 checker and are caught now. A Postgres test
disables the trigger and expects the next capture to see the write.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): refuse interface dispatch on unbound type arguments (TASK-3388)
Codex round 3: a generic helper run[T](m Mutator[T]) is walked once, with T
unbound, so Mutator[T] never matched *store.Store and the call went
unflagged. An interface call whose receiver is, or is instantiated with, a
type parameter is now refused, rather than propagating type arguments
through the walk. Fixture genericifacebody passes the round-2 checker and is
caught now.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): no interface dispatch inside a generic body (TASK-3388)
Codex round 4 found two more ways a type parameter hides from the
round-3 check:
- an anonymous interface constraint (or a generic alias)
- a type parameter nested inside a func, slice, map or alias type argument
Chasing type parameters through every type form is open-ended, so the class
is closed outright: a generic function body, walked once with its parameters
unbound, may make no interface method call except on error. A non-generic
function cannot hold an unbound type parameter, so the ordinary
implemented-by-store check stays exact there. Fixtures genericconstraint and
genericnested both pass the round-3 checker and are caught now.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
4e3988300f |
fix(attachments): a thumbnail takes its original's binding at insert time (BUG-3385) (#1762)
A thumbnail job reads its original once, then decodes and resizes it, and
builds the variant rows from that snapshot. If the original was attached to
an item in the meantime (POST /attachments/{id}/attach), the snapshot's
item_id was still NULL. The attach updates only the variants that already
exist, so the thumbnails were inserted unbound under a bound original, and
orphan GC reclaimed them after the grace period.
CreateAttachmentVariantIfParentLive already locks the parent row before it
inserts. It now reads the parent's item_id under that lock and inserts the
variant with it, so no bind can land between the read and the insert.
|
||
|
|
745f576ebc |
ci: run the native PostgreSQL backup/restore round trip (TASK-2962) (#1761)
* ci: run the native PostgreSQL backup/restore round trip (TASK-2962) TestPostgresBackupRestoreConnection had never run in CI. It skips unless PAD_TEST_POSTGRES_TOOLS_URL is set, and CI could not have set it: the Go (PostgreSQL) job's server is postgres:17, ubuntu-latest ships the 16 clients, and pg_dump refuses to dump a newer server. The restore rewrite (#1703) shipped in rc.3 with its round trip unexercised. The job now: - installs postgresql-client-17 from PGDG, after checking the signing key against its published fingerprint, and puts its bin first on PATH - checks that pg_dump and psql are both version 17 - runs the round trip alone and verbosely with PAD_TEST_POSTGRES_TOOLS_URL set, and fails unless the output shows it PASSED. In the quiet suite a skip and a pass look the same, which is how it went unrun. Measured locally against a postgres:17 server, with 17 clients (via docker wrappers): the test passes, and it fails for each of four restore and backup defects: - no --single-transaction: a failed restore left its change committed - psqlrc honoured: a restore reported success after a SQL error - no ON_ERROR_STOP: the same - BUG-2952's PGDATABASE=<uri>: pg_dump failed Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * ci: the PGDG key file must hold exactly one primary key, PGDG's (TASK-2962) Codex round 1: signed-by trusts every key in the file, and the check read only the first fingerprint, so a response carrying PGDG's key followed by another would have passed. The file now has to contain exactly one primary key, and its fingerprint must be PGDG's. Checked locally: the real key is accepted, and the real key with a second key appended is refused. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
cc85c2f9da |
feat(auth): social claim of a never-verified account, provider subjects, cloud-only sidecar routes (TASK-3351) (#1760)
* feat(auth): sidecar OAuth routes are cloud-only; provider subjects bind on next sign-in (TASK-3351) U1: oauth-login, oauth-link and oauth-unlink answer only in cloud mode. A self-hosted install carrying a cloud secret created accounts and minted sessions on a caller's word about an email. U2: user_oauth_identities (SQLite 111 / PG 085) binds a provider's stable account id to one pad account. oauth-login and oauth-link take an optional subject. The first sign-in carrying one binds it, and a different one is refused 403 oauth_subject_mismatch, naming the recovery route (password reset, then relink). A subject bound to another account creates nothing. Unlink forgets the binding. The subject is checked at the door (no NUL trigger), and the four columns are recorded in the NUL baseline. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(auth): a provider that vouches for an address claims the never-verified account (TASK-3351) U3: oauth-login for an existing account whose provider is not linked used to refuse. Now, if the account has never been verified and the provider vouches for the address, it is claimed: - Google and Apple: email_verified is enough. - GitHub: the sidecar must also send email_primary=true (ruling 2), and sidecars today don't, so GitHub claims stay refused until one does. The claim runs the BUG-3382 core, now shared as claimAccountTx: - credentials reset and the epoch bumped - access stripped in workspaces the account doesn't own, and the ones it owns soft-deleted (ruling 3) - an account with billing refused with account_claim_needs_support (ruling 4) The core now also unlinks every sign-in provider and forgets their bound subjects; those were linked by whoever held the account. The claimant's provider is then linked and bound, the display name replaced with the provider's, and a session minted on the claim's epoch. The response carries claimed, stripped_workspaces and deleted_workspaces. The mailbox gets a notice listing them (lead's ruling: the redirect may never render the response); without email it is logged. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(auth): claim, link and bind commit together; serialize provider writes; claim lock order (TASK-3351) Codex round 1: - P1: a social claim committed before its provider subject was checked, so a sign-in refused on the subject left the account claimed. ClaimAccountByProvider now takes the provider and subject and links and binds in the claim's own transaction; a mismatch rolls the whole claim back. - P2: a sign-in that read the link before an unlink could re-bind its subject afterwards. Every provider write (link, bind, unlink, claim) now locks the account row, and a bind requires the provider to still be linked (ErrOAuthProviderNotLinked, answered as oauth_provider_not_linked). - P2: the social claim locked the account before its verification tokens, the reverse of verification, and the two deadlocked on Postgres. A test hook holds the claim at its account lock while a consume starts; without the fix it fails with 40P01 under test-pg, and with it it passes. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(auth): one lock order, account before tokens; a failed provider link stops the sign-in (TASK-3351) Codex round 2: - P2: the token-first order adopted in round 1 deadlocked against account deletion, which locks the account first. Every path now locks the account before its tokens: - deletion (unchanged) - the social claim (back to account-first) - ConsumeEmailVerification and the link claim: each finds the token's account without a lock, locks the account, then spends the token conditionally This also removes an older consume-vs-deletion deadlock. Two tests hold the claim, or deletion, at the account lock while the others start. Under test-pg, the consume-first order fails both with 40P01 and the claim-first order fails the deletion test; the fixed order passes both. - P2: on a new account, a failed provider link was only logged and the sign-in continued, leaving an account that could not sign in with that provider again. Any link failure now stops the sign-in. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(auth): a link claim checks its token's expiry after waiting for the account (TASK-3351) Codex round 3: the spend reused a timestamp taken before the account lock, so a token that expired while the claim waited still claimed the account. The spend now reads the clock itself. Test: a token expiring in 1s, with the claim held at the account lock for 2.1s, claims nothing. It fails with the stale timestamp. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(auth): a claim resets the registrant's username and display name (TASK-3351) Lead's ruling on #1760: the registrant chose the username and display name, and either can impersonate ("support", a staff name). Nothing live depends on the username once the claim has deleted the account's own workspaces. The shared claim core (both the link claim and the provider claim) now resets both, in the claim's transaction under the account lock: - the name: the provider's name, or the address's local part - the username: generated from that name with GenerateUsername, and unique among other accounts The handler's post-claim rename moves into the core. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(auth): a claimed account's username follows the username rules and survives a lost race (TASK-3351) Codex round 5: - P2: the regenerated username could be reserved (support@ gave "support", a provider name "Console" gave "console"). The store cannot import the server's ValidateUsername, so the server installs it on the store (SetUsernameValidator), and candidates that fail it are skipped. - P2: a suffix could push a 39-character base past the limit. Each candidate now cuts the base to fit its suffix. - P2: another account taking the chosen username before the claim wrote it failed the claim with a unique-index violation. The claim now retries, up to three times. Its transaction rolled back whole, the link claim's token included. Tests: reserved and length (store, with a stub validator); the real validator through the social claim (server); the lost race, Postgres only, through a hook that takes the username before the write. Each test fails without its fix. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(auth): a claim falls back to a random username when no candidate from the name will do (TASK-3351) Codex round 6: an email fallback the generator leaves invalid (foo--bar), or a name whose first 1,000 candidates are all held, failed the claim. After the derived candidates, the claim now tries random user-xxxxxxxx names under the same rules and uniqueness check. The test fails without the fallback. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
cd6dfdb318 |
fix(settings): Conventions and Playbooks start unchecked when a member is restricted (TASK-3384) (#1759)
Dave's ruling on TASK-3376's picker default: switching a member from All to Specific collections no longer pre-checks the system collections. They start unchecked, like every other collection. The hint under the list now names the consequence: unchecked, they stay hidden from that member, and their agents won't load the workspace's conventions or playbooks. Migration 108's backfill is unchanged: members restricted before TASK-3376 keep exactly the access they had. A member already restricted keeps their saved list.v0.18.0-rc.4 |
||
|
|
98871c7e01 |
fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382) (#1758)
* fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382)
Registration mails the verification link to the address, and the address
may belong to someone other than whoever registered it. Clicking it used to
verify the squatter's account in place, keeping the squatter's password and
sessions.
- POST /auth/verify-email now verifies only when the request is signed in as
the account the token names. Otherwise it answers 409 verify_needs_session
with {email, can_claim} and leaves the token unspent.
- New POST /auth/verify-email/claim {token}: the mailbox owner claims a
never-verified account. One transaction spends the token, resets every
credential (an unusable password, 2FA, sessions, PATs, OAuth, CLI
handoffs, reset and verification tokens), marks the address verified,
strips memberships, grants, watches and tabs in workspaces the account
does not own, and soft-deletes the ones it owns (TASK-3351 ruling 3). An
account with a billing customer is refused with
account_claim_needs_support (ruling 4). The answer carries a
set-password path and the affected workspace names.
- users.credential_epoch (SQLite 110 / PG 084): bumped by a password change,
a disable and a claim. Every session mint is fenced on the epoch read
when the credential was checked (login, 2FA via the challenge token, CLI
approval, reset completion, rotation, OAuth login), so a sign-in checked
before a claim mints nothing after it (401 credentials_changed).
- The web verify page offers "Sign in to verify" (back to the link), and,
for an unverified address, a confirmed claim listing what was removed.
- A regression test shows that an unverified account's cookie cannot use
the cookie-fallback routes.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(auth): fence password writes on the checked epoch; own verify bucket; switch account (BUG-3382)
Codex round 1:
- A password change (PATCH /me) or a reset completion that started before
a claim could still overwrite the claim's reset. UserUpdate.ExpectedEpoch
makes the write conditional on the epoch its authority was checked under:
the current password's read, or the reset token's spend. The spend now
reads the epoch in the same statement, and a test hook puts a claim
between the spend and the read-back. A mismatch writes nothing
(credentials_changed).
- verify-email and its claim move to their own EmailVerify bucket (10/hour),
so opening the link, refreshing and claiming no longer spends the
PasswordReset budget the claimant needs to set a password.
- The verify page, signed in as a different account, signs out before
sending the user to sign in. A plain /login link bounced straight back.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
3bcef0e04d |
fix(access): system collections are ordinary collections for restricted members (TASK-3376) (#1756)
A member restricted to specific collections saw every system collection (Conventions, Playbooks) whether it was listed for them or not, and an editor among them could write it, because requireEditPermission passes any editor before it looks at collections. Eight sites each unioned the system collections in. They now follow member_collection_access and grants like any other collection, for reads and writes. - Store: VisibleCollectionIDsQ, GetUserWorkspaces' freshness MAX and ResolveBacklinksVisibility drop the union; ListSystemCollectionIDs(Q) had no caller left and is removed. - Server: the item-visibility rule, /me's full-access set, watch access, search and the SSE full set drop it. - Migrations 108 / 082 list every live system collection for every member already restricted, so the upgrade changes nobody's access. A system collection created later is not added for them. - Settings: Conventions and Playbooks are real checkboxes in the member access picker, checked by default when an owner moves a member from All to Specific, with a line saying what unchecking them does. Also (PR #1756 codex round 2 P1): an editor or owner ROLE applies only inside the collections a member's access reaches ('all', or listed in member_collection_access). Before, requireEditPermission let any editor through before it looked at the collection, so a restricted editor who could see an item only through a VIEW grant could edit it, comment on it, create beside it and bulk-mutate it. That was true on main for ordinary collections too. Outside the member's reach the grant decides, and only 'edit' writes. The same rule now governs the collab socket's write decision, the bulk per-item and move-target gates, and the cross-workspace edit authorizer, and ResolveUserPermission's membership step checks the member's listed collections instead of the navigation set (which lists a collection because one item in it is granted). The collection check binds the EDITOR role only (codex round 3, lead ruling): an owner can set any member's collection access, their own included, so the owner role keeps its full edit bypass, and the server agrees with the web client's canEditItem / canEditCollection. |
||
|
|
0929f65705 |
feat(invitations): a mailbox-only proof on invitation emails verifies the invitee (TASK-3352) (#1757)
* feat(invitations): a mailbox-only proof on invitation emails verifies the invitee (TASK-3352) BUG-3348 stopped an invitation code from verifying the invitee's address, because the inviter sees the code; a cloud invitee then had to click a separate verification email before writing. The invitation email now carries a second secret that only the mailbox receives. - CreateInvitation mints a 128-bit proof next to the code and stores only its SHA-256 (workspace_invitations.proof_hash, migrations 109 / 083). The plaintext is on the returned struct for the email sender only and never serialises. - Both senders (member invite, admin resend, which re-creates the invitation and so rotates the proof) email /join/<code>#proof=<proof>: in the fragment, so it never reaches request logs or a Referer. The invite response's join_url, the members and admin invitation lists and copy-link carry the code alone. - Store.ConsumeInvitationProof spends it in one transaction (user row locked first, live accounts only): clear the hash, mark the address verified. Single-use and bound to its invitation. - Accept (`proof` in the body, after a successful accept, unverified accounts only; the email match already ties the account to the address) and register with an invitation (`invitation_proof`, which then skips the separate verification email) spend it. A wrong or spent proof is ignored, not refused. The code alone never verifies. - The join page reads #proof=, keeps it in sessionStorage for the code across a sign-in round trip, strips it from the address bar and sends it with the accept or signup. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(invitations): an expired invitation's proof verifies nothing (TASK-3352) Codex review round 1: the consume checked only the invitation id and the hash, so a request that passed the handler's expiry check and crossed the expiry before consuming still verified the account (reproduced at 537 ms past expiry). The expiry is now decided inside the consume's own statement. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(invitations): an empty expiry verifies nothing, and a malformed proof fragment is ignored (TASK-3352) Codex review round 2: - expires_at = '' was let through by the consume, though IsExpired reads it as expired; it now verifies nothing, like any expired invitation. - #proof=% made decodeURIComponent throw before the join page's error handling, leaving it loading forever; a malformed escape is now no proof. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(invitations): name the email-match duty of ConsumeInvitationProof's callers (TASK-3352) Lead's review nit. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
82395bde1c |
feat(import): mark rows a workspace import wrote, author not verified (BUG-3379) (#1755)
* feat(import): mark rows a workspace import wrote, author not verified (BUG-3379) Workspace import keeps the export's comment authors, version kinds and attachment uploader ids verbatim (BUG-3372 ruling (1)), and nothing distinguished those rows from authenticated authorship, so an importer could present a fabricated trail. Nothing stored could tell them apart either: user_id is NULL after an account deletion too, the export supplies the timestamps, and import writes no activity. - Migrations 107 / 081 add imported (INTEGER 0/1) to comments, item_versions and attachments. Only the import doors set it (JSON and bundle; a derived thumbnail or transform inherits its original's). - Comments, versions and attachment rows serialise an omitempty imported: true. - An imported attachment's uploaded_by is never resolved to a local name: the member route sends X-Pad-Attachment-Imported: 1 instead. That also closes an importer naming a real member as uploader by putting their id in the manifest. - The web marks them: "· imported" beside a comment or reply author, an Imported chip on a history row (and an imported row never merges with a native one), and "imported" in place of the uploader in the viewer. - Rows imported before this are not marked: there is nothing to backfill from. - MCP tool surface 0.64 (additive): the flag reaches comment and attachment responses on both transports. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(import): keep the imported mark through recovery, autosave runs and copies (BUG-3379) Codex review round 1: - An imported version claiming recovery or system lost its marker in the history; it is kept. - The History tab's autosave row had no marker; it shows the chip. - The server's autosave burst grouping could fold an imported snapshot into a native one (both often have no user id); it no longer does. - A cross-workspace copy carried imported=1 onto a clone the copier uploaded, hiding its real local uploader; the clone is not imported. - A store test inserting attachments by column list is updated for the new column. - The 0.64 changelog names the MCP responses that actually carry the flag: comments, attachment list and full history (not history summaries, attachment show or the attachment resource). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(import): a local edit never lands inside an imported version (BUG-3379) Codex review round 2: the version throttle ignored the flag, so a local edit within the hour, by the same kind and source as the newest imported version, wrote no version and read as part of the imported change. An imported predecessor now always starts a new version. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
4d6ebd426c |
fix(server): attribution comes from the request, never the body (BUG-3372) (#1754)
* fix(server): attribution comes from the request, never the body (BUG-3372) A body could set who wrote a comment, an item, a link or a version, and from which client: author (free text, rendered as the comment's author), created_by, source, last_modified_by and version_source all yielded to a body value, so any editor could post as another member, or file a write as an agent's or a human's at will. - Comments and replies: author is the signed-in account's name (empty, labelled by kind, with no account); created_by and source are the request's. - Item create: created_by and source are the request's. Item update: last_modified_by and the version row's source are the request's, and a body source no longer moves items.source (an update never should). - Links (blocks / parent / ...): created_by is the request's. - The canonical account columns are filled on every request write path: items.created_by_user_id and last_modified_by_user_id (create, update, artifact import, cross-workspace copy), item_links.user_id (link create, parent set on create and update). Account deletion already nulls all three. - X-Pad-Agent stays the agent signal (self-declared; it only labels the caller's own writes) but is trimmed, stripped of control characters and capped at 64 runes; a header that cleans to nothing is not an agent. No client sends any of these fields except the web's comment create and the MCP comment door, which send the values the server derives anyway. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(server): legacy document doors and bulk edits stamp attribution from the request (BUG-3372) Codex review round 1: - POST/PATCH /documents still took created_by, source and last_modified_by from the body; they are now the request's. - Bulk field and tag edits stamped the writer's kind but not the account; they now fill last_modified_by_user_id like a single update. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(server): the document autosave hint reads the body's source before attribution is stamped (BUG-3372) The legacy document PATCH skips its activity row for a content-only web autosave, keyed on the body's source. Stamping source from the request made every cookie request 'web', so every content-only update lost its activity row (TestActivityEndpoints). The body's value is kept as that hint only: it decides whether an activity row is written, never what any row says. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(server): the document autosave skip also requires a web request (BUG-3372) Lead's review: the autosave hint is the body's, so any caller could skip the activity row; a PAT or agent sending "source":"web" on a content-only PATCH wrote no audit record. The skip now also requires the request's own source to be web. Red-first test with the cookie control. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
db62001ad4 |
fix(billing): a plan past its expiry entitles nothing, and Stripe plan writes are ordered (BUG-3356) (#1753)
* fix(billing): a plan past its expiry entitles nothing, and Stripe plan writes are ordered (BUG-3356)
/admin/plan was last-write-wins under the plan-source rule, so two
Stripe-derived writes arriving out of order could leave the older one
standing and restore an entitlement a newer one removed; and
plan_expires_at was stored but never read.
- models.User.EffectivePlan / PlanExpired: an expiry at or before now
(or one that does not parse) makes the plan free. Every entitlement
reader uses it: the workspace and user limit checks and workspace
storage info; the session and account responses report it. Admin
responses keep the stored plan and add plan_expired.
- Migrations 106 / 080 add users.plan_revision and
plan_subscription_id. /admin/plan takes optional revision (the
sidecar's fetch time, unix ms) and subscription_id; a write with a
revision applies only if it is newer than the stored one, decided in
the same UPDATE as the source rule. A refusal answers 200
applied:false with reason "source" or "stale_revision".
- A stripe write with no revision (a sidecar that predates it) still
applies, unordered, and is logged and counted in
pad_plan_writes_total{outcome="no_revision"}; a later change refuses
it once the sidecar sends one.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(billing): bigint revisions, manual writes fence in-flight Stripe writes, expiry-aware billing counts (BUG-3356)
Codex review round 1:
- Revisions are unix ms, beyond int4; Postgres inferred int4 for the
bare placeholders compared with 0 and refused real values (500).
Every revision placeholder is now CAST AS BIGINT; a test uses real
millisecond values and fails on Postgres without the casts.
- An admin downgrade left the stored revision alone, so a Stripe fetch
taken before it and landing after it restored pro. A manual write now
raises plan_revision to now (unix ms): a fetch from before the
operator's decision is refused as stale, one from after still applies.
Pad and the sidecar share a host, so the two clocks are close; skew
moves the fence by that much.
- The admin billing counts (customers by plan, new pro signups, the
admin user list's plan counts) ignored expiry; they count the
effective plan, decided in Go because a stored RFC3339 value may carry
an offset and does not sort against now.
- A refusal's reason was read after the UPDATE in a fresh snapshot; the
row is now read under its lock first and both decisions use it.
- subscription_id is validated at the door (Stripe-shaped, at most 255
bytes), since the column takes the NUL baseline's exemption.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(billing): a manual write cannot carry a revision, and the admin update validates expiry (BUG-3356)
Codex review round 2:
- /admin/plan accepted a revision on a manual write, which replaced the
current-time fence with a value the caller chose; a revision is now
refused (400, and by the store) unless the source is stripe.
- The admin user update stored an expiry without parsing it; under
expiry enforcement an unparsable one stores pro and grants nothing.
It is now refused as /admin/plan refuses it.
- The over-length subscription id case used NUL bytes, so JSON
rejection masked the length check; it is now a valid-shaped id.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
v0.18.0-rc.3
|
||
|
|
a2e1c5d9ca |
fix(oauth): narrow current-only wildcard connections to their current workspaces (BUG-3338) (#1752)
Connected Apps let a user turn include_future_workspaces off under the wildcard, but the wildcard is live and the flag was never read, so those connections kept reaching workspaces joined later. A startup step, next to BackfillOAuthConnections, turns each such connection into a specific list of its user's live memberships and clears the wildcard: the same result as "Limit to my current workspaces". Narrowing-only (it writes rows only for workspaces the wildcard already reached and only ever clears the wildcard), idempotent (a narrowed row no longer matches, and no door stores the split combination since BUG-3338), one transaction, and it logs how many connections it narrowed, how many rows it wrote, and how many were left empty. A Go step rather than a numbered migration because the migration runner has no per-migration hook to log a count. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
5614980edc |
Make database restore preserve live data on failure (#1703)
Stage and normalize SQLite backups before atomic publication, checkpoint committed live journals safely, and stop PostgreSQL restores on SQL errors. Cover recovery failures and compatibility with real-engine regressions. |
||
|
|
5749671dd9 |
fix(oauth): include_future_workspaces is no longer a control; limit to current is an action (BUG-3338) (#1751)
* fix(oauth): include_future_workspaces is no longer a control; limit to current is an action (BUG-3338)
The wildcard (all_current_workspaces) is live and covers workspaces
joined later, and nothing ever auto-added on include_future_workspaces,
so the Connected Apps checkbox promised something false either way:
unticked under the wildcard it still granted workspaces joined later,
and ticked on a specific list it added nothing.
- Connected Apps drops the checkbox and labels the wildcard "All my
workspaces, including ones I join later".
- PATCH /connected-apps/{id}/flags ignores include_future_workspaces
(an older page may still send it); the stored value follows
all_current_workspaces, as the consent screen always wrote it.
- POST /connected-apps/{id}/limit-to-current copies the user's live
memberships into the list and turns the wildcard off in one
transaction: the explicit form of "only my current workspaces".
- The consent screen is unchanged.
Rows already stored with the wildcard on and include_future off are
narrowed by a separate migration, held for a production count.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(oauth): limit acts only on a wildcard, keeps guest workspaces, and the empty-list rule holds under the connection lock (BUG-3338)
Codex review round 1:
- A repeated limit (a stale tab) on an already specific list added the
workspaces joined since; it now changes nothing unless the wildcard
is on.
- The snapshot left out workspaces the user reaches only as a guest
through a collection or item grant, which the wildcard reaches; it
now includes them.
- Removing a workspace and turning the wildcard off checked the
empty-list rule outside any lock, so a concurrent limit or flags
change could leave a specific list empty. Both now check and write
in one transaction holding the connection row's lock.
- Turning the wildcard off and limiting now re-check live connections,
as a removal does (TASK-3365).
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(oauth): the limit snapshot counts only live guest grants (BUG-3338)
Codex review round 2: a grant on a soft-deleted item or collection
confers no access, but put its workspace in the snapshot, where it
counted toward the not-empty rule and would come back to life if the
content were restored. The grant subqueries now use the live-grant
filter UserHasGrantsInWorkspace uses.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
119823d62d |
fix(server): role-board reorder refuses another workspace's item and counts its rows (BUG-3342) (#1750)
The reorder loaded each item with the global GetItem and never compared its workspace with the route's, so an item from another workspace the caller could edit passed every check. The workspace-scoped UPDATE then matched nothing and the answer was 200, which also told the caller the id existed somewhere. - The handler answers a foreign item exactly as a missing one. - UpdateRoleSortOrder requires each entry to update exactly one live item in the workspace, or rolls the whole batch back with ErrRoleSortItemNotFound, which the handler answers the same way. - checkItemVisibleQ refuses an item whose workspace is not the one asked about, as defence in depth. All 61 existing callers already pair the two. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
23a0360991 |
fix(mcp): a connection row decides workspace access, not the legacy token list (BUG-3337) (#1749)
* fix(mcp): a connection row decides workspace access, not the legacy token list (BUG-3337) The MCP OAuth gate unioned a grant chain's legacy session.Extra allow-list with its oauth_connections rows. Startup backfill seeds a row from Extra for every pre-PLAN-1519 chain, and Connected Apps edits only the row, so narrowing such a connection (wildcard to specific, or removing a workspace) never took effect, and refresh carried Extra forward so it never would. With a row present the row is authoritative and Extra is ignored. Extra still decides for a chain with no row (a backfill that failed for it), as before. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(mcp): name the restored-workspace exception the connection gate leaves (BUG-3337) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
681780486f |
feat(oauth): count and log requests that omit the RFC 8707 resource (TASK-3363 phase 1) (#1748)
Dave's ruling on TASK-3345 #1: a missing `resource` parameter is defaulted to /mcp's canonical audience, which lets a client registered through DCR replay a token cross-server. Phase 2, a release later, refuses such requests with invalid_target. Phase 1 measures who that would break, and changes no behaviour. - translateResourceToAudience now reports whether it DEFAULTED a missing resource. The authorize, decide and token handlers then call noteResourceMissing. - noteResourceMissing logs one WARN line naming the endpoint, client_id and the client's registered name, and increments the new pad_oauth_resource_missing_total{endpoint}. The label is the endpoint only, so cardinality stays bounded; the client is in the log. - Legs: with resource, nothing is counted; without, the request is still served and counted at authorize, decide and token. Mutants L1-L5 killed. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVCv0.18.0-rc.2 |
||
|
|
67d06fdf81 |
fix(auth): a CLI session is never a browser cookie, and sign-in refuses a cross-site form (BUG-3350) (#1747)
(a) Sessions did not record how they were issued, and SessionAuth accepted
any padsess_ token from a cookie as a cookie session. isBearerAuth is
decided by transport, so an admin's CLI session sent as a Cookie picked up
the cookie-only platform-admin bypass that bearer use is denied (BUG-1616).
- Migration 105/079 adds sessions.kind ('web' | 'cli'). It backfills 'cli'
from device_info 'cli-browser-auth' (lead's ruling: no forced re-login;
older CLI password logins stay 'web' until they expire).
- The kind comes from the device label at creation.
- The CLI sends X-Pad-Client: cli, so its password and 2FA sign-ins mint a
'cli' session and set no cookie. Rotation keeps the replaced session's
kind.
- SessionAuth, validateSessionCookie and requestSessionInfo's cookie path
accept only a 'web' session as a cookie. A CLI session still works as
the bearer it is.
(b) /auth/login (and 2FA login-verify) were CSRF-exempt and decoded JSON
whatever the Content-Type, so a cross-site text/plain form could sign the
victim into the attacker's account. A sign-in must now be
application/json (415 otherwise), and an Origin the browser names must be
this server (its Host or base URL) or an allowed CORS origin, including the
localhost defaults the dev server uses (403 otherwise). Headless clients
send no Origin. An E2E leg pins the web app's own login form sending JSON.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
56922e790a |
feat(server): every access and credential change re-checks live connections (#1746)
* feat(server): every access and credential door kicks live connections (TASK-3365 PR2) PR1 (#1744) kicked through the doors that already publish workspace_access_changed. This wires the rest: member role change and collection access; grant creation (which can narrow access); collection delete and item archive/move/restore, single and bulk; logout (each deleted session's owner), the session a sign-in replaces, strict-mode IP/UA kills; password change/reset/local reset and TOTP disable through rotateSessionsAfterCredentialChange; admin role change, temp password and disable; account delete, including the workspaces of grants the user issued, reported from DELETE ... RETURNING inside the deletion transaction; PAT delete/rotate, workspace-token delete; connected-app revoke and workspace removal; unusable-workspace cleanup. A source census fails any function that calls an access- or credential-changing store method without kicking. * test(server): account deletion kicks the workspaces of grants it issued (TASK-3365 PR2) The lead asked for the door to be proven rather than left to review: a freshly signed-in interactive session deletes an account that issued a grant in someone else's workspace, and that workspace's connections are kicked. Red with the kick loop removed. |
||
|
|
2bd0862af2 |
fix(auth): platform administration refuses an API token (BUG-3361) (#1742)
* 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
* test: drive platform administration with the admin's session, not its API token (BUG-3361)
CI on the first push: 70 E2E specs and TestMCPCapability_UpgradeChangesNothingWhileOff
called /api/v1/admin/* or admin account creation with an admin PAT, exactly
the use this change refuses. Our own harness was the first operator script
the breaking change broke.
- E2E: global setup keeps the session token login already returns as
fixture.adminSessionToken (padsess_ is not User-Agent bound when sent as
a Bearer). The seven admin call sites use it: account creation in
permission-walk, workspace-tabs, workspace-access-live, no-commerce and
account-delete; the admin user search; and PUT /admin/mcp. Every other
call keeps the PAT, including the MCP client config in plan-2310.
- mcp_capability_test: the admin drives /admin with a CLI session and keeps
its PAT for /mcp, which takes one.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(e2e): the admin console specs use the admin's session (BUG-3361)
CI on
|
||
|
|
e515da41ee |
docs(chatgpt): state the personal-data policy as 'Pad adds no personal data' (TASK-3364) (#1745)
Dave's ruling on TASK-3345 #2. What Pad attaches to a ChatGPT response (internal ids, account emails, an email-shaped display name) is stripped; what users wrote (titles, bodies, comments, field values) passes through unchanged. The stated invariant in chatgpt_project.go, the catalog doc comment and the plugin's longDescription now say exactly that. No behaviour change. |
||
|
|
484ca668a3 |
feat(server): revoking access re-checks live connections immediately (#1744)
* feat(server): access kicks re-check live connections now, not on the next tick (TASK-3365 PR1) Dave's ruling on TASK-3345 #3. A per-instance registry indexes live collab, /events and /events/stream connections by user and workspace; a kick runs the revalidation each already performs on its 60s tick immediately (the tick stays as the backstop). Kicks travel the watch bus as the server-internal KindAccessInvalidated, which watchStreamDelivers drops, and the existing workspace_access_changed publication kicks too, so the doors that already publish it are covered. Kicks are non-blocking sends that coalesce per connection. PR2 wires the remaining access and credential doors. * fix(server): access kicks ride their own transport, reach watch streams, install idempotently (TASK-3365 PR1) Codex r1: kicks on the client watch bus took sequence ids, replay slots and client queue space, so they could evict real notifications and open gaps. They now ride internal/accesskick (Redis pub/sub on its own channel, or local with one instance), and the access-changed helpers kick directly, so nothing internal touches the client bus and the internal kind is gone. Watch streams register under a wildcard workspace so workspace kicks reach them. SetAccessKickTransport is idempotent and replacement stops the old subscription. A partial revoke kicks too. Tests make the first tick deterministic. * fix(server): access kicks never wait on Redis on a request path (TASK-3365 PR1) Codex r2: the shared Redis client ignores a context deadline on command I/O, so a stalled Redis held each publish ~5s, serially per recipient, on the request that revoked access. Local connections are now kicked at once and remote publication goes through one bounded background worker (dropped when full; other instances fall back to the tick); the transport uses its own client clone with context timeouts enabled; lifecycle changes send one workspace kick, and a complete revoke no longer kicks twice. * fix(server): access-kick worker cancels on removal; transport owns and closes its client (TASK-3365 PR1) Codex r3: the worker published with context.Background, so removing a stalled transport left the worker and queue alive; it now has its own cancellable context. The transport's cloned client is closed when the server replaces or clears it, and the clone gets its own push-notification processor instead of registering on the source client's. |
||
|
|
0f5b567fe7 |
fix(server): lineage and closure name only items the caller may see (#1743)
* fix(server): lineage and closure name only items the caller may see (BUG-3366) Dave's ruling on TASK-3345 #4: HIDE. Parent title/ref and derived-closure related items authorized the other item by its COLLECTION, which is navigation-lenient for item grants, so an item-grant guest was shown ungranted parents and superseders in a granted item's collection. They now use the per-item policy relation_targets uses (itemVisibleFor, shared and fail-closed), for restricted callers in the list, single-item and closure paths. TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection is flipped: an ungranted parent is not matchable, a granted one is. * fix(server): redact an ungranted legacy relation target by title too (BUG-3366) Codex r1: a target with item_number NULL hydrates with a title and no ref, and redaction keyed on the ref alone, so an item-grant guest saw an ungranted legacy target's title through a granted item's relation field. |
||
|
|
66b4a9d477 |
fix(auth): disabling an account ends every credential, and a disabled account gets no new one (BUG-3349) (#1741)
(b) Admin disable deleted sessions only, so the account's PATs and MCP grants kept working. DisableUserAndRevokeAccess does all of it in one transaction: - disables the account; - deletes its sessions and API tokens; - deactivates every OAuth access and refresh token (by subject), and every unexchanged authorization code (through its connection's request id); - deletes its PKCE and OAuth connection rows. EnableUser restores none of them. (c) A disabled account acts nowhere and gets no credential: - requireActiveUserTx is the one gate every credential insert passes. It reads the user row in the insert's own transaction, FOR SHARE on Postgres, and refuses a disabled or missing account with ErrUserDisabled. It covers sessions, API tokens, OAuth codes, access and refresh tokens, and OAuth connections. FOR SHARE conflicts with the disable's row lock, so a mint admitted before a disable cannot land after it. TestBUG3349_MintWaitsForAConcurrentDisable_Postgres proves the wait with two connections. TestEveryCredentialInsertRequiresAnActiveUser fails on any new credential insert that skips the gate. - RequireAuth checks disabled status before the /api/v1/auth/* exemption. Only POST /auth/logout and GET /auth/session stay open, and the session check answers account_disabled. - The MCP door, /oauth/token, /oauth/authorize and /authorize/decide, 2FA login-verify, and email verification (before its token is spent) refuse a disabled account. The login page shows "Your account has been disabled". - Share links the account created are suspended (lead's ruling): byte-identical to an unknown token while it is disabled, serving again once it is re-enabled. Webhooks are unchanged by design; they belong to the workspace. This is documented in docs/deployment.md, "Disabling an account". Part (a) shipped in BUG-3336. codex review: two rounds, eleven findings, all fixed. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
9ef346268c |
fix(import): refuse duplicate source ids and attach by source id (#1740)
* fix(import): refuse duplicate source ids before writing anything (BUG-3357) The import remaps collections and items by source id, and a bundle's attachments by attachment id. Two rows sharing an id collapsed into one mapping, so every reference to it (an item's collection or parent, links, comments, versions, reminders, relation fields, pad-attachment refs) landed silently on whichever row was written last. The store now refuses a duplicate collection or item id with a validation error before its transaction opens (both import doors answer 400), and the bundle door refuses a duplicate attachment id in the manifest, rolling back the workspace pad-export.json minted. * fix(import): attach by source-id map, refuse repeated blobs and colliding empty ids (BUG-3357) Codex r1: the bundle door attached rehydrated attachments through the item SLUG, which the import renames when two items share one, so an attachment landed on the wrong item; it now uses the store's source-id -> new-id map (ImportReport.ItemIDs). A second tar entry at an already-rehydrated blob path is refused. Two rows without an id collide on "" exactly like two with the same id, so the second is refused; a single id-less row stays importable. * fix(import): ItemIDs names inserted items only; check a repeated blob first (BUG-3357) Codex r2: ItemIDs carried items the import skipped (missing collection), whose ids exist nowhere; it now returns the inserted-only map. The repeated blob check ran after the per-blob size check, so an oversized repeat took the keep path; it now runs first. |
||
|
|
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> |