mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
ca158c22d8dfe942e327dc2accc163cbf6dcc7fb
1073 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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) |
||
|
|
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. |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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 |
||
|
|
82064526e3 |
fix(brand): name the entity as Perpetual Software LLC everywhere (TASK-3321) (#1722)
Dave's ruling: the legal entity is Perpetual Software LLC and there is no dba, so "Perpetual Software" never names the entity without "LLC". Fixed: the ChatGPT plugin's author and developerName, both Claude plugin manifests' author/owner, the brand spec's copyright format, the cloud email footers (HTML and plain), and the auth-page footer. The guard test now pins the ChatGPT package's developerName and author to the full name, and the email tests expect it. LICENSE already said "Perpetual Software LLC"; goreleaser, the Dockerfiles and Nix carry no entity name. The self-hosted email test keeps asserting the bare substring is ABSENT: the shorter string is the stricter check there. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
bb3b587e40 |
chore(deps): npm update devalue to 5.9.4 (unblocks the Web job's audit gate) (#1705)
Six devalue advisories (high) covering <= 5.9.2 were published after #1701, so every PR's Web job now fails audit:ci. devalue reaches us through @sveltejs/kit (^5.8.1) and svelte (^5.9.2); 5.9.4 is inside both, so this is a lockfile-only update of that one entry. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
a4079d12e3 |
chore(deps): npm update undici markdown-it (Dependabot #9, #11-14, #17-18) (#1701)
Lockfile only: undici 7.29.0 -> 7.30.0 (dev-only, via jsdom for vitest) and markdown-it 14.3.0 -> 14.3.2 (runtime, via tiptap-markdown). Both stay inside their parents' ranges, and neither advisory path was reachable: we never use undici's BalancedPool, retry, dump or cache, and linkify is off at every Markdown.configure site. The materializer site, which runs on the server, now says linkify must stay off below markdown-it 14.3.1. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
1c6348edc5 |
fix(auth): OAuth login and password reset no longer mint a session past TOTP (BUG-3322) (#1699)
* fix(auth): OAuth login and password reset no longer mint a session past TOTP (BUG-3322)
Two session-minting doors skipped the second factor that password login
enforces:
- POST /auth/oauth-login (pad-cloud's GitHub/Google/Apple sign-in) minted
a full session for a user with TOTP on. It now answers 403
two_factor_required with the same login challenge password login issues
(in details.challenge_token) and no session. A sidecar that predates this
reads the 403 as a failed login, so it fails closed.
- POST /auth/reset-password minted a session after the reset, so access to
the mailbox alone defeated 2FA. A TOTP user now gets the new password,
every prior session deleted as before, and {ok, requires_login} with no
session.
The web login page opens the code step from a #challenge= fragment (kept
out of server logs and Referer), and the reset page sends a TOTP user to
/login. pad-cloud's redirect and IP forwarding are U2.
The other session-minting doors were swept on the item trail: each checks
TOTP, mints for a brand-new account, or inherits an existing session.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(auth): fill a missing avatar only after the oauth-login checks pass (BUG-3322 review)
Codex round 1, P3: the avatar of an existing account was updated before
the TOTP refusal. It now runs after every check, including the second
factor and the disabled-account check.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(auth): the two_factor_required message never asks for a password (BUG-3322 review)
A provider-created account has a random password nobody knows, and TOTP
setup does not require one, so "sign in with your password" was wrong for
it. The message now sends a native shell to the web code step.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
a4f16bfa22 |
fix(web): 44px touch targets round 2: group archive button, board lane +/⋯, card tags (TASK-3311) (#1698)
* fix(web): 44px touch targets round 2: group archive button reachable on touch, board lane + and ⋯, card tags (TASK-3311) * fix(web): card tags are visibly 28 tall with a 16px row gap; the extender gives the 44 (TASK-3311, Dave's ruling) |
||
|
|
c6df08f054 |
feat(attachments): the viewer says when a file was uploaded, and by whom (TASK-3319) (#1697)
* feat(attachments): the viewer says when a file was uploaded, and by whom (TASK-3319) * fix(attachments): the upload caption has its own class, so the type/size line's selector stays unambiguous (TASK-3319) |
||
|
|
d44c4f6545 |
fix(web): one workspace-access stream per browser, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318) (#1696)
* test(e2e): open pad tabs must not starve a new page of HTTP/1.1 connections (BUG-3318, red) * fix(web): one workspace-access stream per browser, not per tab, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318) |
||
|
|
552dee8ded |
feat(web): workspace tabs feel like Chrome's (TASK-3312) (#1676)
* feat(web): workspace tabs feel like Chrome's (TASK-3312) Dave approved all six of the lead's proposals (day 83): - Motion: a tab grows in and collapses out (CSS classes, not Svelte transitions, since the strip is a dndzone). Chrome's close-freeze holds the other widths until the pointer leaves, the window resizes, or the tab set changes from elsewhere. Reduced motion drops the animation but keeps the freeze. - The arrow cursor instead of grab. - The active tab flares into the page. The list now reaches over the bar's border, which overflow-x had kept a tab's own negative margin from doing. - Dividers between inactive tabs, Chrome's rule. Tabs are 38px. - A favicon-like filled square icon in both states. - Middle-click closes a tab, so it no longer opens the workspace in a new browser tab. The × shows on every tab of 150px or wider. The tab-bar e2e helpers move to e2e/lib/workspace-tabs.ts. The TopBar close tests now wait out the 140ms collapse. Two of them kept passing with the delay only because they asserted before the close ran, so they now also assert that the close was called. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * fix(web): no line under the active tab, room for the edge flares, readable tab initials (TASK-3312) The lead's review of #1676: - The 1px line left under the active tab was the connect banner's (and the verify banner's) top border, sitting flush against the bar and doubling its border. On desktop they now sit 8px below the bar as cards; phones are unchanged. Pixel-sampled: the page colour now runs straight down under the active tab in both themes. - The list clipped a first or last active tab's outer flare. It gets 9px of inline padding. - White initials measured 1.39-2.75:1 on the palette. An inactive icon now takes --text-primary, and an active one a fixed dark ink: 4.19:1 at worst. Two e2e legs pin the flare room and the missing line. Both were red on c1661c69 and are green here. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL |
||
|
|
02cbfd5a01 |
fix(web): 44px touch targets at phone width: strip, card ⋯, status chip (TASK-2244) (#1674)
* fix(web): collection strip controls are 44x44 at phone width (TASK-2244 U-a) Dave's ruling (day 83, option 1): at <=768px every control in the collection strip grows visibly to 44x44. A transparent hit area would overlap the neighbour 4px away. Desktop is unchanged, and the e2e pins the base sizes. Co-Authored-By: Claude <noreply@anthropic.com> * fix(web): the mobile view chip drops its visible View: prefix so the strip fits one row (TASK-2244 U-a) The lead's ruling on the 360 checkpoint (option B). The aria-label 'Change view' is unchanged. The e2e pins one row at 390 (list and board) and at 360 (board). It was red on the previous commit's binary at 390 list and 360 board, and it is green here. Co-Authored-By: Claude <noreply@anthropic.com> * fix(web): a mobile card has one 44x44 ⋯ carrying star, copy and reorder (TASK-2244 U-b) Dave's ruling (day 83, option 3). At <=768px the card's star, copy-ref and ⋮ could not each reach 44x44 without overlapping. One ⋯ replaces them on every ItemCard host, including the starred, tags and roles pages, which have no reorder. Star and Copy item ID are its first entries. A starred card shows a passive ★, which is not a control. Desktop is unchanged, and the e2e pins its sizes. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * fix(web): the card status chip gets an invisible 44x44 touch area that spares the tags (TASK-2244 U-c) Dave's ruling (day 83): the status chip gets an invisible ::after extender at <=768px. It grows upward from the chip, into the title (the card link), because a centred one landed 20 of the first tag's 40 sampled points on the chip. The e2e probes both sides with elementFromPoint: the 44x44 area hits the chip, and no point of a tag or of the card ⋯ lands on it. The card legs now seed a collection of their own, because the shared Tasks lane put seeded cards out of view. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * test(web): desktop legs pin only CSS-fixed sizes (TASK-2244) CI failed the desktop legs on text- and glyph-sized widths, which depend on the machine's fonts (view 77.6 vs 75, + New 101.1 vs 93.8, ⚡ 34.4 vs 41, ⋮ 18 vs 8, star 11.9 vs 13). Pinned now: every strip height (28), the three icon buttons' widths (30), and the copy button (22x22). The glyph-sized card controls must stay below 44. Run with the phone rule in force, the same assertions fail on every control. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * fix(web): the phone realtime dot is a badge on the collection menu button, so the strip is one row at 360 in both views (TASK-2244, Dave's gate) --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
4f367adc8d |
feat(history): one History tab replaces Activity and Versions (PLAN-2348 U3) (#1692)
* feat(timeline): serve body_edited rows and describe collapsed autosave runs (PLAN-2348 U3) * feat(history): group timeline rows into events; version card shows its own edit (PLAN-2348 U3) * feat(history): History tab replaces Activity and Versions (PLAN-2348 U3) * fix(history): diff labels, verb casing, create row, filter chip height (PLAN-2348 U3) * test(history): e2e specs and prose follow the History tab (PLAN-2348 U3) * test(history): re-review identity-fence rows after the tab change; HistoryView joins the child tables (PLAN-2348 U3) * fix(history): create versions render; a throttled edit shows its diff against the current body (PLAN-2348 U3) * fix(history): the since-now row is labelled Since then (PLAN-2348 U3) * fix(history): the since-now diff says it is cumulative (PLAN-2348 U3, lead ruling) * feat(timeline): a kinds filter, so a view pages through only what it renders (PLAN-2348 U3) * fix(history): History fetches only history kinds and Details only comments, so neither pages through the other (PLAN-2348 U3) * fix(timeline): an empty filtered page is [] not null; the History feed hears its item's updates (PLAN-2348 U3) * test(history): the recovery attribution test follows the attribution to HistoryView (PLAN-2348 U3, TASK-2198 U4) |
||
|
|
cb99879d53 |
feat(materialize): recover unsaved editor sessions into items.content (TASK-2198 U4) (#1683)
* feat(materialize): recover unsaved editor sessions into items.content in the background (TASK-2198 U4) Wires the U3 supervisor in. A collab room reclaimed at the end of its grace TTL with content-bearing rows above the flush watermark (T3), or a dormant pending item found by a one-minute sweep (T1), is queued; one worker goroutine materializes the item's whole content-bearing op-log with the whole-workspace link index and lands it through store.MaterializeFlush, under the collab item lock with no room open. The write is a flush: cursor == MAX(op-log id), rows pending, no set-aside rows, checked in the write's own transaction; watermark to the cursor only; seq bumped; one version row attributed to system/recovery; the op-log is never pruned. PAD_MATERIALIZE=off disables it entirely. A per-item in-memory failure budget (3 failures, doubling backoff, reset by a new op-log row) keeps untrusted input from being retried in a loop. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): recovery watermark is the cursor, not MAX at UPDATE time (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): a second recovery still writes its own version row (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(mcp): op-log recovery bounds the stale-body window; tool surface v0.60 (TASK-2198 U4) content_state applied_pending_flush now clears on its own after the item's room closes, unless recovery fails or PAD_MATERIALIZE=off. Every sentence that called the window unbounded, or said nothing server-side moves op-log content into items.content, is corrected (CLAUDE.md, instructions.md, README, docs/deployment.md, docs/backup.md, code comments). BEHAVIOR bump to 0.60; goldens regenerated. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): count ErrNoMemoryCap silently against the per-item budget (TASK-2198 U4) U3 now refuses every job with ErrNoMemoryCap when no memory cap can be established, and warns once itself. The recovery worker counts it like any failure but logs it at debug, not per item. Its log lines go through a logger seam so the test can tell. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * style: gofmt (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(materialize): one log line per budget exhaustion; count set-aside skips on the sweep line (TASK-2198 U4) An item that uses up its failure budget logs exactly one WARN naming the item, the failure count and the last error kind (DEBUG when the kind is no_memory_cap, which the supervisor already warned about once), and logs again only after op-log growth resets the budget and it is exhausted anew. The schema-version skip goes through the same line. Items skipped for set-aside rows are counted and reported on a new single sweep summary line, written only when it has news; never per item. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): keyset-page the recovery sweep so no candidate starves (TASK-2198 U4, codex r1) The sweep read the oldest 200 candidates from the start every tick, and the room check and the failure budget filtered them afterwards. Once 200 sat in backoff or exhausted, every sweep returned the same 200 and the 201st was never queued: filtering above a bound is starvation. Each tick now reads one page strictly after an in-memory keyset cursor (newest op-log created_at, item id) and wraps to the start after a short page, so every candidate is offered within ceil(N/limit)+1 ticks, whatever sorts ahead of it. Keyset over NOT IN: no parameter-count limit, and it also covers the room check, which an exclusion list would not. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(materialize): the sweep cursor wraps; docs for the exhaustion line, the set-aside count and paging (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(deployment): reflow the recovery budget paragraph (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(materialize): recovery strips the follows-title marker as a tab's save does (TASK-2198 U4, BUG-3315) Since #1688 a tab loads a link that follows its target's title with the follows-title marker in the link's title attribute, so the marker lives in Y.Docs that recovery replays. Three fixture cases (a follows-title link; the same link after a rename; a URL edited to an external address) are generated from documents that really hold the marker, and the existing 8 are unchanged. A new leg recovers each through the production path (whole-workspace index) and through the runner with a restricted and an empty index, and asserts no 'pad-follows-title' reaches the body. It goes red in all 7 ways when the bundle's marker strips are disabled. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): a recovery version row carries #1691's history data (TASK-2198 U4) Rebased onto PLAN-2348 U2 (#1691), which adds user_id, line counts and a create marker to version rows. Recovery writes its row through the same UpdateItem insert, so it gets them automatically: no user_id (a system row), not a create, and line counts equal to diff.LineCounts of the stale and recovered bodies. The leg pins that. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
7ba823004c |
feat(history): version rows carry their writer, line counts and create marker, plus a per-version diff endpoint (PLAN-2348 U2) (#1691)
* feat(history): version rows carry their writer, line counts and create marker; a diff endpoint pairs each row with its own change (PLAN-2348 U2) * test(history): server legs, export/import leg; web type and API method for the version diff (PLAN-2348 U2) * docs(history): the version diff endpoint and row fields in CLAUDE.md; FormatDiffSummary names what it counts (PLAN-2348 U2) * fix(history): user_id already existed on item_versions (migration 012); a diff refuses a body the chain could not rebuild (PLAN-2348 U2, codex r1) |
||
|
|
a61f50421f |
fix: a move refused for a value only the caller can supply gets a way forward on web, CLI and MCP (BUG-3200) (#1690)
* fix(web): a pane-menu move refused for a value only the user can supply opens the copy dialog's picker, preset to that collection (BUG-3200) * fix(cli): item move refused as missing_required_fields prints what each field needs, from the copy preflight (BUG-3200) * fix(mcp): a remote move refused for a destination value names the field parameter that supplies it (BUG-3200) * fix(web): fence and classify the copy dialog's preset continuation; the handoff is behind stillOnSource (BUG-3200, full vitest) * test(web): the handed-off move keeps the item's id and ref and sends no copy (BUG-3200, lead check) |
||
|
|
c7cd363173 |
fix(web): a renamed item's links follow the new title in collab-edited docs (BUG-3315 U2) (#1688)
* fix(web): a renamed item's links follow the new title in collab-edited docs (BUG-3315 U2) A tab loads [[TASK-1]] as a link whose TEXT is the title at that moment, and the text lives in the collaborative document from then on. After a rename, the next save compared it with the current title only and wrote [[TASK-1|Old Title]], pinning the old title for good. The load now marks a link that follows the title with the link mark's EXISTING `title` attribute, set to the text as loaded, so no schema change. It is set only for a link with no explicit override whose text is the title. The save treats text still equal to the marker as following the title ([[REF]]). An edited text, or a deliberate [[REF|X]] (which loads without the marker), is kept as an override. The marker never reaches stored content: markdownToWikiLinks consumes it, and cleanBrokenLinks, the last step of every save pipeline, strips any that is left, because those pipelines skip markdownToWikiLinks on an empty index. SafeLink keeps a same-origin link's `title` out of the DOM, so the stale title is never shown as a tooltip. Documents already in an op-log carry no marker and keep today's behaviour until they are next loaded. The characterization test from #1684 is flipped to the fixed answer. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(web): keep the save's link match as wide as before the marker (BUG-3315 U2, codex r1) The marker regex narrowed the href to exclude whitespace, so a link whose last segment had a space stopped converting. The path is matched lazily instead, ahead of the optional title, and the cleanBrokenLinks strip the same way. A guard leg pins it. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(web): the follows-title marker is prefixed, so a user's link title is never read or stripped (BUG-3315 U2, codex r2) cleanBrokenLinks stripped EVERY same-origin link title, so a user-authored [x](/path "tip") lost its tooltip on save. markdownToWikiLinks would also read that title as a marker. The marker is now "pad-follows-title:<text>". Only a title with that prefix is read, stripped, or hidden from the DOM. A titled internal link a user wrote is left exactly as before U2, which never converted it. The prefix lives in its own module (utils/followsTitle.ts), so safeLink, which the materializer bundles, imports nothing DOM-dependent. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(web): a follows-title marker straight from the load still reads as the title (BUG-3315 U2, codex r3) Through the editor the marker comes back as the serializer writes a title (only " escaped), so it is compared raw. Straight from wikiLinksToMarkdown it still carries the load's \\, \" and \& escapes, so it is also compared with those undone. The ')' findings of the same round did not reproduce; A)B, f(x) = y and q"uote) convert and strip correctly on both paths. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(web): the follows-title prefix is reserved (BUG-3315 U2, codex r4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(web): the follows-title marker is stripped from every link at save, whatever its href (BUG-3315 U2) The lead's gate before the GO: the marker never reaches stored markdown on any path where a marked link does not convert back to [[...]]. A leg now covers ten paths through real save pipelines: target deleted, empty index, restricted viewer, hard break in the text, partial bold, markdown table, HTML-serialized table, and a link whose URL the user edited to an external address (target present, and empty index). It asserts no 'pad-follows-title' substring, with a guard that each document really carried the marker. The URL-edit path LEAKED: the mark keeps its attributes, and both strips matched only same-origin hrefs. The final strip in cleanBrokenLinks now removes the reserved-prefix title from any link. Red variants: that strip off alone leaks 3 paths, all strips off leaks 9. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(web): the marker strip touches only a real link's title, never code or prose (BUG-3315 U2, codex) The broadened strip removed marker-shaped text wherever it appeared, so a doc that mentions the marker (a code span, a fenced block, escaped-bracket prose) lost text on save. Marker handling in markdownToWikiLinks and the cleanBrokenLinks strip now skip fenced code blocks and inline code spans (codeRanges), and the strip needs an unescaped '](' with a space-free href. Four legs pin it: each goes red when its guard is removed. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(web): marker strip counts backslash parity; code scan is linear (BUG-3315 U2, codex) A link text ending in a backslash serializes as [a\\](...): its ']' follows a backslash, but an even number of them, so it is a real link. The strip's lookbehind now requires even parity, where it used to keep the marker. The inline-span scan rescanned per opening run (267ms for 40,000 alternating runs, 18x for 10x the input). It is now one pass with per-length pointers and binary-searched ranges (9.6ms, 98ms at 400,000). The fence finding of the same round did not reproduce: a closing fence with trailing spaces is recognized. Legs pin both fixes. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(web): an inline code span never pairs across a fenced block (BUG-3315 U2, codex r3) Backtick runs are paired only inside their own gap between fences, which also keeps every code range disjoint, as the binary search requires. A leg pins it; it goes red when the gap is ignored. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(web): pin that a run inside a code span opens no second span (BUG-3315 U2, codex r4) Codex r4 read the span pairing as able to overlap spans of different lengths. It cannot, because the scan resumes after the closing run, so the finding did not reproduce. This leg pins it, and goes red when the resume is removed. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
5124a5f83d |
fix(menu): a scroll pending when a portal menu opens no longer closes it (BUG-3278) (#1687)
* fix(menu): a scroll closes a portal menu only if its trigger moved since it opened (BUG-3278) * fix(menu): narrow BUG-3278 to the pending scroll: ignore scroll events until the first frame after opening, so later scrolls keep their meaning * fix(menu): inside the first-frame window, a scroll that moved the trigger still closes the menu (BUG-3278, codex r2) * fix(menu): the first-frame check compares all four trigger edges; sideways and resize legs (BUG-3278, codex r3) |
||
|
|
1e16d2228b |
fix(web): link text with markdown/HTML-significant characters survives a save (BUG-3315 U1) (#1686)
A wiki link's display text was mangled on its way through the editor, and the first save of any item linking such a title pinned the mangled text as a display override, with no rename involved (a census found 76 stored overrides of this shape among 107 that differ from their target's title). Load: escapeMarkdownLinkText escaped only brackets and backslashes, so the html-enabled parser read the title as markdown. `*real*` became emphasis, `<topic>` was dropped as a tag, `>` was decoded. It now backslash-escapes \ [ ] * _ ~ ` < > &, all ASCII punctuation CommonMark lets a backslash escape. Save: unescapeMarkdownLinkText undid only bracket and backslash escapes, while the serializer also backslash-escapes other punctuation and writes < > & as entities. It now undoes both, in one pass. One residue is intrinsic and pinned by a test: the serializer writes a literal `>` and a literal `>` as the same bytes, so a title containing entity-shaped text still becomes an override (0 of 9,620 live titles do). The materializer corpus: two expectations change (a backtick and `<b>` in link text, both this fix), taken from the goja bundle's output. A full regeneration was not used because the generator is nondeterministic: with unchanged code it rewrites all 50 cases' rows and 1 expected value. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
a8eab7f7bf |
docs(collab): PruneAndApply stops advising a direct-write fallback (BUG-3313); characterize BUG-3315 (#1684)
* docs(collab): PruneAndApply no longer tells callers to fall back to a direct write (BUG-3313) The doc comment still said the caller "is expected to fall through to a plain direct write" on ErrRoomActiveDuringPrune. That fall-through is the door BUG-3313 traced a live item's divergence to: content and a version were stored while the op-log kept the old document, so every later tab replays the stale body. #1318 removed it; the content route re-decides within applierSettleBudget and refuses room_settling on expiry. The comment now says so and names the current classifier (HasElectableApplier). Comment-only; no behaviour change. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(web): characterize a rename pinning the old title on op-logged items (BUG-3315) A tab freezes a wiki link's display text into the collaborative document at load; after the target is renamed, the next flush stores [[REF|Old Title]], pinning the old title. Both [[Old Title]] (which the server's title cascade rewrites) and [[REF]] (which it never touches) behave the same, so the cascade is not a factor (BUG-3313). The test pins the current, defective answer and says so; BUG-3315's fix flips the first two cases. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
fa2755e183 |
feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2) (#1678)
* feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2)
Builds the editor's own JS (Yjs -> y-tiptap -> Tiptap schema -> tiptap-markdown
-> the tab's flush pipeline) into one IIFE, embeds it, and runs it in goja. Not
wired into the server yet (U3 supervisor, U4 write path).
- web/src/lib/collab/materializer/: bundle entry (extension list mirrors
Editor.svelte's collab branch via the U1 shared modules), op-log replay
mirroring wsProvider's MESSAGE_SYNC arm, linkedom DOM shim and polyfills
from the spike, and a vendored patch of prosemirror-markdown
MarkdownSerializerState.atBlank (quadratic under goja; equivalent).
- web/scripts/build-materializer.mjs (esbuild) -> web/build-materializer/,
run by `npm run build`; embedded as pad.MaterializerJS.
- internal/materialize: Runner (Go-native atob/btoa/TextEncoder/TextDecoder/
getRandomValues, schema-version check, ctx interrupt with a clean VM after),
and RunWorker's length-prefixed JSON protocol behind the hidden
`pad __materialize-worker` command.
- Parity: vitest drives two real Editor.svelte mounts and compares
materialize() with the tab's flush on 50 seeded cases; the same cases are
committed as testdata/corpus.json and replayed in goja (TestCorpusParity).
- CI Go jobs, release tests, Docker and Nix produce the bundle; linkedom and
esbuild are new devDependencies.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(materialize): pin the atBlank patch's equivalence under V8 too (TASK-2198 U2, codex r1)
Codex r1 claimed upstream's /(^|\n)$/ is true for a trailing CR in JS. It is
not (checked in V8: false for "\r" and "a\u2028", as the patch returns),
but the only equivalence test ran under goja and the patch header cited a
V8-side test that did not exist. This adds it, with the line-terminator edge
cases, and it fails when the patch checks CR instead of LF.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* build(nix): vendorHash for goja (TASK-2198 U2)
Taken from the Nix job's recompute on
|
||
|
|
79ed724dd1 |
refactor(editor): shared modules for codeBlock, link and table (TASK-2198 U1) (#1677)
Behaviour-neutral. The headless materializer (TASK-2198 U2) must build the same schema and markdown serializer as the live editor, so the schema-affecting parts of the three extensions Editor.svelte defined inline now live in plain modules both can import. DOM-only parts (the mermaid NodeView, the copy plugins) stay in the component. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
a2e7768035 |
fix(comments): a deleted comment with replies leaves a tombstone (BUG-3252) (#1675)
* fix(comments): a deleted comment with replies leaves a tombstone (BUG-3252) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(comments): backdate so a tombstone moving updated_at is seen (BUG-3252) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * chore(mcp,store): v0.59 instructions heading, NUL baseline for comments.deleted_at (BUG-3252) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(comments): reactions wait for a tombstoning delete; unreact on a tombstone answers 409 (BUG-3252, codex r1) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(comments): unreact racing a tombstoning delete answers 409, not 404 (BUG-3252, codex r2) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
754d85fa7c |
feat(web): the connect modal serves the token path (TASK-2321, PLAN-2310 U6) (#1668)
* feat(web): the connect modal serves the token path (TASK-2321, PLAN-2310 U6) PLAN-2310 DR-8. The session and setup payloads now carry mcp_public_url whenever MCP is available (it was cloud-only), plus mcp_auth: ["oauth","pat"] where OAuth is served, ["pat"] on an http self-host, and [] while MCP is off. sessionMCP reads the setting once, so the URL and the methods cannot disagree across a toggle. ConnectWorkspaceModal keys on both. With OAuth it keeps the sign-in path and offers a personal API token in a disclosure; with tokens only, the token steps are the path and the claim-code tab and Connected apps link are hidden, since both need OAuth. The token steps link to API Tokens in console settings (new #api-tokens anchor) and give per-client config with an Authorization: Bearer header for Claude Code, Cursor, Windsurf, VS Code and Claude Desktop (via mcp-remote). A server that predates mcp_auth gets the OAuth rendering it always had. Tests: a Go state table through the router (8 states, setup and session payloads); vitest for each mcp_auth shape and every client config; an e2e that copies the modal's URL and Cursor config, substitutes the suite's token, and gets tools/list from /mcp. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(web): connect modal codex r1 (TASK-2321) - The e2e enables MCP inside its try, so a failed readiness assertion after the PUT still turns it back off. - The client selector is a labelled group of aria-pressed toggle buttons: it declared tablist/tab with no panels or arrow-key navigation. - Claude Desktop's config runs `npx -y`, since Desktop cannot answer the install prompt on first use. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * refactor(server): the four MCP session fields share one read (TASK-2321) After the rebase onto U5 (#1666), the session and setup payloads carried mcp_available and oauth_available from their own reads of the setting beside U6's mcp_public_url and mcp_auth from sessionMCP's. sessionMCP now returns all four from one read, so a concurrent toggle cannot produce oauth_available beside ["pat"], or a URL with mcp_available false. The state-table test asserts the four agree in every state. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(server): /admin/stats reads the MCP setting once (TASK-2321, codex r4) Its mcp_available and oauth_available came from two reads, so a concurrent toggle could report OAuth available with MCP not. Both now come from one sessionMCP result, as the session payloads do. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * docs: the Connect dialog's MCP paths (TASK-2321, PLAN-2310 U6) The deployment guide (U7) now says where users find the MCP URL and the token configuration, which this PR adds to the connect modal. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
e43bd7b858 |
feat: the console MCP section; Connected Apps follows OAuth (TASK-3303, PLAN-2310 U5) (#1666)
* feat: the console MCP section, and Connected Apps follows OAuth (TASK-3303, PLAN-2310 U5) The admin Settings page gains "MCP for agents": the toggle (saved on click; disabled with "set by environment (PAD_MCP_ENABLED)" or "managed by the operator" when locked) and the GET /api/v1/admin/mcp readout: state (on, off, or blocked with the reason), public origin and where it came from, MCP URL, https, how agents sign in, and while off, what would resume (nothing was revoked). The session, setup and admin stats payloads carry mcp_available and oauth_available (per request). Connected Apps keys on oauthAvailable instead of cloud mode, and the admin MCP Audit tab is always shown to admins, with "No MCP activity recorded" as its empty state. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(web): the MCP Off state no longer overstates what the routes answer (TASK-3303, codex r1) Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * feat(web): the MCP toggle saves with an explicit Save, like the page's other sections (TASK-3303, lead ruling) Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(web): 'off by default' is a self-host fact (TASK-3303, codex r3 nit) Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
22bfe8cc89 |
feat(mcp): off-cloud Host allowlist on the non-API MCP paths (TASK-2319, PLAN-2310 U3) (#1665)
* feat(mcp): off-cloud Host allowlist on the non-API MCP paths (TASK-2319, PLAN-2310 U3) Off cloud, /mcp, /oauth/* and /.well-known/oauth-* answer 421 Misdirected Request (JSON) when Host does not name the configured origin, MCP URL or auth-server URL. Matching is case-insensitive with the port defaulted from the configured scheme; a loopback origin admits every loopback spelling on its port; an empty Host is refused. It runs after the availability gate (MCP off still answers 404 whatever the Host) and before auth, audit and the OAuth group's rate limiter. The /api/v1 routes are not wrapped: they keep the API perimeter, and the in-process dispatcher sends them an empty Host. Cloud is unchanged. mcp-go's DisableLocalhostProtection stays, with its comment rewritten. The e2e harness sets PAD_URL to its base URL. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * feat(mcp): honour X-Forwarded-Host from trusted proxies; 421 names the fix (TASK-2319 lead review) Behind a proxy that rewrites Host to the backend's name (Azure Application Gateway and App Service, some ingresses) every MCP request would get a 421. The allowlist now checks the first X-Forwarded-Host entry when the TCP peer is in PAD_TRUSTED_PROXIES, the same trust test cliAuthScheme and TrustedProxyRealIP use; from any other peer the header is ignored, so DNS-rebinding protection is unchanged. The 421 names the received host and its header, the configured origin, and the proxy fix, and the first refusal per host is logged at WARN behind one overall limiter (5, then 1 a minute; at most 1024 hosts remembered). Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 |
||
|
|
4c57d94b0d |
feat(web): the workspace tabs are real tabs (TASK-3306) (#1664)
* feat(web): the workspace tabs are real tabs (TASK-3306) Dave, day 82: the tabs inherited the old centered workspace-button layout. Now the strip is left-aligned after the logo with "+" right after the last tab. Each tab carries its own shape and background, so the close X sits inside it, and the active tab takes the page's background and sits on the bar's border. Tabs are 200px while there is room and shrink equally to 120px; past that the list scrolls, with a fade on each side that has tabs out of view, and the active tab is kept in view (the list scrolls, the page does not). Arrow keys, Home and End move focus between tabs with a roving tabindex, and the focused tab's close button is the next Tab stop. Middle-click still opens a workspace in a new browser tab (TASK-754). The row no longer runs under the collapse button and the avatar. Mobile is unchanged (it has the workspace switcher). Rulings: Dave via the lead (120px minimum, scroll with fades, keep middle-click, roving focus now). Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * feat(web): keyboard tab move; reorders sent in order (TASK-3306, codex r1) dndzone put the tab list and every tab wrapper in the Tab order, ahead of the roving tab link. Both stops are off (zoneTabIndex/zoneItemTabIndex -1). The zone's keyboard drag started only from a focused wrapper (measured: Space on the link does not reach it), so Ctrl+Shift+Left/Right on the focused tab link now moves the tab, with focus following it (lead ruling (a), with aria-keyshortcuts). tabsStore.reorder now sends reorders one at a time, in call order. The server serialises writes and the BUG-3285 revision orders their answers, but neither rejects a stale order: two reorders in flight could be processed in the other order and store the older one, which the revision then faithfully committed. Measured with the new e2e over 30 runs at 6 workers: unchained 6/30 stored the older order, chained 0/30. It covers both keyboard moves and drags, the only two callers. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(web): a queued reorder is fenced by the identity that asked; tests wait for both writes (TASK-3306, codex r2) tabsStore.reorder takes the identity fence at the call, not when the queued request runs, so an order queued before a sign-out or account switch is dropped rather than sent with the next identity's credentials. Unit tests pin call-order sending, that a failed reorder does not wedge the queue, and the identity drop; mutants of the chain and the fence each fail their test. The e2e's completion wait now requires both moves' writes sent as well as all sent answered, closing the gap between them. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(web): a new identity starts its own reorder queue (TASK-3306, codex r3) Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(web): a dropped reorder rejects, so its follow-up pin is skipped (TASK-3306, codex r4) Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
ae7f77d9d8 |
feat(mcp): construct MCP off cloud, mount always, gate per request (TASK-2317, PLAN-2310 U2) (#1663)
* feat(mcp): construct MCP off cloud, mount always, gate per request (TASK-2317, PLAN-2310 U2) The remote MCP capability (transport, in-process dispatcher and resource fetcher, audit writer, session tracker) is now constructed on every install by wireMCP, and the OAuth server, claim secret and connections backfill whenever the resolved auth-server URL is https. Nothing is served until Server.mcpAvailable() / oauthAvailable() say so, per request: every DR-5 route is mounted always and answers the JSON 404 while unavailable, before auth, audit or rate limiting. The two audit-history routes are unchanged. - Discovery takes its URLs from configuration only. The four "https://"+r.Host fallbacks and rewriteMcpSubdomain are gone; with nothing configured the documents answer 404, and the /mcp 401 is a bare Bearer realm="pad" unless OAuth is available. - resource_metadata follows RFC 9728 section 3.1, so a resource with a path (a self-host's <origin>/mcp) points at a route that exists. Byte-identical for a path-less resource (cloud). - An explicitly set PAD_MCP_PUBLIC_URL / PAD_AUTH_SERVER_URL keeps its historical spelling (TrimRight "/"), so the OAuth audience of every existing deployment is byte-identical and issued tokens keep validating. - Protected share-link image signing gets its own key field, set exactly where the claim secret used to be (cloud with PAD_MCP_PUBLIC_URL), so building OAuth on an https self-host changes nothing on share pages (lead ruling B; BUG-3305 is the deliberate fix). - The session and setup payloads keep mcp_public_url cloud-only until the connect modal gains its PAT path (DR-8, unit 6). - The claim routes answer 404 instead of 412 claim_disabled where OAuth is not available; the auth-server metadata answers 404 instead of 503. Tests: the DR-5 route table across the eight acceptance states and the upgrade test (no mcp_enabled row, https origin: every gated route 404, the audit routes unchanged, a valid dispatcher claim refused; the control leg reaches every route and the same claim succeeds), both over the production wiring; an https self-host OAuth flow for audience <origin>/mcp; a PAT call over http that writes an audit row. Release gate (PLAN-2310): must not ship without units 3, 4 and 7. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * docs(mcp): the availability gates run inside the API perimeter on /api/v1 routes (TASK-2317 codex r1) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 |
||
|
|
52d66e2684 |
test(e2e): the pane-back setup names a re-mount when its link detaches (BUG-3241 diagnostics) (#1661)
A third BUG-3241 shape (CI run 36585302307): setup's link passed toBeAttached, then scrollIntoViewIfNeeded threw "not attached", while the pane's side panels fetched in two waves for the same item. That looks like the item detail re-mounting, which would be user-visible. No local lever reproduces it (0/40 at 8 workers), so the bug stays parked, and the scroll now fails with the pane's fetches (a relation-backlinks fetch per item-detail mount, with its slug) and the route at each, plus main-frame navigations, so the next artifact names the cause. Diagnostics, not a fix. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
ac99f665f1 |
fix(web): the mobile apps link no marketing page (TASK-3299) (#1660)
Follow-up to TASK-3293 (PLAN-3291 DR-1). The getpad.dev root, blog, FAQ and contribute links are hidden in the mobile shells: the auth header wordmark becomes text, the Blog nav link and the footer's Contribute and FAQ are filtered, the error page's "Back to getpad.dev" is gated, and the share page's "Powered by" loses its link. Legal and docs links stay (lead ruling): the privacy policy must be reachable in the app. A vitest classifies every getpad.dev address as kept or gated, so a new one fails until someone decides; a gated one's file must read the gate in code, not in a comment. The sibling TASK-3293 guard gets the same code-only reading. The shell-UA e2e covers /login and /register with a browser control. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
1ed52bdb4c |
fix: no commerce in the mobile apps (TASK-3293, PLAN-3291 U1, BUG-3290) (#1658)
* fix(server): plan-limit refusals to a mobile shell name no upgrade destination (TASK-3293) planLimitDetails and planLimitMessage take the request, so every door that renders a plan-limit refusal (single 403, bulk per-item failure, bundle import) has to say who it is answering. A request carrying the PadShell/ user-agent marker gets no upgrade_url; everything else is unchanged. PLAN-3291 DR-1/DR-3. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(web): no upgrade copy or billing links in the mobile apps (TASK-3293, BUG-3290) authStore.nativeShell reads the PadShell/ user-agent marker both shells append, and authStore.commerceAllowed (cloud mode and not a shell) is the one gate every billing surface reads (PLAN-3291 DR-2/DR-3). All thirteen plan-limit toasts go through showPlanLimitToast, which adds the upgrade copy and link only where commerce is allowed; the client default is neutral; the Billing menu, sheet and console-nav links and the settings card's buttons are hidden in the app; /console/billing there shows the plan and its limits only. A vitest guard pins the population to the gated files. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * test(e2e): shell-UA pass finds no commerce, browser control finds it (TASK-3293, PLAN-3291 DR-7) Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * test: re-pin the gates this unit's edits moved (TASK-3293) The BUG-2803 text-safe scan now counts fromNativeShell's requestUserAgent call (it reads the UA to choose copy and binds nothing). The TASK-3097 identity gates for roles, settings and [collection] hash the top-level import each unit references, so replacing planLimitMessage with showPlanLimitToast in that import moved every unit that uses api. The only other change in those pages is a synchronous toast call inside existing catch arms: no await, write or check moved. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix: the app drops checkout-return states and the invitee's upgrade clause (TASK-3293, codex r1) /console/billing?checkout=success|cancelled ran the web-checkout result flow in the app, with payment banners and a support mailto; it now clears the query unread there, and the e2e visits both returns. The invitee's member-limit refusal (BUG-3098) is its own message and said "upgrade their plan" to a shell; it now takes the request like the plan-limit helpers. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * test: pin the account menus' Billing link under commerceAllowed (TASK-3293, codex r2) Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * test(e2e): install the session patch before login (TASK-3293, codex r3) Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
3d13df619f |
chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 7 updates (#1634)
Bumps the npm-minor-and-patch group with 7 updates in the /web directory: | Package | From | To | | --- | --- | --- | | [dompurify](https://github.com/cure53/DOMPurify) | `3.4.15` | `3.4.16` | | [yjs](https://github.com/yjs/yjs) | `13.6.32` | `13.6.33` | | [@sveltejs/vite-plugin-svelte](https://github.com/sveltejs/vite-plugin-svelte/tree/HEAD/packages/vite-plugin-svelte) | `7.3.0` | `7.3.1` | | [marked](https://github.com/markedjs/marked) | `18.0.13` | `18.0.14` | | [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.57.0` | `5.57.1` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.0` | `8.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.1` | `5.0.2` | Updates `dompurify` from 3.4.15 to 3.4.16 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.15...3.4.16) Updates `yjs` from 13.6.32 to 13.6.33 - [Release notes](https://github.com/yjs/yjs/releases) - [Commits](https://github.com/yjs/yjs/compare/v13.6.32...v13.6.33) Updates `@sveltejs/vite-plugin-svelte` from 7.3.0 to 7.3.1 - [Release notes](https://github.com/sveltejs/vite-plugin-svelte/releases) - [Changelog](https://github.com/sveltejs/vite-plugin-svelte/blob/main/packages/vite-plugin-svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/vite-plugin-svelte/commits/@sveltejs/vite-plugin-svelte@7.3.1/packages/vite-plugin-svelte) Updates `marked` from 18.0.13 to 18.0.14 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v18.0.13...v18.0.14) Updates `svelte` from 5.57.0 to 5.57.1 - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.57.1/packages/svelte) Updates `vite` from 8.3.0 to 8.3.1 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.3.1/packages/vite) Updates `vitest` from 5.0.1 to 5.0.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.2/packages/vitest) --- updated-dependencies: - dependency-name: "@sveltejs/vite-plugin-svelte" dependency-version: 7.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: dompurify dependency-version: 3.4.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: marked dependency-version: 18.0.14 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: svelte dependency-version: 5.57.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: vite dependency-version: 8.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: vitest dependency-version: 5.0.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: yjs dependency-version: 13.6.33 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
237333681b |
feat(attachments): attach binds an unattached upload to an item, so its share renders it (TASK-2247) (#1652)
The July audit's share-page broken-image finding was fixed by BUG-2389 and
TASK-2637: the share byte endpoint serves variants of attachments the
shared item OWNS. What was left: an image uploaded with
`pad attachment upload -` (item_id NULL) and then embedded is never owned,
so its share shows the placeholder. A census of this workspace found 4
such refs out of 13, all from that path.
Lead ruling (option C): fix it explicitly, with no implicit binding on
content writes.
- POST /workspaces/{ws}/attachments/{id}/attach {item}. It binds a live,
unattached original in this workspace, and its live variants, to an
item. Every UPDATE requires item_id IS NULL, so an attached row is never
moved, and the bind is exclusive with the never-attached GC claim. The
item is pinned like CreateAttachmentForLiveItem.
- Authorization: the caller can see and edit the item, and uploaded the
attachment or is a workspace owner. An attached row answers 409 to a
caller who can see its owner, and the shared 404 to anyone else.
Restricted members and guests cannot read unattached rows, so they get
the 404. That closes the case where a guest binds an id they know to an
item they can edit and publishes it through its share.
- The attachment_attach capability. `pad attachment attach <id> <item-ref>`
refuses against a server that does not advertise it, because an older
build's bare 404 would read as "attachment not found".
- The `upload -` help text, the attachment group help, both skill copies
and CLAUDE.md say an unattached image does not render on share pages.
- No MCP surface: pad_attachment has no upload.
Tests: 12 server/store tests, including an end-to-end share leg (404
before, variant bytes after), and 9/9 mutants killed. CLI capability
gate: 2 tests, mutant killed. e2e: the share renders the image after
attach; with attach made a no-op it goes red at "attached: no
placeholder".
Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
|
||
|
|
bd3e79ef3a |
fix(tabs): order workspace-tab lists by a per-user server revision (BUG-3285) (#1649)
* fix(tabs): order workspace-tab lists by a per-user server revision (BUG-3285) Every tab write bumps users.workspace_tabs_revision under lockUserTabsTx and answers with the list as it left it, revision and rows read in one statement inside the write's transaction. The web store commits a list only when its revision is not below the committed one, so two requests processed in the other order than they were sent can no longer put an older list in the bar. Background writes (route PATCH, pin) commit again and the confirmedRoutes overlay is removed. Close now takes the per-user lock; member removal, grant revoke, soft delete and account deletion bump the users whose rows they delete, users row first. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * fix(tabs): account deletion does not bump holders (lock order); lock-order tests (BUG-3285) * test(tabs): drop the cross-account-deletion probe; the deadlock it found is pre-existing (BUG-3285) * docs(tabs): known-limit note in the handler; CLAUDE.md account-deletion wording (BUG-3285) * fix(tabs): prune callers take users(U) before their first write (BUG-3285, codex r2) Codex round 2 (P2): under Postgres READ COMMITTED the prune's conditional bump and its DELETE could see different tab rows, so an open committing between them was deleted without a bump. Taking lockUserTabsTx first in every prune caller (member removal x2, grant revokes x3) keeps every tab write for U out for the whole transaction. It also fixes a deadlock this branch had introduced: a member removal racing that member's own account deletion (40P01 on PG before, pass after). Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * fix(tabs): soft delete locks its tab holders before the workspaces row (BUG-3285, codex r3) Codex round 3 (P2): account deletion takes users(D) then D's owned workspaces rows; soft delete took the workspaces row then its holders' users rows, D among them, so the two could deadlock on Postgres (measured 40P01 in TestWorkspaceTabs_SoftDeleteVsOwnerAccountDeletion, round 2). Soft delete now locks the holders first; every transaction that locks a users row takes it before the other rows it writes. The IN-list helper is gone with the account-deletion bump it existed for. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * fix(tabs): an open refuses a soft-deleted workspace under a row lock (BUG-3285, codex r4) Codex round 4 (P2, the shape predates this branch): an open that committed its insert after a soft delete's DELETE left a tab on the deleted workspace, which a restore would bring back. OpenWorkspaceTab now reads the workspaces row FOR SHARE (conflicts with the soft delete's NO KEY UPDATE) and requires deleted_at IS NULL; a loser answers sql.ErrNoRows and the handler serves the workspace 404. Lock order is unchanged: users(U) first, then the workspaces row. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 |
||
|
|
c3bf60da2a |
test(e2e): the rename-heal guards and the pane-back setup name what happened when they fail (BUG-3243, BUG-3241 diagnostics) (#1651)
Both flakes fail with messages that cannot name a cause. BUG-3243's vacuity guard read "Received: false". BUG-3241's setup read "pane-back-btn not visible". Each needs evidence from a CI occurrence (via the playwright-flaky-evidence upload, BUG-3278), and the message is the cheapest part of it. - bug-2601-sync-rename-heal: routeRecorder logs main-frame navigations and the events/changes/collections traffic, stamped relative to the rename. All four stranded-route guards in both tests print the log and the path the route moved to. - bug-2182-pane-back-scroll: when Back never appears, setup throws with the popover's hrefs, the URL before and after the click, and whether the pane, the sidebar row and the popover are present. That separates drilled, navigated, and swallowed. Diagnostics, not fixes. Both bugs stay open-parked. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
e78e06bd56 |
BUG-3278 diagnostics: self-describing move-menu steps + flaky-run evidence upload (#1648)
* test(e2e): the move walk's card-menu steps fail in seconds naming what the menu rendered (BUG-3278 diagnostics) A card's action menu HIDES a Move entry its position rules out, so a bare getByRole click on an absent entry waited out the whole 30 s test budget and reported only the name it waited for. That is BUG-3278's CI failure, which cannot say whether the menu never opened or opened without the entry. clickMenuEntry asserts the menu opened, then that the entry rendered, and otherwise throws naming the rendered entries and the lane the entry was chosen from, both then and now. Applied at all four trigger-then-menuitem sites in the spec. Diagnostics, not a fix: the flake is not reproduced locally, and two candidate mechanisms (a lane re-sort after placement, a card remount) were probed and not observed. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * ci: upload Playwright evidence for flaky runs (BUG-3278) A test that fails and then passes on retry leaves the job green, so the failure()-gated report upload never runs and the flake ships no screenshot, trace or video, even though retain-on-failure kept them in web/test-results. Upload that directory as playwright-flaky-evidence on a green run, only when a failed attempt left a trace.zip. Retention is 7 days. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
e7811c5c69 |
fix: registering from an invitation link lands in the joined workspace on an ephemeral tab (BUG-3284) (#1647)
* feat(auth): a signup with an invitation code names the workspace it joined, through the accept doors' one helper (BUG-3284) * fix(web): registering from an invitation link lands in the joined workspace on an ephemeral tab, re-reading the session first (BUG-3284) * fix(auth): the signup's accepted_invitation carries the invited role through the helper's new role argument (BUG-3284, rebase onto BUG-3281) * fix(web): the join page re-reads the session once more when the joined read predates its sign-in (BUG-3284, codex r1) |
||
|
|
ba75b6f10a |
feat(web): landings open an ephemeral workspace tab, a write keeps it, and last_route lives only on the tab row (TASK-3279, PLAN-3002 U5) (#1646)
* wip(web): landings open ephemeral tabs (TASK-3279) * test(web): landing, pin-on-write and retired-fallback coverage (TASK-3279) * test(e2e): a landing per site opens an ephemeral tab; a write and Keep open keep it (TASK-3279) * fix(web): a setCurrent miss refreshes the workspace list; retire stale fallback prose (TASK-3279) * refactor(web): the workspace-write registry is its own module, so client mocks stay whole (TASK-3279) * revert(web): leave the review-hashed ItemDetail and settings Undo untouched; the setCurrent refresh covers the Undo's list (TASK-3279) * fix(web): a write reports only to the identity that issued it; a failed list refresh can retry (TASK-3279, codex r1) * fix(web): a landing decides against the committed tab list, never blind, so it cannot reopen a tab closed meanwhile (TASK-3279) * fix(web): route saves and write-triggered pins update their own row and never replace the tab list, so one processed before a close cannot resurrect the closed tab (TASK-3279) * fix(web): the session's own saved route stays ahead of the row, so a list processed before the save cannot restore the old route (TASK-3279, codex r3) * fix(web): a confirmed route is pruned once its row is gone or has caught up, so it cannot mask a newer route after a close and reopen (TASK-3279, codex r4) * docs(web): name the confirmed-route tradeoff and its fix (BUG-3285) (TASK-3279, codex r5) |
||
|
|
918a0164c7 |
feat(web): mobile switcher lists the open set first (TASK-3283, workspace tabs U6) (#1643)
* feat: order mobile workspace switcher by open tabs (TASK-3283) * fix(web): mobile switcher shows the plain list until the open set loads (TASK-3283) Before tabsStore loads, every workspace looks not-open, and a tap would pin it as a durable tab. Claude-Session: https://claude.ai/code/session_01QTTW1HgsMVoKVajug4y6HY * test(web): mobile switcher before the open set loads, and an open tab only navigates (TASK-3283) |
||
|
|
115abb9336 | refactor(web): TopBar closeTab lands through tabLanding, the one copy of the Q3 rule (TASK-3280) (#1642) |