Commit Graph

138 Commits

Author SHA1 Message Date
xarmian 6fd59627bd fix(deploy): the Kubernetes manifests run one Pad replica with Recreate and no autoscaler, since collaboration needs a single writer (BUG-3573) (#2039)
deploy/k8s ran replicas: 2 with an HPA up to 10, but real-time
collaboration keeps each item's room in the serving process (an
in-process bus, op-log appends serialized per item): two pods hold
independent rooms for one item, miss each other's edits and leave gaps
in the op-log. A RollingUpdate overlapped the old and new writer too.

deployment.yaml: replicas 1, strategy Recreate (the old pod stops first;
a brief outage per upgrade). hpa.yaml removed. secret.yaml's
encryption-key note no longer cites two replicas (the key must still be
set: a pod's /data is not kept across restarts). docs/deployment.md says
why, how to remove an HPA applied from an earlier version, and that
scaling out needs a shared collab bus, which is not built.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 21:26:05 -04:00
xarmian f17aaf26e2 fix(server): a workspace invite in the setup window answers setup_required, not 500 (BUG-3544) (#2034)
An invitation records who sent it. On a fresh install with no account yet,
requests run without auth, so pad workspace invite reached CreateInvitation
with no inviter, failed the inviter foreign key, and answered 500, which
reads as transient and retryable. handleInviteMember now refuses a request
with no resolved user: 409 setup_required with sign-in's own message
("This Pad instance must be initialized with pad auth setup") when no account
exists, else 401. Nothing is written.

ToolSurfaceVersion 0.85, a BEHAVIOR bump on the v0.58 / v0.45 grounds:
pad_workspace.action=invite over local stdio now gets that refusal where it
got server_error. docs/api.md notes it on the invite route.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 20:15:11 -04:00
xarmian 28872c2410 feat(admin): the MCP audit page names users and connections instead of truncated UUIDs (TASK-2255 C110) (#2032)
GET /admin/mcp-audit gains user_name (the name, else the email) and
connection_name (the OAuth client's name, else the connection's
label; a PAT's name), resolved in one IN-list query per kind per page
and omitted when the id no longer resolves. The page shows them,
falling back to the short id. docs/api.md documents the route.

MCPAuditNames reads oauth_refresh_tokens and oauth_connections, so it
is classified in the TASK-3399 reader census as censusPerGrant: it is
addressed by request ids already present in audit rows and never lists
a user's chains.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 18:32:29 -04:00
xarmian 9a7b38ed12 build(deploy): docker compose runs the released, signed image; k8s pins a release (TASK-2034) (#2031)
* build(deploy): docker compose runs the released, signed image; k8s pins a release (TASK-2034)

docker-compose.yml built this checkout, so every self-hoster ran an unpinned
source build that reports its version as "dev (unknown)". It now runs
ghcr.io/perpetualsoftware/pad:${PAD_VERSION:-latest}: the multi-arch image
the release pipeline builds and cosign-signs. `latest` moves only on stable
releases (goreleaser's tag template), so an unset PAD_VERSION never pulls an
RC. .env.example documents PAD_VERSION and recommends pinning it.

Contributors keep a source build through a new override,
docker-compose.build.yml, which tags the result pad:local so it never
shadows the released image.

deploy/k8s/deployment.yaml pins 0.17.2 instead of `latest`. With several
replicas (hpa.yaml), `latest` lets pods restarted after a release run a
different version from the rest against one database.

README and docs/deployment.md describe the pin, the upgrade
(`docker compose pull && up -d`) and the contributor build.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* build(deploy): the source-build override never pulls pad:local (TASK-2034, codex P3)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 18:20:15 -04:00
xarmian f3b753e3a8 fix(server): a non-cloud boot no longer makes a Pad Cloud database's free users unlimited (TASK-3551) (#2028)
A self-hosted boot converts free and empty plans to `self-hosted`, which has
no limits. Nothing recorded which kind of instance owned a database, so one
non-cloud boot of a Pad Cloud database (a missing PAD_MODE, a restore tested
locally, a debug boot) made every free user unlimited for good: a later
cloud boot fills only empty plans.

- Every cloud-mode boot marks its database cloud-owned
  (platform_settings.instance_mode = cloud), sticky and before its backfill.
- A database with no marker counts as cloud-owned when any user carries
  Pad Cloud billing state (a stripe plan source or a stripe customer id).
- The non-cloud boot's conversion (store.BackfillSelfHostedPlans) checks
  ownership in its own transaction and converts nothing on a cloud-owned
  database. The boot logs an error and continues: a debug or restore boot is
  not an outage, and free users keep free-plan limits.
- `pad db release-cloud [--force]`, on the server host, is the deliberate way
  to move a Cloud database to self-host. It refuses while billing state
  remains unless forced; a later cloud boot marks the database again.

The plan step of `pad server start` moves into bootPlanBackfill so it can be
tested as the server runs it. docs/backup.md documents the behaviour.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 17:26:08 -04:00
xarmian 5960cf1978 fix(collab): an open tab refuses a content write over typing the server has not stored (BUG-3542) (#2016)
A content PATCH routed to an open tab (the designated applier) replaced
typing that was still on the wire. The BUG-3133 pending-edits check reads
the op-log when the PATCH arrives and cannot see it.

The server now marks the applier_request `guarded` unless the caller sent
overwrite_pending_edits. A tab whose edits may be missing
(WsProvider.editsMayBeMissing: unsent, catch-up owed, or sent without a
barrier ack) answers applier_refuse instead of applying. The server takes
that as final (no re-election) and the PATCH answers 409
content_not_applied with apply_reason unconfirmed_edits and content_landed
false; other fields land, as on every content_not_applied. Tokenless writes
are refused too, by ruling; overwrite_pending_edits is the only escape.

ToolSurfaceVersion 0.84 (BEHAVIOR bump). docs/api.md and docs/mcp.md updated.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 09:08:04 -04:00
xarmian 47ca9e521a fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) (#2009)
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B)

Lead ruling: a repeated or empty field key or select option is a broken
schema, and the web renders fields and options as lists keyed by them, so
one repeat blanked the view (BUG-3538). PR A made the web render such a
schema; this stops new ones being written.

- Collection create and update refuse, with 400 validation_error naming
  it, a schema in which a field has no key, two fields share a key, or a
  select field lists an empty option or the same option twice
  (models.ValidateSchemaKeysAndOptions, beside ValidateAbandonedOptions).
  WRITE check only: a stored schema with a repeat still reads, and an update
  that sends no schema still applies. Live census on :7777: 0 of 213
  collections affected.
- Dynamic Client Registration registers a repeated redirect URI once (it is
  unauthenticated, and the connected-apps page lists URIs keyed by value).
- ListWorkspaceTags groups tags as TEXT: on SQLite json_each kept [1] and
  ["1"] apart while both scanned to "1".
- Pins that convention commands and surfaces are deduped on read: they are
  already (normalizeItemConventionMetadata); the census verdict that said
  otherwise was wrong.

Not done, deliberately: refusing repeated app-manifest event names or
companion field keys. appmanifest.Parse also re-reads a STORED manifest on
upgrade, so a new refusal there would fail a read of an installed app; PR A
made both web lists render repeats.

ToolSurfaceVersion 0.82 -> 0.83: pad_collection create/update refuse calls
they used to accept (the v0.43 / v0.48 grounds). docs/api.md states the 400.

* fix(server): hold companion and template schemas to the key/option rule (TASK-3539)

Codex round 1: writers that bypass the collection handlers could still store
a schema that repeats or empties a key or option.
- An app's companion collection schemas are checked in the install/upgrade
  preview, on the NEW manifest beside the reserved-key check, and refused
  422 invalid_manifest. Not in appmanifest.Parse, which also re-reads an
  installed app's stored manifest.
- Every template and default collection schema the repo ships is checked by
  a test (they seed through the store, not the handlers).
- The legacy-schema test also reads and creates items in a collection stored
  with a repeat.
Workspace and artifact import, and the library option widening (which
already dedupes), are unchanged: refuse on write only.

* docs(mcp): the v0.83 entry in docs/mcp.md's version history (TASK-3539)

Codex round 2: the current-version labels said v0.83, the history began at
v0.82.

* docs(mcp): docs/mcp.md's intro names the current catalog, v0.83 (TASK-3539)

Codex round 3: the intro still said v0.81, stale since before this change.

* fix(server): the option check covers select and multi_select only (TASK-3539)

Codex round 4: it refused empty or repeated options on every field type, but
only a select or multi_select's options are a rendered list. Another type can
carry a legacy options list (the old field DSL put a third part there), and
refusing it would block an unrelated schema save. Tested both ways.

* test(collections): the template schema check is independent of the validator (TASK-3539)

Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was
circular. It now uses its own check. Also records, at the write, why the
library option widening is not held to the validator: it only appends missing
words (never a repeat), and refusing would fail an item create over a legacy
schema.
2026-10-10 03:04:20 -04:00
xarmian 0b0398b2c0 feat(mcp): count admitted MCP requests by method and client (TASK-2307) (#2003)
* feat(mcp): count authenticated MCP requests by method and client (TASK-2307)

The go-sdk port would run its Streamable HTTP transport stateless, and a
stateless transport answers GET with 405, which ends the standalone SSE
stream. No unit test can say whether a real client opens that stream, so
before any engineering time goes into the port this measures it:
pad_mcp_http_requests_total{mount, method, client}, incremented on entry for
every authenticated request on /mcp and /mcp/chatgpt.

On entry, because pad_http_requests_total records a request when it ends,
and a GET holding an SSE stream open ends when the stream does. After
MCPBearerAuth, so refused requests stay with pad_mcp_preauth_denied_total.
The client label is a closed set read from the User-Agent (claude-code,
claude, codex, openai, cursor, windsurf, vscode, mcp-remote, mcp-inspector,
python, node, go, curl, okhttp, other, none), never the header itself.

* fix(mcp): read the User-Agent through requestUserAgent (TASK-2307)

The BUG-2803 call-site guard refuses a raw User-Agent read; the class is
computed from the sanitised value. The guard's exact count moves from 7
call sites to 8, re-pinned with the reason: like fromNativeShell, the counter
only substring-matches the header into a closed label and binds nothing.

* docs(mcp): name what the request counter admits, not 'every authenticated request' (TASK-2307)

Codex round 1: a caller refused by the per-token rate limit inside
MCPBearerAuth is authenticated and is not counted. That is the intended
boundary (a refused GET opens no stream, and the refusal is already counted
in pad_mcp_authz_denials_total{reason="rate_limited"}), so the comment, the
metric help and docs/deployment.md now say what is counted.

* test(mcp): a rate-limited MCP request is not counted (TASK-2307)

Codex round 2: the documented boundary (MCPBearerAuth's per-token 429 is
not an admitted request) had no test. Counted equals admitted; the mutant
moving the counter ahead of auth counts the 429 and fails.
2026-10-10 00:50:31 -04:00
xarmian f61a07fe9a feat(search): include_archived on /search, and an is:archived search token (TASK-2864) (#2001)
* feat(search): include_archived on /search, and an is:archived search token (TASK-2864)

/search hard-filtered soft-deleted items on every query path, so the web
search's Show archived toggle could not reach a body: query. It now takes
include_archived=true, which widens the full-text query, the ref and number
lookups, the total and the facets alike; an archived result carries its
deleted_at. The permission filter is unchanged. An individual item grant
names live items only, so a guest sees an archived item only through a
collection grant, as on the item index's include_archived.

The web search box recognises is:archived (the token the TASK-1367 prefix
parser had prototyped and pulled): the collection page shows archived rows
for it as the toggle does, sends it on body: queries, and fences the
response on it; the command palette sends it and strips it from the text it
sends. The collection identity gate re-records 16 hashes: 14 units reach
the items view, which now reads the token, and none moved an await or a
check.

* test(search): drive is:archived through the palette and page bindings (TASK-2864)

Codex round 1: the parser tests could not see the call sites. The palette
test mounts CommandPalette and reads api.search on the body, cold-index and
content-supplement paths; the collection fence harness reads the body
search. Mutants (filter dropped, token left in the text on each path, the
page ignoring the token or not sending it) each turn a case red.

* test(search): an item-grant guest with a live grant sees no archived ungranted item (TASK-2864)

Codex round 2: the all-archived access cases short-circuit to empty access
before the store, so they could not catch include_archived widening a
genuinely applied item-grant filter. Mutant (skip the permission filter
when IncludeArchived) turns the new case red.

* docs(search): the archived filter comment does not claim facets cover direct hits (TASK-2864)
2026-10-09 23:10:51 -04:00
xarmian 560ab27b33 feat(server): reference items stop counting as open work (PLAN-3535 PR 2) (#2000)
The items of a collection with tracks_work=false are left out of:
- parent progress (GetItemProgress / GetAllItemProgress SQL, and
  childProgressState for the restricted recompute and the dashboard);
- the open-children close guard (all four doors share runOpenChildrenGuard);
- a default report (resolveReportCollections: !tracksWork replaces IsSystem);
- the dashboard's active items, stalled / overdue / orphaned / blocked /
  content attention, suggested_next and by_role (store and visible recount),
  and so next / ready / stale and the bootstrap dashboard;
- standup and changelog completed work and in-progress, server and CLI;
- the attention decision set's eligibility;
- the admin items_open count.
Sidebar and summary totals, item lists, search, links, the graph, stars and
reminders keep counting every item.

A workspace bundle written before the key existed imports its system
collections as reference, as the migration marked them.

Guards: a three-phase behavioural fixture (work / reference / deleted) over
every excluded surface, each proven sensitive; and a name-based
classification of every lifecycle-helper call site in Go.

MCP ToolSurfaceVersion 0.82 (behaviour bump, v0.49 grounds).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 22:31:16 -04:00
xarmian f5a00d5b13 feat: a collection can be reference material, not work (PLAN-3535 PR 1) (#1996)
* feat: a collection can be reference material, not work (PLAN-3535 PR 1)

The switch, with no counting change yet (PR 2 server, PR 3 web).
CollectionSettings.tracks_work: false makes a collection REFERENCE
material; absent means work, today's behaviour, so old rows, old writers
and a client/server skew are unchanged. One Go predicate
(models.CollectionTracksWork) and one TS twin (collectionTracksWork).

- New collections: the creator's value, else system collections are
  reference and others are work iff their done field can finish. New
  template Docs collections are created as reference (Dave).
- Migration 135 / PG 107: existing Conventions and Playbooks become
  reference; everything else, Docs included, stays work.
- Settings are written wholesale, so an update whose settings omit the
  key KEEPS the stored value (CarryTracksWork), for every writer.
- A typed top-level `tracks_work` on POST/PATCH sets just that key
  server-side. CLI: --tracks-work=true|false (a string flag, so false
  survives stdio MCP). MCP: pad_collection tracks_work, v0.81. A
  non-boolean is refused 400; the CLI checks the response, since an
  older server ignores the member.
- Web: the Display tab's "Track as work" toggle; the editor now starts
  its settings write from the STORED settings (buildCollectionSettings),
  so keys it does not render (content_template, tracks_work) survive.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(store): carry tracks_work from the locked row; order the SQLite migration's JSON checks (PLAN-3535, codex)

The update now computes the carried settings from the row it re-read under
the write lock, so a concurrent settings write cannot be reverted by a carry
read before the lock. The SQLite migration evaluates json_type/json_extract
only inside a CASE guarded by json_valid, so malformed settings on a system
row cannot abort it.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(models): a collection whose options the default terminals close is work (PLAN-3535)

DefaultTracksWork required declared terminal_options, but every lifecycle
reader falls back to DefaultTerminalStatuses when a done field declares
none, so a `status: open, done` collection finishes items and was being
created as reference. Work now means one of the done field's options is
terminal by the same resolver the readers use. Found by PR 2's store tests,
whose fixture collection is exactly that shape. Also corrects api.md's
MCP version (0.81).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 21:47:50 -04:00
xarmian 1d752e0ba6 fix(mcp): remote pad_item.import keeps its artifact (BUG-3533) (#1992)
* fix(mcp): remote pad_item.import keeps its artifact (BUG-3533)

The catalog action is written for stdio: it spills the artifact to a
temp file and dispatches the CLI's `file` positional. It also dropped
`artifact` from the dispatched input, and the remote transport's
HTTPHandlerDispatcher reads the artifact text from exactly that input,
so every remote import answered "artifact is required". `artifact` now
stays in the input. The CLI never sees it (BuildCLIArgs emits only
declared args and flags; the existing no-`--artifact` test still holds).

The dispatcher's own tests passed `artifact` to it directly, so the
binding was untested. The new test drives the catalog action through
the real dispatcher and server. The scope population test's
knownBrokenOverHTTP exemption for import is removed, so import now
carries the full write-control and read-refusal legs.

* chore(mcp): ToolSurfaceVersion 0.80, remote pad_item.import works (BUG-3533)
2026-10-09 20:31:42 -04:00
xarmian f9de1871dd fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534) (#1988)
* fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534)

The MCP transport (mcp-go's StreamableHTTPServer) reads a POST body
whole into memory and has no cap of its own, so any caller, before auth,
could make the server buffer any amount. Both MCP mounts now refuse a
body over max(3 x the 2 MiB JSON cap, 6 x the artifact import cap) +
1 MiB with 413 and a JSON-RPC error, before auth and before anything
reads the body. A declared length is checked without a read; a chunked
body is read up to the limit and replayed.

The bound is derived: every tools/call's payload lands in a downstream
request already capped at 2 MiB of JSON (up to 3x once a client
\u-escapes it) or in an artifact (up to 6x as control characters
inside a JSON string), so no call that could otherwise succeed is
refused. It follows PAD_IMPORT_ARTIFACT_MAX_BYTES.

A sweep of every request-body reader (the body-reader census plus every
reader outside internal/server) found mcp-go's the only uncapped one.

* fix(server): cap the MCP body after auth, so an unauthenticated body is never read (BUG-3534, codex r1)

* test(server): pin that a 413 is audited; narrow the unread claims to the limiter (BUG-3534, codex r2)
2026-10-09 19:48:36 -04:00
xarmian 5c9c650ac3 feat(server): a read-scoped token may run a playbook (TASK-2863) (#1987)
* feat(server): a read-scoped token may run a playbook (TASK-2863)

POST /api/v1/workspaces/{ws}/playbooks/{ref}/run is side-effect-free:
it binds the caller's args and returns the body, storing nothing. It is
now the one POST a read or pad:read token may make, through an
exact-path allowlist in tokenScopeAllows (lead ruling, option A).
/playbooks/match stays refused, as do every write and the item
claim/release routes.

Both variable segments must be plain names (RFC 3986 unreserved
characters, not a dot segment). The MCP dispatcher checks the path
string it built from caller input before http.NewRequest parses it, so
without that a ref of "match?" passed as eight segments ending in run
and routed as POST /playbooks/match. Pinned through the real catalog
action and dispatcher.

* test(server): key the read refusal on TokenAuth's scope message (TASK-2863, codex r1)

* chore(mcp): ToolSurfaceVersion 0.79, a read grant may run a playbook (TASK-2863)
2026-10-09 18:56:44 -04:00
xarmian add4b624bd feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) (#1986)
* feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308)

The /mcp 401 challenge now names scope="pad:read pad:write", so a client
asks for the tiers the consent screen offers instead of falling back to
scopes_supported. resource_metadata stays the last parameter.

An OAuth connection whose grant cannot write that calls a write action
(any catalog action outside readOnlyActions) is now answered HTTP 403
with an error="insufficient_scope" challenge, before dispatch. Until now
the refusal could only be a permission_denied tool result, because the
dispatcher refuses after mcp-go owns the response. The challenge is what
lets a client re-authorize with the wider grant. A read-scoped PAT keeps
the tool error, since it cannot re-authorize, and the ChatGPT mount keeps
its own tool-result challenge.

Every write action was already refused for a read token, pinned by a
population test that drives every catalog action through the real
dispatcher under a write control and a read token. So nothing that
worked is refused now. pad_item.import is exempt there: it fails over
HTTP for every token (BUG-3533).

ToolSurfaceVersion 0.77 -> 0.78 (behavior bump).

* test(server): account for the /mcp scope pre-check's body peek in the reader census (TASK-2308)

* test(mcp): use the population test's result-text helper (TASK-2308)

* fix(server): challenge only tools/call requests mcp-go would run; check the import exemption's refusal (TASK-2308, codex r1)

* docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2)
2026-10-09 16:38:39 -04:00
xarmian 2898bff09e fix(collab): close a peer the op bus had to drop an op for (TASK-1273) (#1981)
* fix(collab): close a peer the op bus had to drop an op for (TASK-1273)

When a subscriber's buffer was full, MemoryOpBus.Publish dropped the op
with a warning and the peer stayed connected with a document missing it
until it happened to reconnect. The bus doc promised a force-close that
was never built.

Now the first drop of an op a subscriber needs (anything but awareness)
marks it overflowed: Publish delivers it nothing more, because an op
after the gap would carry an op-log cursor past the dropped row and the
reconnect would resume from there and never replay it. The bus signals
the overflow (SubscribeWithOverflow) once, and the room manager closes
the peer's socket, so its reconnect replays the gap from the op-log.
Awareness drops stay silent. Counted in pad_collab_overflow_closes_total
through an optional OverflowObserver.

cmd/loadtest-collab gains -stalled N (clients that never read). Measured
against a local instance: 5 editors at 200 ops/s of 4 KiB, 2 stalled:
both stalled clients closed by the server, editors 0 errors; with the
close disabled, both stayed open.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(deployment): pad_collab_overflow_closes_total (TASK-1273)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(collab): a real overflow closes the peer once (TASK-1273, codex)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 13:52:27 -04:00
xarmian 416aea2d14 fix(collab): stop appending the whole document to the op-log on every reconnect (BUG-3523) (#1965)
* fix(collab): reconnect catch-up only when this tab's frames can be missing (BUG-3523)

The provider sent the whole document as one update on every reconnect,
appending a full copy to the op-log on each laptop wake or network blip.
It now sends it only when an edit was made with no socket open, or a
local update went out on a socket that then closed and no barrier ack
cleared it.

The barrier: once local sends go quiet (2s), the client sends one
`barrier {n}`; the server answers `barrier_ack {n, ok}` in readLoop
order, after every earlier frame was persisted, with ok=false when an
op-log append on that conn failed since the last barrier. An ok ack
covering every local send clears the debt; ok=false keeps it and logs
the item; a server that never answers leaves the tab owed.

Also counts materializer give-ups (pad_materialize_giveup_total{kind}).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(collab): a SyncStep2 answer is stored but not counted as content (BUG-3523)

A step2 is y-protocols' answer to a peer's step1 and repeats content other
rows carry: the answering tab's own edits went out as updates (and are
repaired with a full catch-up if they may be missing), and everything
else it holds came through the relay. Counting it kept items pending and
made the materializer replay a full copy of the document per joining tab.

Measured first: recorded multi-tab op-logs, across a dormancy prune,
materialize to identical text with and without their step2 rows.

Applied at append only. The startup backfill keeps the older rule for
legacy rows, written by clients with no failed-append repair.

Also: a barrier_ack with ok=false now resends the document on the live
socket at once (lead ruling), instead of only owing the next connection.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(collab): say what the barrier ack's write deadline bounds (BUG-3523)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(e2e): take the server's data dir from one module (BUG-3523)

The step2 spec resolved .pad-e2e one level too shallow: it passed where
PAD_E2E_DATA_DIR was set and could not open the database in CI.
playwright.config.ts and the spec now read e2e/lib/data-dir.ts.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 10:42:55 -04:00
xarmian f6f588b4d4 fix: a convention's stored metadata copy follows its ordinary fields (TASK-2257) (#1964)
A convention keeps its metadata twice: in the ordinary fields (trigger,
scope/surfaces, enforcement/priority, category, commands), which agents
and bootstrap read, and in the reserved `convention` key, which
item.convention prefers. Since BUG-3163 nothing but the create member can
write the reserved copy, so a fields_patch changing `trigger` left the
copy behind and the two disagreed.

UpdateItem now mirrors every mirrored key the write CHANGED into the
reserved copy, under the write lock, after the BUG-3163 carry guard. A
convention with no reserved copy is untouched. The merged copy is
validated with models.ValidateConventionMetadata; a write that would leave
it invalid is refused 400 validation_error (new, deliberate: storing an
invalid copy is what the reserved key exists to prevent).

The web Conventions page's edit form gains trigger / surface /
enforcement selects (sent as fields_patch with both spellings) and an
"Open item" link to the full item.

Drift measured before the change: 194 conventions, 5 with a reserved
copy, all 5 agreeing, so no reconcile follow-up was filed.
2026-10-09 09:38:42 -04:00
xarmian 480c5d351a feat(report): Insights buckets days in the viewer's time zone (TASK-3524) (#1955)
* feat(report): bucket the throughput series in the viewer's time zone (TASK-3524)

* feat(web): Insights asks for buckets in the viewer's time zone (TASK-3524)

* test(report): the tz parameter is echoed, and an unknown zone or Local is refused (TASK-3524)
2026-10-09 08:14:55 -04:00
xarmian 4226e8c5ef feat(activity): the collection and actor filters run on the server, and the web/CLI badge says which (TASK-2219) (#1948) 2026-10-09 06:36:13 -04:00
xarmian 4ed3b5eca2 fix(materialize): a job's deadline scales with its op-log (BUG-3521) (#1946)
* fix(materialize): a job's deadline scales with its op-log (BUG-3521)

Op-log recovery could not finish a large document: the real op-log of
an 86 KB plan (278 content-bearing rows, 470 KiB) took 1.36 s median
in-process on an idle box and 1.77 s on one core, against a flat 2 s
hard / 1.8 s soft deadline. It failed every attempt and exhausted its
budget, leaving items.content stale for the CLI, MCP, search and
exports until someone reopened the item.

Each job's hard deadline is now the configured base
(PAD_MATERIALIZE_TIMEOUT, 2 s) plus 20 ms per KiB of its rows, never
below the base and at most MaxTimeout (60 s): ~5x the measured
single-core rate, so that document gets ~11 s. The supervisor computes
it from the job, so every caller gets it. The soft deadline stays at
90%, and recovery runs off the request path in its own worker.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(materialize): the worker-start line states the deadline rule, not only its base (BUG-3521, codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 05:31:37 -04:00
xarmian 55db86f68e feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253) (#1943)
* feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253)

* test(store): ScanNUL cost figures count the two cli_auth_sessions columns (TASK-2253)
2026-10-09 05:04:19 -04:00
xarmian 5adbe04797 feat: bulk sort_order: one all-or-nothing request per lane reorder, one activity row per item collapsed per batch (TASK-3517) (#1933) 2026-10-09 02:04:52 -04:00
xarmian e8d680b135 feat(brand): the Pad mark is the brand: one vector master, the app's icons, header and link card from it (TASK-3514) (#1926)
* docs(brand): the Pad mark's master SVG set and its rules, proposed (TASK-3514)

* docs(brand): GitHub social preview in GitHub's card layout, two versions, with a one-command re-render (TASK-3514)

* docs(brand): the org avatar export; GitHub's default card kept, the custom one parked (TASK-3514)

* feat(brand): adopt the Pad mark in the app: favicons, PWA and touch icons, the header lockup, the link-preview card; flat app icon (TASK-3514)
2026-10-08 23:28:47 -04:00
xarmian 136fac6609 feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896) (#1923)
* feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896)

A bundle import that hits a DATA error after pad-export.json was imported
keeps the partial workspace (the rule is now written in docs/api.md:
transport failure, validation reject and pre-creation refusal roll back;
only a post-mint data error keeps). The 400 that said "kept" was the only
record, and a client may never read it.

- workspace_import_status (side table, SQLite 131 / PG 104): instance-
  local, never exported, purged with the workspace.
- The keep path writes {status: partial, note}. The note is SANITIZED:
  a fixed category and a correlation id (imp-...); the raw error goes to
  the server log under that id.
- GET /workspaces/{slug} carries import_status (omitempty); the note only
  for owners and instance admins. The list carries the status only.
- PATCH /workspaces/{slug} {"clear_import_status": true} clears it
  (owner-only, like every workspace PATCH).
- pad workspace list marks it "partly imported" (text only; the JSON
  mirrors the MCP workspace-list resource).
- Web: a banner on the workspace landing page and settings, with the
  reason for owners, "Keep it" (clear) and "Delete workspace".

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(import): a rollback that cannot remove the workspace marks it too; the docs name what keeps (TASK-896, codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(import): only members who are admins read the note; the banner's note fetch is fenced on identity (TASK-896, codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 22:22:06 -04:00
xarmian f9937c7653 fix(mcp): cloud refuses to start without an https origin for MCP OAuth; a blocked self-hosted MCP says so (TASK-1069) (#1920)
* fix(mcp): cloud refuses to start without an https origin for MCP OAuth; a blocked self-hosted MCP says so at startup and in health (TASK-1069)

* fix(mcp): pin the cloud origin combinations; health skips the setting read when MCP cannot be blocked (TASK-1069 codex r1)

* test(mcp): pad-cloud's production env (empty PAD_MCP_PUBLIC_URL, https PUBLIC_URL) starts, through the real env loading (TASK-1069)
2026-10-08 21:23:12 -04:00
xarmian 9a99f06c87 feat(collab): count resumes and force_refresh on resume, to measure offline editing (TASK-3501) (#1908)
* feat(collab): count resumes and force_refresh on resume, by reason, to measure offline editing (TASK-3501)

* fix(collab): resume reports run after itemLock is released (TASK-3501 codex r1)
2026-10-08 17:38:53 -04:00
xarmian cfc5391581 fix(events): a corrupted event sequence counter heals visibly into a new id space; the id is read back, not rendered through a double (BUG-2744) (#1906)
* fix(events): a corrupted event sequence counter heals visibly into a new id space, and the id is read back rather than rendered through a double (BUG-2744)

* test(events): two fixtures that forced a script failure through the sequence key use routes the guard does not heal (BUG-2744 codex r1)
2026-10-08 16:46:35 -04:00
xarmian 58be45b35e feat(mcp): remote /mcp writes are agent writes named by clientInfo; refused claims say lease_held (BUG-2772, BUG-3496) (#1903)
* feat(mcp): remote /mcp writes are agent writes, named by the client's declared clientInfo (BUG-2772)

A write over remote /mcp reached the handlers with no X-Pad-Agent and
was recorded as the human. It is now an agent write, named by the
client's own MCP clientInfo.name: a modern request's _meta clientInfo,
else the clientInfo its session sent at initialize (remembered by the
Mcp-Session-Id pad issued; mcp-go's session state does not survive
pad's stateless transport), else unnamed; never the human, never a
guessed name. Each initialize logs the declared client (name, version,
protocol, transport; no tokens).

A remote call's default lease holder is that name plus a short session
id (claude-code#3f9a12), inside defaultLeaseHolder so claim and
next/ready agree; two connections of one client no longer share a
lease. Folds TASK-3495. ToolSurfaceVersion 0.77.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): a refused claim reaches both transports as lease_held with its holder (BUG-3496)

* test(mcp): register WriteLeaseHeldError in the stdio marker vocabulary table (BUG-3496)

* fix(mcp): a remembered client name is bound to the account that declared it (BUG-2772 codex r1)

* fix(mcp): the remote lease holder carries eight hex digits of the session id (BUG-2772 lead ruling)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 15:15:26 -04:00
xarmian 8d02818fd6 feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809) (#1900)
* feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809)

Several agents commonly share ONE account. A claim with no holder
defaulted to the account's email, so a second agent's claim matched the
first one's label and account and silently refreshed its lease: both
"won" (measured: two claims as rook and wren on one account both
answered 200 on the same lease).

- defaultLeaseHolder: the request's X-Pad-Agent name, else the email,
  else the user id. An explicit holder still wins. The lease stays bound
  to the account (BUG-3341). A second agent now gets 409 lease_held
  naming the first, and cannot release it.
- next / ready (the dashboard's suggested_next) leave out items whose
  live lease a claim by the caller would lose: same identity, the claim's
  own predicate (store.ListForeignItemLeaseIDs, spelled positively
  because NOT(...) over a NULL user compare is NULL), filtered before the
  cap of three. Reminders are not filtered.
- Teaching: SKILL.md (claim before you start, pick another on
  lease_held, release when done, register the session), instructions.md,
  the pad_item holder param, and claim's CLI help, including that a
  custom --holder's items leave your own next / ready.
- MCP ToolSurfaceVersion 0.76 (BEHAVIOR bump on the v0.70 grounds),
  version.go entry, docs/mcp.md, README, golden bytes regenerated. The
  holder description was trimmed to stay under the tools/list budget.

Remote /mcp sends no agent name, so there the default is still the email
(folded into BUG-2772). A lease taken before the upgrade is another
holder's to the same agent for up to its TTL.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(mcp): the catalog paragraph names v0.76 (TASK-1809 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 14:28:16 -04:00
xarmian 0bdf539455 feat(release): the Claude Code plugin follows stable releases; the release workflow pins the marketplace (TASK-3487) (#1890)
* feat(release): the Claude Code plugin follows stable releases; the release workflow pins the marketplace (TASK-3487)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(release): the plugin pin never moves backwards; force the tag fetch (TASK-3487 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 12:46:18 -04:00
xarmian 8c64f3bd30 docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489) (#1889)
* docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489)

The initialize payload was 24,454 of its 24,576-byte budget, so the next
MCP change would trip TestMCPPromptBudgets. The multi_relation rules and
the relation title-matching detail are condensed to one line each, with
the full text moved to docs/mcp.md, and the resource list points at
resources/templates/list for descriptions. No rule an agent acts on is
dropped. Now 22,316 bytes (2,260 of headroom).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mcp): state the prompt-budget policy, a fixed ceiling with a trim rule (TASK-3489)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 12:40:51 -04:00
xarmian ea5c305fd4 docs: move the API and MCP references out of CLAUDE.md into docs/api.md and docs/mcp.md, verbatim (TASK-3485) (#1886)
CLAUDE.md was past Claude Code's 150k-character limit (163,658 on main
ee288cbe), so every session started with a warning. The API (53,364 bytes)
and MCP server (67,229 bytes) sections move byte-for-byte, including #1880's
v0.75 and field-filter lines; CLAUDE.md keeps an index and a hard pointer
for each, and says new notes go to the docs files. CLAUDE.md is now 46,162
characters.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 11:41:18 -04:00
xarmian 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
2026-10-06 22:23:29 -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
Matt Faltyn 5614980edc Make database restore preserve live data on failure (#1703)
Stage and normalize SQLite backups before atomic publication, checkpoint committed live journals safely, and stop PostgreSQL restores on SQL errors. Cover recovery failures and compatibility with real-engine regressions.
2026-10-02 22:56:53 -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 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 6118f7065c feat(mcp): mount the ChatGPT catalog at /mcp/chatgpt, OAuth-only, off by default (TASK-3321 U2b) (#1719)
The ChatGPT projection (U1) gets its own transport at <MCP URL>/chatgpt,
behind PAD_CHATGPT_MCP_ENABLED=true and served only while OAuth is
available. It is its own OAuth resource (U2a's second canonical): its
path-aware protected-resource document is
/.well-known/oauth-protected-resource/mcp/chatgpt, its 401s name that
document, and tokens do not cross between the two mounts. Personal access
tokens are refused there with a 401 naming OAuth-only, ahead of the PAT
branch that would accept them without the audience check.

The consent screen names the ChatGPT surface when the requested resource
is the ChatGPT one. The mount is wired only while the door versions
content (ChatGPTDoorVersionsContent, held to the behaviour by a test) and
every catalog tool has a response shape (ChatGPTCatalogReady); otherwise
startup logs and skips it. /mcp is byte-identical.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 03:34:43 -04:00
xarmian 5d420ef606 fix(materialize): the recovery sweep stops re-queuing an exhausted item every tick (BUG-3325) (#1704)
* fix(materialize): the recovery sweep stops re-queuing an exhausted item every tick (BUG-3325)

An item whose failure budget was spent stayed a sweep candidate: the
budget is in memory, so the candidate query cannot see it, and the sweep
queued it on every tick for the worker to refuse. On Pad Cloud that logged
"candidates=1 queued=1" every minute, forever.

The candidate query now also returns MAX(id) (the same quantity as the
worker's cursor), and the sweep holds back an item whose budget is spent
for that op-log, through the predicate admit uses. It is reported as
exhausted_skipped, written only when that count changes, and offered again
once its op-log grows. A backing-off item is unchanged.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): count exhausted items over the budget map, not per page (BUG-3325 review)

Codex round 1, P2: exhausted_skipped was counted per keyset page and
compared across pages, so a stable population over several pages read as
a change on every sweep. The line now carries exhausted_total, counted
over the in-memory budget map, and is written when it changes.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-01 20:49:44 -04:00
xarmian fc1f4e6181 fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323) (#1700)
* fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323)

From a trusted peer, TrustedProxyRealIP took X-Real-IP first and
otherwise the LEFTMOST X-Forwarded-For entry. On an appending proxy chain
both are client-controlled: the leftmost entry is whatever the client
sent, and a proxy that only appends to X-Forwarded-For (Azure Application
Gateway, many ingress controllers) passes a client's X-Real-IP through.
Any client behind such a proxy chose the address pad used for per-IP rate
limits, 2FA challenge binding, session IP checks and audit.

Now X-Forwarded-For wins when present, walked right to left, skipping
hops inside PAD_TRUSTED_PROXIES, taking the first address outside them
(nginx real_ip_recursive). ip:port entries are read as their address; an
unparseable entry stops the walk. X-Real-IP is used only when there is no
X-Forwarded-For. Cloud is unaffected: its router appends the resolved
client, which is the right-most hop.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(server): read every X-Forwarded-For field, and document the append requirement (BUG-3323 review)

Codex round 1, P2: Header.Get read only the first X-Forwarded-For field,
so a proxy appending its hop as a second field left the client in control.
All fields are now joined in order before the walk.

The other P2 (a proxy that sets X-Real-IP but passes a client XFF through
unchanged) is the cost of preferring XFF; the deployment guide now states
that every trusted proxy must append to X-Forwarded-For.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 23:19:41 -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 15c872bfed fix(materialize): recovery never writes a blank body over a stored one (BUG-3316) (#1693)
The pre-rollout dry run of op-log recovery (TASK-2198 U4) over the live
DB's 175 candidates found one that would have been wiped. A load-test item's
op-log of 90 tiny rows, with no base document, replays to an EMPTY document,
and recovery would have written "" over its 1,285-byte body. A tab never
does this: its lazy seed (TASK-1261) sees the empty fragment and seeds FROM
items.content.

MaterializeFlush now refuses a blank recovered body over a non-blank
stored one, decided in the precheck against the row locked for the write,
as the new outcome empty_document. Nothing is written: no body, version,
seq or watermark. Recovery treats it as terminal until the op-log changes
(exhaust, which now takes its kind), so the sweep does not replay it every
minute, and it logs the one exhaustion WARN with
last_error_kind=empty_document.

Stated divergence: a tab refuses only when the fragment is EMPTY, so a user
who truly cleared an item's text and then crashed would have flushed "".
Recovery cannot see the fragment, so it keeps the old body there too, which
is the conservative side. No shrink threshold, by ruling: a tab would flush
a shorter body, and the dry run found no such case.

Gate: the dry run re-run on this branch, flushing into a disposable COPY of
the same snapshot. All 175 items produce the same recovered body and class
as the first run. TASK-1270 is refused (empty_document) with its body
intact; 156 are stamped, 17 applied, and 1 (a deadline failure) is never
flushed. Legs: store (blank and whitespace-only, nothing written) and
recovery (refused, terminal, one WARN, reset when the op-log grows).
Mutants: a guard that never fires (2 red), no exhaust (1), wrong kind (1).

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 14:00:40 -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 d78ff9bdc5 feat(materialize): worker supervisor with per-OS memory cap (TASK-2198 U3) (#1682)
* feat(materialize): parent-side worker supervisor with per-OS memory cap (TASK-2198 U3)

The Supervisor runs materializer jobs in ONE long-lived child process
(`pad __materialize-worker`), started lazily on the first job and fed
jobs serially. A job past PAD_MATERIALIZE_TIMEOUT (default 2s) kills the
child (goja's Interrupt, sent as timeout_ms a tenth earlier, is only the
soft layer); a dead child is respawned on the next job after an
exponential backoff (1s doubling, capped at 5 min, reset by a child that
lived a minute). Every stop is logged with its reason (deadline, memory,
exit, protocol, start, canceled, closed); every start logs the effective
timeout, memory limit and cap mechanism. The child's stderr goes to the
log, pid-tagged and bounded, never near the protocol. A job whose child
died is never retried here.

Memory cap (PAD_MATERIALIZE_MEM_LIMIT, default 2GiB, bytes or
KiB/MiB/GiB, clamped to 1536MiB-16GiB):
- Linux: RLIMIT_AS set on the child by prlimit before its first byte of
  input; the child only runs its own startup uncapped.
- Windows: Job Object with JOB_OBJECT_LIMIT_PROCESS_MEMORY; the child is
  created suspended, assigned, then resumed: no uncapped window.
- macOS: parent-side RSS watchdog every 100ms (proc_info syscall, ps
  fallback).
- Other: deadline kill only.

Not wired into the server yet (U4).

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* ci(materialize): run the supervisor on the real macOS and Windows caps (TASK-2198 U3)

native-smoke now builds the real materializer bundle (as the go job does)
and runs the supervisor, memory-cap and deadline tests on macos-latest
(proc_info RSS watchdog) and windows-latest (suspended start + Job
Object). PAD_MATERIALIZE_TEST_NO_SKIP turns the cap tests' skips into
failures there, so a cap that stops running cannot pass as skipped.

Tests: a real Windows processGone (OpenProcess + GetExitCodeProcess),
RSS samplers for darwin (the production proc_info path) and Windows
(K32GetProcessMemoryInfo), and an allocation helper that writes noise
into every byte so macOS memory compression cannot hide it from the
watchdog.

The worker-loop tests share one loaded Runner through serveWorker
(RunWorker's loop, split out); TestWorkerRoundTrip still goes through
RunWorker end to end. go test -race -count=3 on the package: 714s -> 413s.

docs: the materializer section says the worker and its knobs are inert
until U4 wires it in.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): a worker never outlives its supervising process (TASK-2198 U3, codex r1)

If the server died mid-job (SIGKILL, OOM kill, crash) its worker ran on
with no deadline kill and, on macOS, no memory watchdog. Two layers:

- Linux: Pdeathsig = SIGKILL. The fork runs on a fresh goroutine
  (startUnlocked), never locked to its thread, so it cannot land on a
  thread the runtime later retires (golang/go#27505); Go's forkExec
  re-checks getppid if the parent is already gone.
- Every Unix (the only layer on macOS): the worker command calls
  ExitWhenOrphaned before the bundle loads, polling its parent pid every
  200ms against PAD_MATERIALIZE_PARENT_PID and exiting (status 70) when
  it changes. It writes nothing.
- Windows was already covered by the Job Object's KILL_ON_JOB_CLOSE.

TestSupervisorOrphanedWorkerExits kills a helper process that supervises a
worker hung in a job and requires the worker gone within 1s: 0s on Linux,
105ms with the ppid watch alone; with both layers removed the worker
outlives its parent by 5s. It matches native-smoke's TestSupervisor.*
selection, so it runs on macOS and Windows too.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): relative memory cap, refuse when uncappable, GOMAXPROCS=2; nix-safe ps test (TASK-2198 U3)

The absolute Linux RLIMIT_AS failed on CI: a 64 MiB allocation died under a
1600 MiB cap, because the runtime there reserved more address space than on
the machine the floor was measured on. The cap is now RELATIVE on every OS:
once the worker has loaded and before it is sent any job, its usage is read
as a baseline and the cap is set to baseline + PAD_MATERIALIZE_MEM_LIMIT —
address space on Linux (/proc/<pid>/stat vsize, prlimit, read back),
committed memory on Windows (the Job Object's per-process limit, set after
the suspended start and load), resident memory on macOS (the watchdog's
threshold). Every start logs baseline, limit and effective cap.

Lead ruling, REFUSE: if the baseline probe fails or is implausible (0, or
above MaxPlausibleBaseline = 8 GiB, ~4.8x the largest measured 1704 MiB),
onBaselineProbeFailure kills the worker without sending it the job and
fails the job with ErrNoMemoryCap; one WARN until a probe succeeds again.
An OS with no cap mechanism never starts a worker and refuses every job
with ErrNoMemoryCap, one WARN naming runtime.GOOS.

The worker runs with GOMAXPROCS=2: measured loaded-worker baseline
1357 MiB vs 1597 MiB at this host's default (8) and 1704 MiB at 64.
MinMemLimit drops from 1536 MiB (an artefact of the absolute cap) to
256 MiB: the corpus grows the worker by at most +10 MiB of address space and
+18 MiB resident, and a 4,000-deep nested document by +129..194 MiB.
A cgo worker's "pthread_create failed" at the cap is now classified as a
memory death.

Nix: TestParsePSRSS parses fixed strings only; the live `ps` leg is
TestPSRSSLive and skips when there is no `ps` (the Nix sandbox has none;
ps is only macOS's fallback), unless PAD_MATERIALIZE_TEST_NO_SKIP is set.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): Windows kills on the job's memory notification; steady thread set before the baseline (TASK-2198 U3)

Windows (CI on c1866957): the Job Object refused the 1 GiB commit
(VirtualAlloc errno=1455), but the worker did not die and the job ran to its
deadline. The cap no longer depends on the runtime choosing to crash: the
job is associated with an I/O completion port, and
JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT for the worker's pid kills it at once
(ErrMemoryLimit). errno=1455 on stderr is a second signal. On every OS a
cap mechanism that sees the limit hit calls onLimit, and the kill is
reported as "reached its <mechanism> cap".

Linux (CI's cgo test binary at GOMAXPROCS=64): threads created after the
baseline ate the growth budget. The worker now brings its thread set to
steady state before readiness (warmThreads: an allocating goroutine per P,
then a GC). Measured on a cgo build: threads added after the baseline are 0
at GOMAXPROCS=2 with or without it; at 64, +3 threads and +272 MiB of
address space during the corpus without it, +0 and +0 MiB with it. 256 MiB
stays the floor (largest growth at GOMAXPROCS=2: +193 MiB, a 4,000-deep
nested document; the corpus adds +0 MiB).

The cap test's second leg no longer uses GOMAXPROCS=64 (which also changed
thread creation) to stand in for a bigger host: the helper inflates its
own baseline before readiness, with never-touched address space on Linux,
committed memory on Windows and resident memory on macOS.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* feat(materialize): the worker stops after an idle timeout; the next job restarts it (TASK-2198 U3)

Dave's ruling on the worker process: GO, with an idle exit. A loaded worker
holds ~200 MB resident, which an install that rarely materializes should
not pay for all day.

PAD_MATERIALIZE_IDLE_TIMEOUT: default 5m, clamped to 30s-24h, 0 = never.
After each job the supervisor (re)arms a timer. When it fires, the stop
runs only while holding the job slot, so it never takes a worker with a job
in flight. It re-checks the idle time, forgets the worker WITHOUT recording
a death (backoff is untouched), and closes the worker's stdin so its read
loop ends with status 0, with a kill after a grace if it does not go. The
stop is logged at INFO, reason=idle. A job that arrives meanwhile waits for
the slot, finds no worker, and starts a new one with no backoff wait.

Legs: the idle exit after the timeout (INFO, status 0, no WARN); the next
job respawns and succeeds with no wait; an idle stop leaves the backoff
streak alone (crash, idle, crash waits 2s, not 4s); a job arriving during
the stop runs on a new worker; a timer that fires mid-job does not stop that
worker; NeverIdle arms no timer; env parsing and clamps. Mutations, each red
on its own: recording a death (3 legs), logging as a kill (1), not holding
the slot (1), never arming (3). Docs: the knob beside the other two, and
`idle` in the stop reasons.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): Close does not return while an idle-stopping worker still runs (TASK-2198 U3, codex r1)

An idle stop forgets the worker before it has exited, so a Close in that
window found no worker and returned with it still alive. The supervisor now
tracks the worker an idle stop is ending, and Close kills and reaps it. A
leg pins it, and goes red without the fix.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): an idle stop that Close ended says so (TASK-2198 U3, codex r2)

The idle stop stored its kill reason unconditionally, so it overwrote the
'closed' of a Close that had already killed the worker, and its stop line
said idle. It now claims the reason only if none is set, and the line
reports reason=closed. The Close-during-idle-stop leg asserts it.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 09:52:49 -04:00
xarmian b510d67696 fix(oauth): sweep expired token, code and PKCE rows (BUG-3301) (#1671)
* fix(oauth): sweep expired token, code and PKCE rows (BUG-3301)

Nothing deleted OAuth rows by age. Each refresh leaves the chain's
previous access and refresh rows inactive and inserts a fresh pair,
measured at +1 access and +1 refresh row per refresh (48 rows a day for
one active client at the 1 h access lifetime), forever, on Pad Cloud and
on any https self-host that turns MCP on.

The token reaper now also runs SweepExpiredOAuthRows every hour. A row
goes once its requested_at (its own issuance, measured) is older than
its token's lifespan plus 24 h:
- refresh and authorization codes: the refresh lifespan. A replayed
  rotated token or a reused code keeps revoking the family for as long
  as the token could have been valid, because fosite checks "inactive"
  before "expired".
- access: the access lifespan. Nothing reads an expired access row.
- PKCE: the code lifespan. Only abandoned authorizations leave one.
The lifespans come from the running OAuth server (defaults when OAuth is
not built). No row a live chain or a concurrent exchange could use is
ever below a cutoff.

Deletes run 500 rows per statement with a 20 ms pause between batches.
Measured on SQLite with 50,000 rows and a concurrent token write every
5 ms: with no pause, one write waited 1.55 s; with the pause, the worst
write took 52 ms. The per-tick cap is 200 batches per table.

Connected Apps and the MCP resume count now drop a chain whose refresh
token has been expired for the grace period, so their "upper bound"
comments say how far. The deployment guide's "never swept" gap is gone.

Tests: store cutoffs, zero cutoffs, and batches with the cap, on SQLite
and Postgres. A server end-to-end test through /oauth/token: the live
chain survives and still refreshes; an aged-out chain leaves Connected
Apps and the resume count; a swept token is refused without revoking the
family; a retained rotated token still revokes it. Mutants: inverted age
comparison (store tests fail), refresh cutoff at now (e2e fails: the
live chain's refresh answers invalid_grant).

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* fix(oauth): code retention adds the code lifespan; Capped means "may remain" (BUG-3301, codex r1)

- A code's requested_at is when it was authorized, but the first refresh
  token it yields starts at the exchange, up to a code lifespan later. The
  code cutoff now adds the code lifespan to the refresh lifespan, so reuse
  detection lasts as long as that refresh token could be valid. The
  comment no longer claims "the chain's lifetime": a chain renews forever.
- Capped says the cap was used, so rows MAY remain; the last full batch
  may have been the end.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* fix(oauth): a non-expiring lifespan skips its tables (BUG-3301, codex r2)

fosite reads a negative lifespan as "never expires"; the cutoff math
turned -1 into "now minus the grace", which would delete a live
non-expiring token after a day. A table whose retention depends on a
negative lifespan now gets a zero cutoff, which the store skips (codes
depend on both the code and refresh lifespans).

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-29 19:29:17 -04:00
xarmian 0302977ebe fix(server): bound the per-key rate limiter maps and key IPv6 on the /64 (BUG-3308) (#1670)
* fix(server): bound the per-key rate limiter maps and key IPv6 on the /64 (BUG-3308)

ipRateLimiter trimmed its map only after a key sat idle for the
retention window (30 min, up to 2 h), so a caller with many source
addresses grew any per-address bucket without bound. Measured on one
limiter: 1,000,000 keys (one per /64 under a /32) held 1,000,000
entries and 182.9 MiB of heap.

Two changes:

- A size cap per limiter (rateLimitConfig.MaxEntries, default 65,536).
  At the cap, one sweep to 7/8 of it: first keys idle past retention
  and buckets back at burst (a full bucket is identical to a fresh one,
  so dropping it hands nobody anything), then the fullest buckets by
  whole-token level. A drained bucket goes last, so flooding the map
  cannot push an attacker's own exhausted bucket out and reset it. The
  cap is key-agnostic, which is what bounds AuthEmail: its keys are
  caller-typed email strings, and the plausible-email pre-filter never
  bounded them (its comment said it did; corrected). Same measurement
  after: 61,604 entries, 11.3 MiB. One sweep at the default cap costs
  about 17.5 ms, once per 8,192 new keys.

- IPv6 clients are keyed on their /64 at every per-address bucket
  (rateLimitAddr): the 13 middleware doors, the /mcp pre-auth charge,
  the decision-provider charge and the share-password per-address
  bucket. IPv4 and IPv4-mapped addresses key on the address. clientIP
  is unchanged, so sessions, audit rows and the 2FA challenge binding
  still see the full address.

Tests pin the key table, the cap at caps 1, 4 and 64, eviction order
(a drained bucket survives a 1,000-key flood; full buckets go before
spent ones; idle keys go), and the /64 binding through the router at
every door.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* fix(server): charge limiter buckets under the map lock; rank the sweep below a token (BUG-3308 codex r1)

Codex round 1 findings, each verified in the code first:

- A caller took the *rate.Limiter from getLimiter and charged it after
  the map lock dropped, so a request in flight across a sweep could
  spend a bucket that had been evicted and replaced, getting both the
  old bucket's tokens and the new one's burst. Every production charge
  (18 sites) now goes through ipRateLimiter.allow, which looks up and
  charges under one lock hold. getLimiter stays for tests only.

- The sweep ranked buckets by whole tokens, so a drained bucket shared
  level 0 with buckets holding 0.9 tokens and the cut chose among them
  at random. It now ranks by the fraction of burst in 1,024 fixed
  levels. The fixed size also removes a histogram sized by Burst, which
  panicked for a negative burst.

The eviction comment now states what the order buys as a cost: a
bucket is evicted only once the low-water mark's worth of other
buckets are drained at least as far, so each token handed back costs
that many drained tokens elsewhere.

Sweep at the default cap: 18.2-19.3 ms (3 benchmark runs). 1,000,000
keys: 61,968 entries, 11.4 MiB.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* docs(server): state what the /64 key and the eviction order promise (BUG-3308 codex r2)

Codex round 2 found no charge outside the lock and no missed key.
Prose it found false, corrected:

- The share-password per-address bucket said one caller could not lock
  out other viewers. Under the /64 key, viewers sharing a /64 share a
  bucket, as viewers behind one NATed IPv4 address always did. Both
  comments now say so.
- The eviction comment claimed each token handed back costs a fixed
  amount of drained tokens inside a refill window. It now states the
  condition the code enforces: a bucket is evicted only when the
  low-water mark's worth of other buckets hold no more tokens at that
  moment, to within burst/1024. It also states the sweep's lock hold.

Tests: a sibling address in the /64 is refused with the RIGHT share
password too (the gate is charged before the compare), and an
IPv4-mapped address shares the IPv4 bucket through the router.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* test(server): say what the flood test does, not a sweep count (BUG-3308 codex r3)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* docs(deployment): rate buckets key an IPv6 client on its /64 (BUG-3308)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* docs(deployment): the MCP and OAuth limits key an IPv6 client on its /64 (BUG-3308)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* test(server): freeze refill in the /64 binding tests (BUG-3308, CI run 36635279128)

The door test exhausted a real bucket and then probed a sibling address.
Under -race in CI, collab_dial (5/s) refilled past one token between the
429 and the probe, and the sibling got through. The tests now set every
limiter refill to ~0 before the first request, so they measure keying,
not timing. Control: with a 250 ms gap before the probe and refill live,
collab_dial, api and auth_default_api fail; frozen, the same run passes.
Every keying mutant is still killed.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 19:22:17 -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
xarmian 5d641b70d0 docs: MCP over HTTP on a self-hosted install (TASK-3304, PLAN-2310 U7) (#1669)
* docs: MCP over HTTP on a self-hosted install (TASK-3304, PLAN-2310 U7)

docs/deployment.md gains "MCP for agents (self-hosted)": off by default;
the origin (PAD_URL / toml url / --url, else PUBLIC_URL, with no
host:port fallback), PAD_MCP_PUBLIC_URL, PAD_AUTH_SERVER_URL and
PAD_MCP_ENABLED with their precedence and startup-time nature; the admin
toggle (GET/PUT /api/v1/admin/mcp, the env lock, the readiness readout);
https means OAuth plus tokens and http tokens only; the reverse-proxy Host
requirement and PAD_TRUSTED_PROXIES; and the known gaps. .env.example,
CLAUDE.md's API and MCP sections, and README follow.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* docs: .env.example says MCP's toggle and origin are self-host rules (TASK-3304, codex r1)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* docs: the MCP host check and rate limits as merged (TASK-3304, PLAN-2310 U7)

Checked against U3 (#1665) and U4 (#1667) on main:
- The 421: honours X-Forwarded-Host only from a PAD_TRUSTED_PROXIES
  peer; the body names the received host, its header and the configured
  origin; the first refusal per host is a rate-limited WARN; it runs
  before auth and after the availability gate.
- The DR-9 limits as a table. Claim redemption is keyed per signed-in
  user, not per address, as the earlier draft said.
- Pre-auth /mcp refusals are counted in pad_mcp_preauth_denied_total
  with a WARN per address, not audit rows. The once-per-process WARN
  when a proxied request resolves inside PAD_TRUSTED_PROXIES.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* docs: the console toggle and the pre-auth counter's scope (TASK-3304, codex r2)

- The console now has the MCP section (#1666): Admin → Settings → MCP for
  agents. The "console does not show it yet" sentence is gone, and
  .env.example names both ways to toggle.
- codex r2: pad_mcp_preauth_denied_total counts missing/invalid-token
  refusals and the pre-auth 429s, not the 421 host refusals, which
  happen before it.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* docs: OAuth follows the issuer URL; tokens keep their scopes (TASK-3304, codex r3)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-29 17:29:55 -04:00