mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 05:49:50 +00:00
4532eecffdeac4eeff1cd159ff1353df1ccac07b
1113 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b8c9b0ea6b |
fix(web): the import dialog shows progress, stops on a stall, and says what was kept (BUG-3475) (#1869)
* fix(web): the import dialog shows progress, stops on a stall, and says what was kept (BUG-3475) The dialog sat on 'Importing...' forever when the upload stalled: the upload was a bare fetch, with no progress and no way to notice a stall. - The upload goes over XMLHttpRequest (lib/api/importUpload.ts): bytes sent of total, then 'Finishing the import...'. No upload progress for 60s, or no response 120s after the last byte, ends it; 'Stop upload' and closing the dialog cancel it. Both bounds are pinned against the server's 60s per-Read idle window, read from the Go source. - Each attempt carries a fresh import key (getRandomValues: randomUUID needs a secure context, and http on a LAN IP is not one). When no response arrives, the dialog asks GET /workspaces/import-status for up to 75s and says exactly one of: finished (Open workspace), partial workspace kept (Open workspace), nothing kept (Try again), or unknown (check your workspace list). An unknown key is never 'nothing was kept'. - A new open of the dialog ends any earlier operation, so a late outcome cannot paint onto it; it is closed through the store too, which close() never sees. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): a failed import upload is resolved through import-status (BUG-3475) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): import dialog fences Open workspace, closing stops the outcome poll, callbacks cannot wedge the upload (BUG-3475) codex r1 on the web half. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): Open workspace does not navigate after the dialog was closed (BUG-3475) codex r2 on the web half. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
84ddb3f693 |
fix(web): the list cursor no longer snaps back while the pane follows j/k (BUG-3204) (#1863)
* test(e2e): a list change while the pane follows j does not snap the cursor back (BUG-3204)
* fix(web): a list change while the pane follows j/k no longer snaps the cursor back (BUG-3204)
The collection route keeps the row highlight on the open pane's item: an
effect sets the cursor to ?item='s row whenever the list changes. j/k move the
cursor at once and the pane follows 140ms later, so a list change inside that
window snapped the cursor back to the old item, and the follow snapped it
forward again: a visible flicker, and attachment-viewer-owners' owner-2 flake
(CI: alpha, alpha, BRAVO, alpha after one j).
While a follow is pending the cursor is the newer intent, so the effect
yields; the follow's own ?item= change re-runs it.
Lever (e2e/bug-3204-cursor-snapback.spec.ts holds the /items-changes fetch a
rename triggers until j is pressed): main 10/10 fail, this 20/20 pass.
* fix(web): the cursor follows a reactive pending target, and a click supersedes a pending follow (BUG-3204, codex r1)
Codex r1: the first fix gated the snap-back on the non-reactive timer handle,
so a follow that ended without navigating (target deleted, already open)
left the cursor blank with nothing to re-run the effect, and a click during
the debounce was overridden by the follow.
pendingFollow {id, fromRef} is $state. While set, the snap-back targets that
row by id. It clears when ?item= reaches the row, when the follow opens
nothing, or when ?item= moves by another door, which also cancels the follow.
The spec freezes the page clock and holds the list fetch that carries the
change, so each case is deterministic. Main: case 1 (snap-back) and case 3
(click overridden) fail 10/10, and case 2 passes. Fix: 180/180 at 8 workers.
* fix(web): pendingFollow is per-session state; the follow timer keeps its identity fence first (BUG-3204)
The collection identity gate caught both: a new $state the identity reset
did not clear, and a timer whose identityHeld check had moved into a helper.
resetPerSessionState clears pendingFollow; the timer checks identityHeld
before anything else; the gate rows are re-reviewed.
* fix(web): a follow the controller would drop, or that never lands, releases its pending target (BUG-3204, codex r2)
follow() reports false when a pane navigation is in flight, since the
controller silently drops the open then. A follow that navigates arms a
fenced settle timer that drops a target still pending after 1s, covering a
goto superseded without ?item= moving. The identity gate's timer inventory
and rows are updated. Case 2 of the spec now claims only what it exercises.
* fix(web): the settle check shares the follow's timer handle, so teardown clears it (BUG-3204, codex r3)
|
||
|
|
412ea6f568 |
test(e2e): sidebar-footer-row waits for Settings before measuring the row (BUG-3472) (#1865)
The Settings button renders only once workspaceStore.current has loaded
({#if wsSlug && !isGuest}). The test waited for the footer row, which renders
earlier, and measured a row missing Settings when the workspace GET was slow.
Wait for the button the assertions are about.
Lever: delaying the workspace GET 2.5s failed the old wait 3/10 (Settings
absent, []) and passed the new one 10/10.
|
||
|
|
d6f1a06508 |
test(e2e): task-3413 handlers tolerate teardown closing the context mid-fetch (BUG-3471) (#1864)
* test(e2e): task-3413 route handlers tolerate teardown closing the context mid-fetch (BUG-3471) The route handlers fetch, read JSON and fulfil. When the test ends while one is in flight, teardown closes the context and Playwright disposes the fetched response, so apiResponse.json() throws 'Response has been disposed' and the test fails after its assertions passed. Swallow only that abandoned-request family; any other error still fails the test. Lever: closing the context mid-handler fails the old handler 3/3 with CI's exact error and passes the guarded one 3/3. * test(e2e): guard the third task-3413 handler; tolerate only the two teardown messages (BUG-3471) Codex: the long-name test's handler had the same fetch/json/fulfil race, and 'Route is already handled' means two handlers raced, a real defect, not an abandoned request. Re-measured with the narrowed pattern: context close fails the old handler 5/5 and passes the guarded one 5/5. |
||
|
|
e95135c499 |
fix(web): links to the /-/r/ resolver open their target (TASK-3119) (#1862)
* fix(web): links to the /-/r/ resolver open their target (TASK-3119)
/-/r/{ws}/{ref} is a server redirect route with no client route behind it.
Three doors handed it to the SvelteKit router, which rendered it as
[username]=- / [workspace]=r: 'Could not load item / Workspace not found'.
- The 'Possibly breaks CONVE-N' chip: data-sveltekit-reload.
- Cross-workspace wiki-links rendered from markdown: rel="external" (already
on the sanitizer allowlist, so the allowlist does not widen).
- Editor link clicks: planHrefClick sends a resolver href to its full-load
branch instead of goto. Its old test pinned the goto.
Found by clicking the live chip on :7777. The new e2e clicks the chip and a
comment's wiki-link and asserts where the browser lands; both fail with the
fixes reverted.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(web): the resolver click-through e2e cleans up its fixture items (TASK-3119)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
10f722e9fe |
feat(web): "Possibly breaks CONVE-N" chips on the item page (TASK-3119 U1b) (#1860)
* feat(web): "Possibly breaks CONVE-N" chips on items (TASK-3119 U1b)
The item page's decision chips gain the conventions set: a current
conv:<ref> answer at or above 0.9 is a chip reading "Possibly breaks
CONVE-N", linking to the convention through the ref resolver
(/-/r/{ws}/{ref}, so a renamed conventions collection still resolves).
The U0 eval measured the check as precise but shy, so a missing chip
means nothing: no "complies" state, no green, and an answer below the
threshold produces no chip at all. Same fetch, same switched-item and
identity fences as the attention chips.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(web): convention chip title makes no compliance claim (TASK-3119 U1b)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
6903eeeea5 |
feat(web): choose monthly or annual Pad Pro at checkout (TASK-3468) (#1856)
The billing page offers Monthly ($8 / month) and Annual ($80 / year),
with a local-currency note, above both Upgrade buttons, sharing one
choice. Monthly is the default. The checkout sends {interval}.
The page opens Stripe only for a session priced at the picked interval
(decideCheckout, against the sidecar's echo, pad-cloud #62). A missing
echo means a sidecar from before TASK-3367, which sells monthly: a
monthly pick still proceeds, an annual pick is refused. A differing echo
is refused. A 503 for the interval shows a plain message, disables that
option and moves the choice to the other; other failures stay toasts.
The picker shows prices, so it renders only under commerceAllowed and
joins noCommerceOutsideGate's gated list; that guard also gains a
price-copy pattern, so a price outside the gate fails it.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
9b7ffdf897 |
fix(web): long Active Plans titles stay inside their card at every width (BUG-3440) (#1855)
* test(e2e): Active Plans rows hold a long title at desktop through 375px in both themes (BUG-3440) * fix(web): a long Active Plans title clips with an ellipsis and its full text on hover, and takes its own line on a phone (BUG-3440) |
||
|
|
13fa0c99e9 |
fix(web): quick-add is c, and one switch turns every single-key shortcut off (BUG-3465) (#1851)
* fix(web): quick-add is `c`, and one switch turns every single-key shortcut off (BUG-3465) Mod+N reaches a page in no browser tab: Chromium reserves IDC_NEW_WINDOW (IsReservedCommandOrKey), Firefox declares accel+N reserved="true", and Safari reportedly does not dispatch it. Quick-add moves to `c` (Linear, GitHub and Jira's create key), outside editable fields and with no modifier held. Mod+N stays bound for installed-app windows but is no longer advertised. WCAG 2.1.4 needs a way to turn character-only shortcuts off, and the app already shipped `?` and j/k/h/l without one. characterKey() is now the single gate: `c`, `?`, j/k/h/l (mapped onto the arrow cases, so Ctrl+J no longer moves the list or swallows the browser's Downloads key) and the image viewer's + = - 0 read their key through it. A per-device "Single-key shortcuts" switch sits in the shortcuts modal and on the account settings page (the way back once `?` is off). Shortcut labels follow the platform: ⌘ on macOS, Ctrl elsewhere, in the modal and every tooltip. A source census fails on any raw single-character key comparison outside characterKey(), with a reasoned allow-list for focus-scoped input triggers; it is red on the unconverted bindings. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): the palette's dark-theme axe scan waits for the theme's transitions (BUG-3465) The scan ran the instant data-theme flipped, while the filter chips were still fading their colours (`transition: all 0.15s`), so axe read in-between values: #9b9baa on #3b3b44, 4.04:1, failing #1851's CI twice while passing 13/13 locally. Neither colour belongs to either theme. The scan now waits until no CSS transition is running. With every transition stretched to 2s, the old order fails with CI's assertion and the new one passes. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
c17865462f |
feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)
U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.
U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.
* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)
* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)
* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)
* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)
* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)
* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
|
||
|
|
35ca07bd6e |
fix(web): drop the ⌘] binding that cancelled browser Forward for nothing (BUG-2666) (#1849)
⌘] toggled uiStore.detailPanelOpen, which no component has read since the first release; the real detail pane is URL-derived (?item=). Chrome does not reserve Back/Forward (IsReservedCommandOrKey in Chromium's browser_command_controller.cc), so it delivered the key, and the handler's preventDefault took Forward away in Chrome, Safari and Firefox on macOS and Firefox on Linux while changing nothing visible. Removes the binding, the dead state (detailPanelOpen, its toggle/open/ close, the pad-detail-panel key, the mobile-crossing force-close, the unused --detail-panel-width var) and the e2e probes that measured it. A layout test pins that Mod+[ / Mod+] are never prevented, with either modifier; it is red on main. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
a7c559e431 |
fix(web): the command palette is an accessible combobox, axe-clean in both themes (TASK-2234) (#1847)
* test(e2e): the command palette is a keyboard- and screen-reader-usable combobox, axe-clean with results and recents (TASK-2234); adds @axe-core/playwright * fix(web): the command palette is an ARIA combobox (listbox options, active descendant, announced count) and the keyboard works on recent searches; status pills clear 4.5:1 (TASK-2234) * test(e2e): reopen the palette only once the item page has settled (TASK-2234) * test: navigation follows the grouped render order; a body-only match is announced; the e2e reads aria-controls as a list and really scans dark (TASK-2234, codex r1) * fix(web): palette navigation walks the grouped results in rendered order; the announcement counts content matches (TASK-2234, codex r1) * test: the selection follows its item across a grouped append; nothing is announced while the content search runs (TASK-2234, codex r2) * fix(web): the palette selection follows its item across Load more; the live region waits for the content search (TASK-2234, codex r2) |
||
|
|
f392847024 |
fix(web): no field suppresses its focus ring any more (TASK-3461) (#1845)
WIP: 40 component rules removed outline under :focus on a field, which beat the global :focus-visible ring (TASK-2262). Removed from all 40; the danger input's ring takes its red. A vitest guard (src/lib/a11y/fieldFocusIndicator.test.ts) refuses a new one unless the rule gives another indicator; red on the old code, green now. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
56f8a49fe0 |
fix(web): SSE bursts coalesce their reconcile and collections reload; the timeline ignores other items' comments (TASK-2224) (#1844)
* test(e2e): a tab's request budget for external updates, a 20-update burst and comments on another item (TASK-2224) * fix(web): SSE bursts coalesce their reconcile and collections reload; the timeline ignores comments on other items (TASK-2224) * fix(web): a cancelled coalesced run settles its callers; a solitary call is capped; the budget gate bursts concurrently and proves the last update was reconciled (TASK-2224, codex r1) * test(web): an SSE callback whose coalesced run was cancelled by destroy stops (TASK-2224, codex r2) * fix(web): a cancelled SSE callback stops; the budget gate scales with the burst's duration and waits out the reconcile queue (TASK-2224, codex r2) * test(web): cancelling a coalesced run already in flight rejects its callers (TASK-2224, codex r3) * fix(web): cancelling the coalescer abandons runs in flight too; the budget gate requires a burst short enough to discriminate (TASK-2224, codex r3) * test(web): a synchronously throwing coalesced function rejects its callers (TASK-2224, codex r4) * fix(web): a synchronous throw rejects the coalesced callers; the budget gate counts until the tab is quiet and skips only the budget on a box too slow to discriminate (TASK-2224, codex r4) * test(e2e): the budget's windows come from when the page received the 20 events (TASK-2224, codex r5) * test(e2e): the budget counts only once no API request is in flight (TASK-2224, codex r6) * test(e2e): a budget run that never goes quiet is skipped, not counted (TASK-2224, codex r7) * test(e2e): room for the budget's waits (TASK-2224) |
||
|
|
7222505d41 |
fix(web): every field shows a focus ring, and the sidebar add buttons reach 3:1 (TASK-2262) (#1843)
The 2026-07-20 audit's C9 / C10, re-measured on current main. - --text-muted already meets AA: a runtime survey of every element rendered in the muted colour against its effective background, 13 pages x 2 themes, 458 elements each, has a minimum of 5.05:1 dark and 5.09:1 light (the audit-era #666666 measures 2.94 with the same instrument). No token change; the dead --status-archived token (0 uses) is removed. - Focus (C10): app.css stripped every field's outline and gave only input and textarea a 1px border shift; selects had NO indicator (settings > Members invite selects, measured with a keyboard-focus probe). A global :is(input, textarea, select):focus-visible 2px ring, with the pseudo-class doubled so it beats components' plain outline resets; fields showing the ring went from 0 to 10 of 14 probed, none left without an indicator. The 44 per-component ':focus { outline: none }' field rules are their own task (lead ruling). - The sidebar's icon-only '+' controls rendered muted at opacity 0.5: 2.29:1 dark, 2.12:1 light. At 0.8: 3.94 / 3.76 (WCAG 1.4.11, 3:1). e2e task-2262-focus-and-contrast: 4/4 green here, 4/4 red on main. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
1d022040e6 |
fix(web): the field editor's advanced-section sync no longer depends on the Svelte runtime to stay alive (TASK-2186) (#1841)
The 2026-07-20 audit's critical C1: typing the first letter of a new field's label wedged both collection modals (key stuck at one letter, add-field dead, unclosable). FieldEditor's $effect.pre read and wrote lastSyncedKey as $state, the self-invalidating class BUG-1687 fixed in CreateCollectionModal. Svelte 5.56 stopped the wedge ( |
||
|
|
4fdc6ed625 |
feat: in-app tutorials on Pad Cloud, links to getpad.dev/learn elsewhere (TASK-3452) (#1839)
Design and rulings on TASK-3452 (lead, day 89). - Placement: a first-time card on /console's empty state (Start a new project) and in the workspace launchpad's step 2 (Set up your workspace with /pad onboard); each vanishes with its screen. The stable way back is a Tutorials entry in the user menu's Resources block, right after Docs. - Dismissal: users.ui_dismissals, a JSON set of keys behind an allow-list (models.UIDismissalKeys), read-modify-written under the users row lock, so a dismissal holds on every device. GET/PUT /me/ui-dismissals, web only. - Pad Cloud: the server fetches https://www.getpad.dev/learn/catalog.json (a constant; 1h cache, last good copy kept, 5 min back-off) and serves it same-origin at /tutorials, with posters proxied at /tutorials/posters/ {slug} from www.getpad.dev only. The page CSP gains frame-src for youtube-nocookie, Cloud only. /console/tutorials hosts the click-to-load player and chapters; transcripts link to getpad.dev. - Self-hosted: both routes 404, no outbound request, no CSP change; every surface links to getpad.dev/learn. Migration 127 / pg 101, after #1837's 126 / 100 (rides rc.7). No CLI or MCP surface. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
1d94d038a4 |
fix: the first-run launchpad follows an onboarding agent live (BUG-3447) (#1837)
* test: collection create publishes an event; the launchpad and connect banner follow agent writes live (BUG-3447) * fix(web,server): collection create publishes collection_updated; the launchpad and connect banner follow agent writes live (BUG-3447) * test(web): a debounce armed in one workspace does not fire into the next (BUG-3447, codex r1) * fix(web): the live reload and the banner re-check fire only for the workspace they were armed in (BUG-3447, codex r1) * test(web): an event during the first load or check still triggers the live refresh (BUG-3447, codex r2) * fix(web): an event before the first load or check commits still triggers the live refresh (BUG-3447, codex r2) * test(web): a failed quiet re-check that overtook the first check leaves the banner showing (BUG-3447, codex r3) * fix(web): a failed quiet re-check from the unknown state falls back to showing the banner (BUG-3447, codex r3) * test: an agent's collection is agent activity (dashboard, banner, e2e incl. the Agent-connected step) (BUG-3447, lead) * fix: a collection records its source; an agent-created collection is agent activity for the launchpad and the banner (BUG-3447, lead) * test(e2e): the launchpad follows an onboarding agent from outside the tab, red on main for each outcome (BUG-3447, lead) * test(store): collection provenance survives export/import; the census sees the new column (BUG-3447, codex r5) * fix(store): collection provenance travels in the workspace bundle, validated on import; census records the column (BUG-3447, codex r5) * test(store): an unrecognised bundle source is stored as '' (BUG-3447) |
||
|
|
a91fcce7f9 |
fix: blank workspaces accept the library's own triggers and scopes (BUG-3446) (#1836)
* fix: a blank workspace's conventions and playbooks take the library's own triggers and scopes (BUG-3446) The blank template seeds Conventions with trigger [always] / scope [all] (Playbooks [manual] / [all]) and nothing widened them, so all 18 library conventions were refused on every door: CLI and MCP library activate, the web Activate button, and the onboard playbook's literal B3 create. A create in a system collection declaring the convention or playbook artifact kind now adds a missing trigger/scope value to the select's options when the LIBRARY itself uses that word (collections.LibraryOptionVocabulary, computed from the library). The write goes through UpdateCollection with its concurrency token, ahead of validation, and is reported in warnings.options_added (CLI: stderr) and in the created activity's metadata, which the item timeline renders. Any other value is refused as before. Editors may trigger it (lead ruling). The onboard playbook's B3 now says how an owner adds an invented trigger. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(mcp): ToolSurfaceVersion 0.67 for BUG-3446 (additive) Lead ruling: a create in a system conventions/playbooks collection now adds a library trigger/scope word instead of refusing it, and its result gains an omitempty warnings.options_added. No tool name, enum or param moved; tools/list golden bytes are unchanged, initialize moves for the version and the instructions note. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
f74f626fcb |
fix(server): register-with-code claims the invitation atomically; a gone invitation refuses and rolls the account back (BUG-3438) (#1834)
* test(server): register-with-code through an invitation gone before its claim joins nothing (BUG-3438) * fix(server): register-with-code claims the invitation in the membership's transaction; a gone invitation refuses and rolls the account back (BUG-3438) * test(server): a cloud signup refused for a gone invitation is rolled back; a failed claim beside a membership is not a join (BUG-3438, codex r1) * fix(server): the signup rollback removes the account's verification tokens with it; a membership the claim did not write is not a join (BUG-3438, codex r1) * test(server): a session minted in the window does not block the signup rollback; a refused signup keeps the emailed proof for the retry (BUG-3438, codex r2) * fix(server): the signup rollback removes the account's sessions under its row lock and restores a spent invitation proof (BUG-3438, codex r2) * test(server): a reset token minted in the window does not block the signup rollback (BUG-3438, codex r3) * fix(server): the signup rollback is the full account deletion; a proof whose consume reported an error counts as possibly spent (BUG-3438, codex r3) * fix(server): an invitation proof whose consume errored is never restored from what the caller sent (BUG-3438, codex r4) * test(server): an invited signup reports the role the account holds (BUG-3438, codex r5) * fix(server): an invited signup reports the role the claim kept (BUG-3438, codex r5) * fix(server): the signup rollback kicks the account's connections, as every account deletion does (BUG-3438, codex r6) * fix(server): the signup rollback kicks even when the deletion reports an error (BUG-3438, codex r7) * test(server): on open registration a refused invited signup keeps the account, joins nothing and says why (BUG-3438, lead ruling) * test(web): a registration kept without its invitation says so on the join page and lands nowhere (BUG-3438) * fix(server,web): a refused invited signup is rolled back only when the invitation was its admission; on open registration the account is kept and told (BUG-3438, lead ruling) * test(server): a claim refused before commit needs no reconcile read; a failed rollback is not reported as one (BUG-3438, codex r9) * fix(server): a claim refused before commit skips the reconcile read; a failed rollback answers 500; docs name the admin case (BUG-3438, codex r9) |
||
|
|
b831e9fe09 |
feat(web): + badge, Decline, and an accept/decline card on invitation links (BUG-2136 U2) (#1833)
* test(web): red tests for the invitation badge, Decline, and the join-page consent card (BUG-2136 U2) * feat(web): pending-invitation badge on +, Decline in the list, and an accept/decline card on /join (BUG-2136 U2) * test(web): a list fetch issued before a decline cannot resurface the row; accept locks Decline (BUG-2136 U2) * fix(web): a decline orders out list fetches already in flight (BUG-2136 U2) * test(e2e): the + badge counts and clears, and a decline from + joins nothing (BUG-2136 U2) * docs: invitations are pending until accepted; the CLI, + menu and join link (BUG-2136) * test(web): stale list responses across an identity change or an accept; the 2FA sign-in lands on the card (BUG-2136 U2, codex r1) * fix(web): list responses are fenced by identity and ordered out by an accept; 2FA button says Verify; decline routes documented (BUG-2136 U2, codex r1) * test(web): /join accept and decline refresh the + badge; the 2FA button reads exactly Verify (BUG-2136 U2, codex r2) * fix(web): /join accept and decline force-refresh the + badge (BUG-2136 U2, codex r2) * test(web): the TopBar mount refresh skips the badge throttle (BUG-2136 U2, codex r3) * fix(web): the TopBar mount refresh is forced (BUG-2136 U2, codex r3) * test(web): /join follows a code change under the mounted page, drops a late preview, and announces results (BUG-2136 U2, codex r4) * fix(web): /join restarts its flow per code and acts on the code it shows; results are announced; docs name the CLI (BUG-2136 U2, codex r4) * test(web): an accept, decline or sign-in in flight across a /join code change does not land on the new code's page (BUG-2136 U2, codex r5) * fix(web): /join fences every action continuation by flow, and a late sign-in restarts the current flow (BUG-2136 U2, codex r5) * refactor(web): /join accept keeps one fence, at the landing; a spent proof is cleared whichever page shows (BUG-2136 U2) * test(web): a /join code change empties the form, keeps an in-flight sign-in busy, and a late signup clears its proof (BUG-2136 U2, codex r6) * fix(web): a /join code change empties the form and keeps an in-flight sign-in busy; a late signup clears its proof; the username check is fenced (BUG-2136 U2, codex r6) * test(web): the + list answers the badge under a token reserved before it fetched (BUG-2136 U2, codex r7) * fix(web): the + list and the badge refresh share one order (BUG-2136 U2, codex r7) * test(web): the + rows follow the badge store's one order (BUG-2136 U2, codex r8) * fix(web): the + rows render from the badge store, so one order decides both (BUG-2136 U2, codex r8) * test(web): the TopBar store double carries the list the + surface renders (BUG-2136 U2) |
||
|
|
2f3cf402b1 |
fix(timeline): 'commented on update' only for a comment an update carried (BUG-3437) (#1832)
* fix(timeline): 'commented on update' only for a comment an update carried (BUG-3437) TimelineCommentCard showed 'commented on update' whenever the comment had an activity_id, and every comment has one: a comment or reply written on its own links to its own 'commented' activity, so a plain 'pad item comment' read as if it were attached to a change. The store's comment list join (the one that already reads the agent name off the linked activity) now also reads its action into Comment.LinkedActivityAction (json:"-"), and the timeline sets an additive, omitempty TimelineEntry.comment_on_update when that action is anything but 'commented'. The card keys the label on it. The comments API, the CLI and MCP keep their shape (the new field is never serialized outside the timeline, which MCP does not expose). Tests: the server test writes a standalone comment, a reply and an update carrying a comment through the real routes (web, CLI and MCP share the comments route) and checks the flag, with the old rule and an always-false mutant each red; the web test pins the card and the entry list's pass-through, each red with its half reverted. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(timeline): a comment that arrives live keeps 'commented on update' (BUG-3437) The label comes from the server-set comment_on_update. Comments that arrive while the timeline is open come through the SSE refresh, which re-reads the timeline endpoint (nothing builds a comment entry client-side), so they carry the flag. This pins it: red if the entry list stops passing the flag. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
8c8deeeb89 |
fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)
* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)
* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)
* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)
* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)
* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)
* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)
* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)
* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)
* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
|
||
|
|
7aa5fa67f7 |
fix(web): an open mobile sheet stops page pull-to-refresh, and a pull from its list top closes it (BUG-3386) (#1830)
With the mobile Workspace sheet open, a downward swipe in the collection list did not close it, and at the list's top it pull-to-refreshed the page. DockedSheet put overscroll-behavior: contain on .ds-panel, which never scrolls, so it did nothing: the list (.ds-content) is the scroller, and its overscroll chained to the page. - .ds-content gets overscroll-behavior: contain; - while any DockedSheet is open the root is overscroll-behavior-y: none (html/body :has(.ds-panel)), so a drag on the backdrop, the grip, or a list too short to scroll cannot refresh the page either; - a downward drag that starts in the list with the list at its top closes the sheet, the grip's existing close gesture continued into the content. A drag on a scrolled list, or one that moves up first, stays a scroll. Applies to all three DockedSheets (Workspace, You, Quick capture). Unit tests cover the content pull (close, snap back, scrolled list, up-first, viewer in front); mutants removing the handler, the scrollTop check and the up-first check each turn one red. The mobile-chromium e2e drives a real CDP touch drag: red on main (root overscroll still auto under the open sheet) and red on a mutant without the content handler (the sheet stays open). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
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
|