Commit Graph

1089 Commits

Author SHA1 Message Date
xarmian 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
2026-10-06 10:49:45 -04:00
dependabot[bot] 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>
2026-10-06 00:40:14 -04:00
xarmian 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
2026-10-05 23:38:37 -04:00
xarmian e62188ed15 docs: fresh 2x product screenshots (dark and light) and a tightened README (TASK-3426) (#1816) 2026-10-05 19:03:24 -04:00
xarmian 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)
2026-10-05 18:44:09 -04:00
xarmian 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
2026-10-05 15:52:54 -04:00
xarmian 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
2026-10-05 10:21:28 -04:00
xarmian 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)
2026-10-05 10:15:06 -04:00
xarmian 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)
2026-10-05 07:50:11 -04:00
xarmian 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)
2026-10-05 07:28:37 -04:00
xarmian 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)
2026-10-05 06:09:12 -04:00
xarmian 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
2026-10-05 04:50:02 -04:00
xarmian 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
2026-10-05 04:49:25 -04:00
xarmian 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)
2026-10-05 03:53:12 -04:00
xarmian 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
2026-10-05 02:29:48 -04:00
xarmian 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)
2026-10-04 19:22:21 -04:00
xarmian 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)
2026-10-03 19:41:18 -04:00
xarmian 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.
2026-10-03 10:17:27 -04:00
xarmian 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
2026-10-03 09:48:19 -04:00
xarmian 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.
2026-10-03 09:19:10 -04:00
xarmian 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
2026-10-03 07:59:37 -04:00
xarmian 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
2026-10-03 05:18:30 -04:00
xarmian 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
2026-10-02 23:32:54 -04:00
xarmian 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
2026-10-02 22:41:29 -04:00
xarmian 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
2026-10-02 20:48:37 -04:00
xarmian 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 c383e89c: task-2979 (admin tab wrap) and task-3121 (decision
provider settings) open /console/admin in a page authenticated with the
suite's PAT, so the console's own /api/v1/admin calls are now refused.
Those two specs, the only ones that open the admin console, set the page
context's credential to fixture.adminSessionToken, and task-3121's
direct admin calls use it too.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 19:57:23 -04:00
xarmian 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
2026-10-02 18:34:56 -04:00
xarmian 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.
2026-10-02 17:20:07 -04:00
xarmian 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
2026-10-02 15:43:57 -04:00
xarmian 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>
2026-10-02 08:22:41 -04:00
xarmian 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
2026-10-01 20:05:42 -04:00
xarmian 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
2026-10-01 00:10:26 -04:00
xarmian 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
2026-09-30 22:50:45 -04:00
xarmian 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)
2026-09-30 20:41:55 -04:00
xarmian 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)
2026-09-30 16:49:25 -04:00
xarmian 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)
2026-09-30 16:49:09 -04:00
xarmian 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
2026-09-30 16:47:53 -04:00
xarmian 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>
2026-09-30 16:16:51 -04:00
xarmian 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)
2026-09-30 14:58:39 -04:00
xarmian 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
2026-09-30 11:03:07 -04:00
xarmian 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)
2026-09-30 10:02:41 -04:00
xarmian 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)
2026-09-30 09:34:38 -04:00
xarmian 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
2026-09-30 08:52:35 -04:00
xarmian 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)
2026-09-30 08:03:49 -04:00
xarmian 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, `&gt;` 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 `&gt;` 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
2026-09-30 07:19:04 -04:00
xarmian 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
2026-09-30 06:19:34 -04:00
xarmian 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 c7fa1931 (specified sha256-iEqv…,
got sha256-P1bQ…), so main does not depend on the heal job after merge.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 02:50:10 -04:00
xarmian 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
2026-09-30 01:17:21 -04:00
xarmian 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
2026-09-30 00:41:20 -04:00
xarmian 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
2026-09-29 18:15:34 -04:00