mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
d39cb9f00a150f19bd2a6f04420617613afab01f
1089 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d39cb9f00a |
fix(web): the selected tab is marked by an accent, not a background step (BUG-3420) (#1829)
In dark mode the active workspace tab was hard to tell from the others: its background is the page colour (the tab flows into the page), which measured 1.07:1 against the strip and its neighbours (1.09:1 in light), with the same text colour and weight. It now carries a 2px accent line along its top edge (inset box-shadow): 4.85:1 / 5.20:1 in dark, 5.70:1 / 5.24:1 in light, against the strip and the tab. A survey of every selected-state control with siblings, across 12 pages in both themes, found three tab-like segmented toggles failing the same way (background steps of 1.05-1.16:1): the item pane's Rich / Markdown toggle, the tag page's view toggle and the Insights window selector. Each selected segment gets a 1px inset accent ring (>= 4.47:1). Underline tab bars (item pane, settings, library, admin) already met 3:1 and are unchanged. The e2e pins the requirement for the tab bar in both colour schemes: the active tab's indicator reaches 3:1 against an inactive tab and the strip. Red on the old build (no indicator) and on a mutant drawing the line in the border grey. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
28cc004686 |
chore(deps)(deps): bump source-map-js from 1.2.1 to 1.2.2 in /web (#1819)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2. - [Release notes](https://github.com/7rulnik/source-map-js/releases) - [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2) --- updated-dependencies: - dependency-name: source-map-js dependency-version: 1.2.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
b454f17928 |
build(web): move the SvelteKit and Svelte config into vite.config.ts (TASK-3423, PR A) (#1823)
SvelteKit 3 refuses svelte.config.js, and kit 2.62+ already takes its config
through sveltekit({...}), so the move lands on kit 2.70.3 first. Kit's own
keys (adapter) stay with kit; dynamicCompileOptions is forwarded to
vite-plugin-svelte.
The vitest jsdom project used a bare svelte(), which got runes mode only by
loading svelte.config.js itself. Both configs now import dynamicCompileOptions
from one module, svelteCompileOptions.ts, and the jsdom plugin passes
configFile: false. Deleting the file without that would have dropped runes
mode from component tests silently.
Proofs:
- runesMode.svelte.test.ts: importing a component with a legacy $: statement
is refused in the jsdom project; red without the vitest change.
- svelteCompileOptions.test.ts: resolving the real vite.config.ts yields
vite-plugin-svelte options carrying the forcing ({runes: true} for ours,
undefined for node_modules) and configFile false; red without the forward.
The unused @sveltejs/adapter-auto is removed.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
e62188ed15 | docs: fresh 2x product screenshots (dark and light) and a tightened README (TASK-3426) (#1816) | ||
|
|
73fd8d5111 |
test(e2e): assert each list-navigation step on its own (BUG-3204) (#1815)
* test(e2e): assert each list-navigation step on its own, with a named expected card (BUG-3204) * test(e2e): key cursor steps by slug and accept a row that arrives mid-step (BUG-3204, codex r1) * test(e2e): the return step compares against the card read just before its press (BUG-3204, codex r2) |
||
|
|
f0c65fe08c |
chore(web): adopt Tiptap 3.31.4 and regenerate the materializer corpus (TASK-3421) (#1811)
* chore(web): adopt Tiptap 3.31.4 and regenerate the materializer corpus (TASK-3421) 3.31.4's TableCell/TableHeader renderHTML stop rendering a default colspan="1" / rowspan="1". parseHTML is unchanged, so a stored body carrying them parses to the same document. Replaying all 50 old corpus op-logs through the 3.31.4 bundle reproduces the old output exactly once those attributes are removed (and fails on 28 cases without removing them). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(web): pin that a pre-3.31.4 table body still dedupes on open (TASK-3421) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
78f3c08240 |
test(e2e): a real installed app, end to end over https (TASK-3415) (#1804)
* feat(e2e): a test-build-only door for the app origin's CA (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): a real install end to end over an https front and app origin (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): the install review waits for a real manifest fetch (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore: ignore the e2etest pad binary (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): discovered https endpoints, awaited teardown, OS-assigned ports, an explicit timeout (codex r1 on TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): teardown survives a spawn failure, pad's port retries, the front drops hop-by-hop headers, cert setup cleans up (codex r2 on TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
8b8124ab5c |
feat(apps): item-pane link-out for installed apps' item actions (TASK-3413, SPEC-6 U9d) (#1794)
* feat(apps): the item pane's app actions: one link-out button per action, the tab opened inside the click with no opener, only web URLs followed, refusals and sign-outs close it (TASK-3413, SPEC-6 U9d) * test(apps): e2e for the item action: new tab with no opener at the minted URL, a refused mint closes it, no button without actions (real server) (TASK-3413, U9d) * fix(apps): an item action whose pane moved on before the mint answered closes its tab; the referrer the app sees is documented (TASK-3413, U9d codex r1) * fix(apps): the item action fences with authStore.identityFence() and joins ItemDetail's reviewed child population; its tab cleanup is classified (BUG-3095 guard; TASK-3413, U9d) |
||
|
|
83ca50f861 |
fix(apps): the apps UI tolerates null lists from the server (BUG-3417, client half) (#1795)
* fix(apps): every list the apps UI reads is made an array where its response lands, so a null list from the server no longer crashes the install or upgrade review (BUG-3417) * test(apps): a changed artifact with null lists in the upgrade review (BUG-3417) |
||
|
|
d5c2ecfe08 |
feat(apps): 'via <App>' attribution on items, comments and history, and the members Apps section (TASK-3413, SPEC-6 U9c UI) (#1793)
* feat(apps): the 'via <App>' label on items, comments, replies and history, history keeping a person's own edits apart from their edits through an app, and the members list's separate Apps section (TASK-3413, SPEC-6 U9c) * test(apps): the via <App> label on comments and replies, and history's writer rules with an app (TASK-3413, U9c) * test(apps): history labels writes through an app and keeps them apart from direct edits (TASK-3413, U9c mutant V5) * test(apps): e2e for the via <App> label on an item and its comment, and the members Apps section, real rows with the app fields added through route.fetch (TASK-3413, U9c) * fix(apps): app names are bidi-isolated in the Members section and wrap in every via label (TASK-3413, U9c codex r1) * test(apps): the long-name leg checks each via label ends inside the viewport; the page-width check could not fail, an ancestor clips (TASK-3413, U9c) * fix(apps): WorkspaceApp gets its own import line, so the identity gate re-reviews only load() and handleInvite(), whose new apps writes sit beside members behind the same checks (TASK-3413, U9c) |
||
|
|
9ae4a0be45 |
feat(apps): upgrade review and draft activation in Settings → Apps (TASK-3413, SPEC-6 U9b) (#1789)
* feat(apps): the install view lists the items its pack provisioned (source_pack = origin@version), for draft activation (TASK-3413, SPEC-6 U9b) * refactor(apps): the per-artifact review is its own component, shared by install and upgrade (TASK-3413, U9b) * feat(apps): the upgrade review (diff by review/auto, released companions, new drafts, the reviewed hash) and the drafts panel with per-item activation (TASK-3413, SPEC-6 U9b) * test(apps): each upgrade diff line sits in exactly one group (TASK-3413, U9b mutant U2) * test(apps): e2e for the upgrade review and draft activation through page.route (TASK-3413, U9b) * fix(apps): Activate addresses the item by id, not slug; the origin prefix is compared in characters; the one-live-install-per-origin rule is documented (TASK-3413, U9b codex r1) * fix(apps): Activate moves focus to its row and announces it; the draft list retries after a failure; the upgrade's loading line is a status (TASK-3413, U9b codex r2) * fix(apps): activation moves focus only when it was lost, and to the panel heading when the row is gone (TASK-3413, U9b codex r3) * test(apps): an app token, service or a delegated owner's, cannot activate its own draft artifact (§2 step 7; TASK-3413, U9b lead ruling) |
||
|
|
48c3ca066c |
fix(test): test temp dirs stop leaking into /tmp (BUG-3412) (#1786)
* fix(test): template and fixture temp dirs no longer leak into /tmp (BUG-3412) The store test templates are named for the owning pid, and each build first removes the template dirs of processes that are gone (and pid-less legacy dirs over a day old), so a test binary killed before TestMain's Cleanup no longer leaves its template on the tmpfs for good. The fence source tests remove their fixture dirs when the file finishes. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(test): never sweep pid-less template dirs; age proves no owner gone (codex r1 on BUG-3412) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
41be1bf113 |
feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) (#1787)
* feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) Items (the creating app), comments and version rows carry via_app (the install id) and via_app_name (the bot's name, else the origin; kept after uninstall) on the web API, the MCP get / list summary / history summary projections, and the TS types, with WorkspaceApp naming the members apps array. ToolSurface 0.65, additive. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): attribution on search, /changes, playbook show and restore; chunked lookup (codex r1 on U9c) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): attribution on the role board, a comment edit's response and the account export (codex r2 on U9c) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs: README tool catalog at v0.65 (TASK-3413) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
ede0c22fc2 |
feat(apps): Settings → Apps: install review, install-code handoff, and lifecycle controls (TASK-3413, SPEC-6 U9a) (#1785)
* feat(apps): GET /workspaces/{ws}/apps lists the workspace's installs for the owner, and says when apps are off (TASK-3413, SPEC-6 U9a)
* feat(apps): Settings → Apps: the install list, the review and install-code handoff, and disable/rotate/re-enable/uninstall with their consequences (TASK-3413, SPEC-6 U9a)
* test(apps): e2e for Settings → Apps (apps-off against the real server; install, handoff, disable, re-enable through page.route), the Apps tab in the permission walk, and the owner tab count 5 → 6 (TASK-3413, U9a)
* fix(apps): rotate's copy says the webhook waits for the new code too (TASK-3413, U9a codex r1)
* fix(apps): focus follows each step: the code panel, the install heading after an action, the opening button on cancel, the app card on back (TASK-3413, U9a codex r2)
* fix(apps): an install_state refusal moves focus to the install heading (TASK-3413, U9a codex r3)
* test(apps): the refusal focus test focuses the confirm button first, as a real click does, so it fails without the fix (TASK-3413, U9a)
|
||
|
|
82c45f24cd |
feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) (#1784)
* feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) [wip] Item actions are written as app_item_actions rows at provisioning, kept in step by a reviewed upgrade (a changed action gets a new revision, a removed one is retired), deleted at uninstall and backfilled at start. A viewer's click mints a single-use, two-minute context code; the app redeems it with its service token. Mint and redeem take the install, item and action rows in that order (FOR SHARE on Postgres), re-check the install, the item's current companion collection, the action's revision and the viewer's current visibility (the human item read's rule), and refuse with one 404 body. Redeem consumes the code first. The frozen TS type ItemAppAction and api.items.appActions / mintAppAction ship for U9. Migrations 125 / pg 099. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U11 mint, redeem, refusal and revocation tests; redeem in the read census; NUL baseline (TASK-3414) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): invalidating mutations wait for a redeem holding the section 6 locks (TASK-3414) Deterministic: a hook pauses the redeem with its locks held while disable, an upgrade's action change, a move or a delete is started; each stays blocked until the redeem commits. On Postgres, dropping the install lock lets disable through, dropping the item lock lets move and delete through. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U11 mutant kills: epoch, deactivation, companion moves, release, admin non-member, backfill and workspace guards (TASK-3414) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): redeem at the token's workspace, service tokens only; no pool reads under the context locks; normalized base URL (codex r1 on U11, TASK-3414) Redeem moves to POST /api/app/v1/context/redeem (§6's path), outside /workspaces/{ws}: the workspace is the token's, injected before the same per-route stack, so no workspace-path refusal can answer before the code is consumed. It refuses a delegated token with the same 404. The viewer re-admission reads the user, role, membership and grants on the mint or redeem transaction (GetUserQ, ViewerRoleQ, UserHasGrantsInWorkspaceQ). A one-connection pool runs mint and redeem for a full member, a restricted member and a guest without hanging; switching any of those reads back to the pool deadlocks it. The link-out URL is built from the validated origin, refused before a code is written when base_url does not normalize. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): a delegated redeem attempt consumes the code before it is refused (codex r2 on U11, TASK-3414) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): build the context code expiry from its constant (lint, TASK-3414) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
7396ec42bb |
feat(oauth): delegated sign-in for installed apps through the issuance barrier, shipped closed (TASK-3399, SPEC-6 U5b-1) (#1779)
* feat(oauth): delegated install grants through the issuance barrier: code, PKCE, access and refresh for a live human member, the consented access bound and capped by the manifest (TASK-3399, U5b-1) * feat(oauth): delegated app sign-in: a fixed-workspace consent page, code+PKCE for install clients, the person's access choice in the grant, and an ownership check before fosite on code and refresh (TASK-3399, U5b-1) * feat(oauth): app grants are not MCP connections: the readers skip install chains, the console lists and revokes them through the binding, the sweep clears orphan bindings, and revoking a person reaches their delegated codes (TASK-3399, U5b-1) * test(server): the console lists an app grant on its own, refuses every connection mutation on it, and revokes it through the binding (TASK-3399, U5b-1) * test(server): the console revoke ends an app grant through its binding (TASK-3399, U5b-1) * feat(web): Connected apps lists installed apps acting as you, read-only plus revoke (TASK-3399, U5b-1) * test(oauth): pin the binding's person and a resource-less sign-in; name the fosite layers behind the consent gate's PKCE and audience checks (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r1: a delegated binding remembers the person's credential epoch and membership and is tombstoned on revoke; issuance share-locks the person on Postgres; app sign-in and grant management work with MCP off (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r2: only a delegated grant's code writes its binding; member removal, disable and claim end a person's delegated grants through their installs; users before installs before tokens in every path (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r3: delegated revocation takes no install lock (the person's row orders it against issuance) and writes child rows in teardown's order, closing two deadlocks with owner-account deletion (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r4: install teardown and a person's delegated revocation lock their bindings in request_id order before any child row (TASK-3399, U5b-1) * feat(store): bounded retry on a Postgres deadlock for delegated-grant revocation, member removal, account disable and claim, and the OAuth sweep, with a per-site metric (TASK-3399, U5b-1, lead ruling (a)) * fix(oauth): U5b-1 codex r6: the console does not list an app grant the install's epoch has moved past (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r7: a consent carries the person's credential epoch and the barrier refuses it after a disable/re-enable; the console lists a grant not yet exchanged (TASK-3399, U5b-1) * feat(oauth): delegated app sign-in stays closed (access_denied, not yet available) behind one named guard until U5b-2 opens it with the app API (TASK-3399, U5b-1, lead ruling) * fix(oauth): U5b-1 codex r8: a consent carries the install epoch and membership its gate read, so a disable/re-enable or removal/re-add after the gate refuses the code (TASK-3399, U5b-1) * fix(oauth): U5b-1 codex r9: a delegated grant needs a live workspace, at the consent gate and the barrier (TASK-3399, U5b-1) * docs(oauth): name the accepted one-second residual of the consent's membership check (TASK-3399, U5b-1) * test: census the delegated consent's form readers, the app-grant revoke's user agent, and four binding columns that carry no caller text (TASK-3399, U5b-1) |
||
|
|
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 |