Skip to content

Durable afterCommit for user-installed plugins, behind --plugin-after-commit (#1851) - #1906

Closed
michielbdejong wants to merge 137 commits into
feat/plugin-debugfrom
claude/durable-after-commit
Closed

michielbdejong wants to merge 137 commits into
feat/plugin-debugfrom
claude/durable-after-commit

Conversation

@michielbdejong

Copy link
Copy Markdown
Contributor

Closes #1851

Implements the design in #1888 (planning/durable-after-commit.md, carried in this branch and updated) with Michiel's four decisions. Everything is behind --plugin-after-commit / ATOMIC_PLUGIN_AFTER_COMMIT, which is off by default. With the flag off, no subscription is recorded, no wake-up marker is written, and the entrypoint does nothing.

What this is stacked on

Base: feat/plugin-debug. The branch starts from claude/atomic-plugins-pin-candidate14 (1432e24). On top of that it carries:

Until the stack lands, the diff shows all of it. Please review these commits:

Commit What
bf22a7c atomic_lib: wake-up markers staged inside the change log's transaction; table_changes_head
73c522e Server: subscriptions, delivery, cursor, review, retries, loop cap, reads scope, runtime entry, /app-after-commit, tests
69e0ca8 createApp apps; reserved routes; @tomic/lib manifest mirror plus shared fixtures; browser/lib integration test
ba17a3c UI for decisions 1–3
98dba07 Docs, planning status and deviations, TESTING_COVERAGE.md

Decisions, as built

  1. Adding the view subscribes the app. The "Let edit rows?" dialog gains the line " is told when rows change, also when this tab is closed." The line appears only when the server has the flag on and the app exports afterCommit. Allow editing (op: "grant") subscribes on the server in the same request. Read-only now sends op: "follow".
  2. Proposals wait per table. A bar above the app's tab reads " wants to change 3 rows", with Apply, Allow all edits by this view on this table, and Decline. The second button is offered only when every change stays within what a row grant can cover. It records a grant with via: "hook-review". The tab gets a mark, and the Installation page shows the count, linking to the table.
  3. Failures stay quiet. After 8 failed attempts, the tab and the Installation page show a warning mark with " stopped following changes to :", the error, and Retry. There is no notification.
  4. The app pauses per table. While a proposal waits there is one delivery in flight, so nothing new is claimed. After the answer, the app catches up on the markers that coalesced meanwhile.
  5. Server

    • lib/src/after_commit_wake.rs: a table → marker index on the store. stage_change_log upserts markers in the same transaction as the change-list entries. That covers commits, replicated resources, imports and sync removals.
    • server/src/plugins/after_commit.rs:
      • Subscriptions per (drive, app, table).
      • Eligibility: a view, a table, the activator can read it, the installation resolves with its own key, and the manifest declares afterCommit.
      • Claim after a debounce (2 s after the last change, or 10 s after the first), then a page of the change list read as the activator.
      • Echoes are dropped by Loro version. The cursor moves only in the acknowledging batch. A delivery whose journal already finished is acknowledged without running again.
      • CURSOR_EXPIRED → reset: 'expired'. A new subscription starts at the head with reset: 'initial'. hasMore continues on the next pass and does not count toward the loop cap.
      • Backoff runs from 30 s, doubling to 1 h. After 8 attempts delivery stops until Retry, or until the source changes.
      • The loop cap is 30 woken runs a minute. Each run gets 60 s of wall clock plus the JS fuel and memory limits.
      • A sweep runs at startup and every 10 minutes, and the grant backfill runs at startup.
    • Writes: with a live grant, changes within check_scope (extras included) and changes to the app's own subtree apply unattended, signed by the app agent. Anything else is held for review.
    • Reads: the host wrapper limits ctx.read and ctx.query to the table, its rows, the row class and its properties, and the app's subtree. ctx.changes(table, {since, limit}) works for the event's table only.
    • Runtime: plugin-runtime calls input.entry (run or afterCommit), and ctx.changes goes through host-call. The WIT is unchanged.
    • The same worker loop as triggers runs a sibling drain (after_commit::tick).
    • GET/POST /app-after-commit: status, answers and Retry.

    Deviations from the design

    Details are in the doc's "Implementation notes".

    • Records live under after-commit/v1/{sub,wake,delivery,own}/, not plugin-event/v1/. The trigger drain reads every plugin-event/v1/ entry as a QueuedEvent, and it deletes any whose trigger record is missing, so a shared prefix would have lost deliveries. The worker loop, journal and run log are shared.
    • The marker is claimed (under the change-log lock) before the page is read. A crash between the two loses only the marker, and the sweep recovers it.
    • Subscriptions are keyed by the app that view-kind names, as the grant is. Code comes from the Installation above the app if there is one. Otherwise it comes from the app's own plugin-source or its entrypoint (createApp apps). Apps without an Installation show their state on the tab only.
    • follow records nothing when the app does not export afterCommit.
    • A page that holds only the plugin's own echoes is acknowledged without a run.
    • A reviewed "Apply" cannot delete rows the app does not own.

    Left for later

    • Deleting subscriptions on uninstall or revoke. For now they wait, as for a paused installation.
    • Ending a subscription immediately when the activator loses read. For now it ends at the next attempt.
    • Refusing a waiting proposal after a release change.
    • A Playwright spec (optional in the brief).
    • A test for "a person's edit just before the hook's write".

    Tests

    • atomic_lib (change_log_test.rs): a marker in the same batch, none for other tables; coalescing; replicated and sync-removed rows; no index, no marker; the head cursor.
    • atomic-server (plugins/after_commit_test.rs, 22 tests with a fixture JS plugin installed from a release), covering:
      • delivery at the head, then per change;
      • a paused installation skipped without counting attempts;
      • a throw redelivering the same page with attempt: 2;
      • 8 failures stopping the table with the cursor unchanged, then Retry;
      • the crash window (a finished journal acknowledged once);
      • CURSOR_EXPIRED;
      • 210 rows arriving in 3 pages outside the cap, and the loop cap itself;
      • echo dropping, with a person's edit after it still delivered;
      • a write under a grant signed by the app agent;
      • review: Apply, Allow all and Decline, with the pause and catch-up;
      • out-of-scope writes;
      • ctx.changes refused for another table;
      • view-kind set by hand, and a kind change ending the subscription;
      • the sweep, the grant backfill and the flag off;
      • durability across a hard restart (subprocess exit, reopen, delivered).
    • manifest.rs plus shared fixtures: afterCommit is accepted in extension and refused in server-extension; onResourceGet and beforeCommit are refused.
    • browser/lib integration (tests/after-commit.integration.test.ts): a real server with the flag. A row saved through store.save reaches the hook, and its edit waits for review.
    • vitest: AfterCommitBar.test.tsx, afterCommit.test.ts, appViewGrant.test.ts (Read-only follows; Allow editing sends no second request; retried while the view saves).
    • Run:
      • cargo test -p atomic_lib --features db-redb --lib: one failure, drive_mapping_tests::reconcile_skips_empty_hosts_and_drives. It passes on its own, reuses a fixed temp-dir name, and has nothing to do with this change.
      • cargo test -p atomic-server --lib, with and without --features plugin-routes: all green after the fixes in 69e0ca8. Those were the pinned release id for the new fixture and the reserved routes app-after-commit and Per-table change list: rows changed since a cursor, including deletions (tombstones) #1850's changes.
      • Clippy: -D warnings for light,wasm-plugins (the hook's set) and for plugin-routes.
      • pnpm typecheck, oxlint and oxfmt.
      • The .po files are settled: a dev-server run afterwards leaves them unchanged.
      • useDriveApps.live.test.tsx fails on Node 26 (localStorage). That failure predates this change.

    Screenshots

    Taken from the real app (vite plus a server with the flag). The setup: an app made with createApp inside an Installation, shown on a "Team calendar" table outside the app; three rows saved; the hook proposes a description for each.

    • the dialog line, at desktop and 375 px;
    • the review bar with the tab mark, at desktop and 375 px, followed by Allow all applying the edits;
    • the Installation page count, at desktop and 375 px;
    • the warning mark with Retry, at desktop and 375 px.

    The stopped state takes 8 failures over about 2 hours, so for that shot alone the status response is rewritten in the browser to stopped. The screenshots are shared in the coordination thread.

    🤖 Generated with Claude Code

Michiel de Jong and others added 30 commits September 23, 2026 10:50
An app's agent resource syncs with its drive, but the key that signs as
that agent does not. The key is posted once to POST /app-agent and stays
on the node it was posted to. Any other node reads the missing key as
Legacy. Scheduled, trigger and plugin runs there then fell back to the
server's own agent, so the app's writes were attributed to the server and
the app's own rights no longer bounded them.

installation::resolve now refuses when a Legacy resource has an app
identity. An app identity means an agent DID in the resource's write list
whose agent resource is in the drive's app-identities folder, which is
where createApp puts it. Plugins without an app identity keep the legacy
signer.

Refs ontola/atomic-plugins#41, ontola/atomic-plugins#54

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A catalog.json entry can now name an installable drive app: `app-module`
(the built ES module for exactly `version`), `app-module-integrity` (its
SRI hash) and optionally `app-row-name`/`app-row-name-plural`. This is
distinct from `pluginUrl`, which is a source link.

- @tomic/lib `catalog-app.ts`: parse an entry, fetch its module and refuse
  it unless its bytes match the pinned integrity, then `createApp` (app,
  ontology, row class, table, entry point, identity) and record where it
  came from (`app-catalog-id`, `app-version`, `app-module`,
  `app-module-integrity`, drive-local terms). `updateCatalogApp` replaces
  only the entry point's source via `updateApp`, so rows, schema, identity
  and rights survive. `catalogAppState` offers install / update / nothing,
  never a downgrade.
- data-browser: the catalog parser keeps app entries (module resolved
  against the catalog URL); the Integrations page gets a "Drive apps"
  section with Install / Open / "Installed <version>" / "Update to
  <version>", gated like the rest of the page (enabled, experimental,
  API plugins). Install hands the app key to the node as the New menu does.

For ontola/atomic-plugins#94.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without these entries the new Integrations section renders its headings,
copy and labels empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plugin can declare `accepts` (extensions, media types, maxBytes, as
text) and a `destination` (a schema and one table) in its version-two
manifest. PluginPage then shows an Import tab instead of Run, Schedule and
Trigger: Set up creates the destination table beneath the plugin and
stores { table, rowClass, properties } as its config; choosing a file
decodes it (UTF-8, else Windows-1252), runs the plugin on the server with
input.upload, and reviews the verdict in RunPluginDialog before anything
is written.

/plugin-run refuses an upload the manifest does not accept or that is
larger than its maxBytes, and takes bodies up to PAYLOAD_MAX so a 5 MB
statement fits after JSON escaping. Both manifest parsers accept the new
fields and share the conformance fixtures.

This replaces the plugin-specific ImportMT940 dialog removed in 4bab16e
with a generic entry point, for atomic-plugins' Bank statements importer
(atomic-plugins#95).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app's save goes to /app-write, so the server makes the commit and this
page's store keeps its old copy of the resource. The app's next `get` is
answered from that copy: it reads its own write undone. The Calendar drive
app (ontola/atomic-plugins#101) keeps a per-row sync baseline and ETag, so a
stale read turned its next refresh into false conflicts right after a
successful send.

After a save lands, fetch the resource from the server again. Best effort:
the write already succeeded, so a failed re-read is not reported to the app
as a failed write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…talog app install)

Integration branch for ontola/atomic-plugins' .atomic-server-ref while both
PRs await review. Not for merging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Table's genesis commit must carry its classtype, so Set up now creates
the table and view with all their properties instead of setting them after
creation. The new UI strings are added to the wuchale catalogs, without
which they render empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two bugs in the drive-plugin write path, found while adding the
timesheets drive-app e2e in atomic-plugins (#96/#97):

- view-client.js `resource.remove(p)` only dropped `p` from the frame's
  buffer. `save` then sent the remaining props, and `/app-write` `save`
  only sets, so `p` stayed on the server. The frame now tracks removed
  properties and sends them as `remove`; hostStore turns that into an
  `/app-write` `remove` op (which the server already had).
- After an `/app-write`, the page's store kept its cached pre-write copy,
  so the view's next `get` returned stale values. An importer comparing
  incoming values with stored ones re-wrote its own last write on every
  sync, and a write carrying an import baseline (`importBaseline`) is
  rejected by `validate_baseline` as stale. hostStore now refetches the
  saved resource from the server (best effort).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- view-client.js `save` now sends only the properties set since the last
  save (plus `remove`), instead of every property the frame read.
  Re-sending everything wrote back whatever the host keeps alongside the
  values.
- hostStore's post-write refresh uses `forceOverride`: a merge kept
  properties a write had removed in the page's copy.

Known open issue: in the atomic-plugins timesheets e2e against this
branch, a property removed through `/app-write` `remove` is still present
when the row is fetched from the server afterwards. The frame sends the
removal and no error comes back; the server-side remove path
(`StoreApplyHost::remove`) needs a look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	browser/data-browser/src/locales/de.po
#	browser/data-browser/src/locales/en.po
#	browser/data-browser/src/locales/es.po
#	browser/data-browser/src/locales/fr.po
…plugins#54)

A v1 request signature covers only "{url} {timestamp}" and stays valid for
five minutes, so a captured POST (e.g. /app-agent, which carries an app's
private key) can be replayed with a different body. v2 adds the method and
the SHA-256 of the body:

    atomic-request-v2\n{METHOD}\n{full URL}\n{timestamp ms}\n{sha-256 hex}

selected by `x-atomic-signature-version: 2`, alongside the existing
x-atomic-* headers.

- atomic_lib: `AuthValues.request` (serde-skipped, so cookies and WS AUTH
  can never be v2) selects the v2 check; it never falls back to v1 and has
  no query-less retry. Strict Ed25519 verification. Exports
  `request_signature_message_v2`, `sha256_hex`, `RequestBinding`, and
  `client::get_authentication_headers_v2` for Rust callers.
- server: `get_client_agent_for_request(req, body, ..)` binds method and
  body; `/app-agent` and `/plugin-view-token` use it (raw bytes instead of
  web::Json so the hash is of what is parsed). They accept v2 but do not
  require it yet. Every other endpoint refuses a v2 signature with a 401
  asking for v1; an unknown version, or v2 without the x-atomic-* headers,
  is refused too. CORS already echoes any requested header; a test pins
  that `x-atomic-signature-version` passes a preflight.
- @tomic/lib: `signRequest(url, agent, headers, { method, body })` signs v2
  over `new URL(url).href`; a string fourth argument is still the legacy
  subject. `Agent.generateNonExtractable()` makes a non-extractable
  WebCrypto Ed25519 agent (`atomic:agent:<pub>`) and throws a clear error
  where Ed25519 is missing (#1688) rather than falling back.
- Cross-language vectors in lib/src/authentication_v2_vectors.json (mirror in
  browser/lib/src, drift-checked by a Rust test): produced and verified in
  Rust, reproduced byte-for-byte in TypeScript; a non-extractable WebCrypto
  signature is verified too.
- docs/src/authentication.md describes v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lay (atomic-plugins#54)

Replaces #1657's `proxy` relay op, which kept LocalThought's rotating
connection code in the page and made every call for the frame. The #54
proxy has no tenant login and no connection codes (decision 6, flag day),
so the relay is removed rather than kept alongside.

Frame (server/src/plugins/assets/view-client.js):
- generates an Ed25519 key in memory, non-extractable, on first use; fails
  with a clear message where WebCrypto has no Ed25519 (#1688);
- asks the page for a capability (`proxyCapability` {platform,
  connectionId, publicKey}), caches it until 60 s before `exp`;
- fetches `{aud}/proxy/{connection_id}/{platform}{path}` itself with
  `Authorization: Capability <payload>.<sig>` and v2 request-signature
  headers signed by the frame key; re-mints once on 401
  `capability_expired`. `store.proxy.request(...)` keeps its signature and
  `{status, headers, body}` result.

Page (helpers/proxyConnections.ts, AppFrame, ProxyConnectReturn):
- every call to the proxy is signed v2 with the user key, as
  `atomic:agent:<pubkey>`: POST /connect/redeem, GET /connections,
  POST /connections/{id}/agents, DELETE /connections/{id}[/agents/{agent}];
- /connect without `user_id`; the PKCE verifier is the only thing kept in
  localStorage, for the handoff window;
- after redeem, the connection is delegated to the app's agent (from
  GET /app-agent); the connect bar also offers an existing connection for
  the platform, which only adds the delegation;
- mints capabilities only for a connection the user owns, of that platform,
  delegated to this app: claims {v:2, connection_id, platform, aud, app,
  cnf: atomic:agent:<frame key>, exp: now + 600 s}, payload base64url(JSON),
  signature by the user key over "integration-proxy-capability-v2\n" + JSON.
- Apps without their own agent (installed catalog plugins) are told they
  cannot be given a connection yet (#54 decision 2, later).

A fixed capability vector (user key of authentication_v2_vectors.json) is
pinned in proxyConnections.test.ts for the proxy's tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode agent (#1700)

Server-side plugins reach the integration proxy through ctx.http, signed by
the host (ontola/atomic-plugins#54, decisions 8 and 12):

- --integration-proxy-url / ATOMIC_INTEGRATION_PROXY_URL: the proxy origin,
  validated at boot and held on the Db.
- HostCore::fetch signs requests to exactly that origin with a v2 request
  signature as this node's app agent for the installation
  (get_authentication_headers_v2 inside with_app_agent). Plugin-supplied
  x-atomic-* headers are dropped; an installation without an agent on this
  node is refused before connecting.
- egress::ProxyOrigin / destination_addresses: a loopback exception for
  exactly the configured scheme, host and port, connecting to loopback
  without a lookup. Every other loopback or private target stays refused.
- A test that GET /app-agent reports the identity activation mints for a JS
  Installation (decision 2): catalog installations already get one per node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app's `resource.remove(p).save()` reached the server and stuck there,
but the host page's copy got `p` back. The post-write refresh goes over
the WebSocket, where a SNAPSHOT-flagged GET makes
`Resource.importLoroUpdate(bytes, replace = true)` reset the Loro doc but
keep `#cache`, which still held the old values. `getLoroDoc()`'s heal pass
then wrote every key the snapshot lacked back into the doc as a local op.
The replace path now clears the cache first, except for server-derived
keys.

Tests: a vitest for the replace path, an apps e2e that removes a value
from inside a real frame, and an app-write handler test showing the
server side was already right.

Also log "rights walk TERMINATED" at debug when the walk simply reaches a
root with no grant (the drive-first probe for an app's agent, query
member filtering). Keep warn only when a parent is named but cannot be
loaded, which is where a real 401 comes from. Rights evaluation is
unchanged.

Closes #1703
Closes #1704

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… connections (#1700)

Answers 1-3 of #1700 (ontola/atomic-plugins#54, phase 2), provisional:

- integrationAppAgent: a keyless atomic:agent:<pubkey> that installRelease
  mints in the genesis the user signs, discarding the private key. The
  server refuses a value that is not an agent id, and refuses to change it
  once set.
- On activation, each node publishes the agent it minted for a JS
  Installation on an InstallationRuntime child, signed by and writable by
  that agent, so the page can register it with POST /runtimes.
- integrationConnections ({platform: connection_id}) reaches server-side
  JS runs as ctx.connections, with the app id as ctx.app, next to
  ctx.config. The host's values replace any the caller sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…red proxy (#1700)

Answer 4 of #1700. A manifest declares proxy: ["clockify"] and operations on
atomic-proxy:/clockify/... URLs. The host resolves a ctx.http request to
atomic-proxy:/clockify/<path>?<query> as
{--integration-proxy-url}/proxy/{connection_id}/clockify/<path>?<query>
(integration-proxy's route), with the connection from the Installation's
integrationConnections, then signs it as in #1702.

It refuses an undeclared platform, a platform with no delegated connection,
a node with no proxy configured, no matching operation, and dot segments.
Absolute proxy URLs keep working and log a deprecation warning. The browser
manifest validator mirrors the rules, and shared fixtures check both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #1725 (atomic-proxy: scheme) with #1710, #1702 and #1696.

Conflicts with #1691 (plugin accepts a file), resolved keeping both:
- browser/lib/src/plugin-manifest.ts: #1691's validateDestination and
  #1725's parseProxyRelative / proxy platform rules side by side.
- testdata/plugin-manifest/index.json: union of the accepts/destination
  and proxy fixtures.
- server/src/handlers/app_endpoints_test.rs: union of the app-write remove
  test and the v2 signature / installation identity tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hase2

#1697 replaces the frame's proxy relay with direct integration-proxy
capability calls; that replacement is kept.

Conflicts:
- AppPage/hostStore.ts: HostRequest keeps #1690's `remove` and #1697's
  `publicKey` (same call as batch #1699); the relay's path/method/query/
  body/ifMatch fields go, as #1697 intends.
- locales/*.po: union of both sides' new entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `plugin-routes` Cargo feature (implies `wasm-plugins`), in no default or
  release feature set. A unit test expands `default`, `light`,
  `light,wasm-plugins`, `https,wasm-plugins` and atomic.place's
  `https,telemetry,img,wasm-plugins` and fails if any turns it on.
- `--plugin-routes off|read-only|read-write` / `ATOMIC_PLUGIN_ROUTES`
  (default off), `--routes-origin` / `ATOMIC_ROUTES_ORIGIN`,
  `--plugin-listeners` / `ATOMIC_PLUGIN_LISTENERS`, `--plugin-sidecars` /
  `ATOMIC_PLUGIN_SIDECARS`, in every build. Without the feature, any of them
  set refuses to start; listeners and sidecars need read-write; the routes
  origin is validated like the website origin (shared helper).
- `Config::plugin_routes_level()` and `PluginRoutesConfig::allows()` for the
  install checks that come next.
- `/plugin-catalog` answers `{ entries, hostFeatures: { pluginRoutes } }`;
  the data browser reads both that and the old bare array.
- CI runs the lib tests and clippy once more with the feature.
- Docs: installation.md and plugins.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s app id (#1700)

Gap 1: the page now calls the integration proxy's runtimes routes
(ontola/atomic-plugins#122), signed v2 with the user key:
- POST /runtimes {app, agent, label} for each InstallationRuntime child
  whose genesis the named agent signed, with the Installation's
  integrationAppAgent as app and the node's name as label. Runs when an
  Installation page or app frame opens, when a runtime child appears, and
  after a delegation. Skips runtimes already registered in this page or
  listed by GET /connections for the same app and label; the proxy upserts
  anyway. In the background; failures are toasted.
- DELETE /runtimes/{agent} on revoke and on uninstall.

Gap 2: appAgentOf prefers the Installation's integrationAppAgent and only
falls back to GET /app-agent (createApp apps, older Installations), so
delegations and frame capabilities name the app id rather than one node's
agent.

Merges #1697 (claude/frame-proxy-capability), which holds appAgentOf and the
v2 ProxyConnections this builds on and is not in #1725's stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An `accepts` entry takes an optional `as: "text" | "base64"`, text by
default. With base64 the host hands over the file's exact bytes as
`input.upload.base64`, with no charset detection; text mode still
arrives as `input.upload.text`, so a plugin can tell them apart.

- `as` is kept as declared: left out, it stays out of the serialized
  manifest, and an explicit "text" stays in, so no release id moves.
- Both validators accept text or base64 and reject anything else with
  the same message, checked against new shared fixtures.
- The server checks the upload is in a declared encoding and bounds it
  by its raw size: the decoded length for base64, not the string's.
- The data browser picks the matching `accepts` entry and reads the
  file with `readUpload` from @tomic/lib.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ProxyOrigin` only exempted a literal loopback address or `localhost`, so
`--integration-proxy-url http://host.docker.internal:8787` (192.168.65.x on
Docker Desktop, 172.17.0.1 on Linux) or a LAN proxy was refused. Exactly the
configured scheme, host and port may now resolve to loopback, private, CGNAT
or ULA addresses. The name is resolved once per request and those checked
addresses are pinned into the client. Link-local and metadata addresses stay
refused even for the proxy (and a literal one is refused at startup), and so
do credentials in the URL. Every other destination keeps all the checks.

Adds a `Resolve` seam so tests can make a name resolve to a private
address, and an `it plugin_proxy` test that runs a JS plugin through
`POST /plugin-run` on a real server and checks that `ctx.http` reaches a stub
proxy as `GET /proxy/conn-1/demo/items`, v2-signed by the installation's node
agent, and that an undeclared platform is refused.

Refs #1700

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1712)

Manifest schemaVersion 3 adds an optional `http` block (routes,
well-known claims, write targets, keys, tokens, listeners, sidecars),
validated in every wasm-plugins build, not only behind plugin-routes, so
a node without the feature can say precisely why it refuses a release.
The block is left out of the serialization when empty; the release ids
of every accepted v1/v2 fixture are pinned in release-ids.json.

From the block the host computes the gate a release needs (none,
read-only, read-write, plus listener and sidecar names) and derives the
`requires` list (public-origin, plugin-routes:<level>,
operator-listener:<name>, operator-sidecar:<name>, ...), which
/plugin-catalog now carries per entry.

Installation activation (install and upgrade) and /plugin-release-pin
compare the release with the node's gates from #1711 and refuse with the
messages of the design's 0.4. The pin endpoint answers 409 with the
typed problem host-feature-unavailable; @tomic/lib throws it as
HostFeatureUnavailableError. A refused upgrade is a refused commit, so
the old release keeps running.

@tomic/lib mirrors validation, gate, requires and the refusal; both
sides run the shared fixtures http-index.json and http-refusals.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…didate2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict: testdata/plugin-manifest/index.json, #1725's proxy fixtures
against #1730's accepts-as fixtures. Kept both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1700)

Flow a, answer 3: the Installation page shows a Connections section for
each platform the pinned release's manifest declares in `proxy: [...]`
(plus any still recorded). Per platform, "Connect <platform>" and, when
the user already has one, "Use existing connection"; both go through a
consent step (the shared ProxyConsentBar, also used by AppFrame), then
reuse helpers/proxyConnections.ts: /connect, the signed redeem, and
POST /connections/{id}/agents {agent: integrationAppAgent}. After that,
integrationConnections[platform] = connection_id is written onto the
Installation in a commit signed by the user (the /connect return records
it in ProxyConnectReturn via finishProxyReturn). Disconnect removes the
delegation at the proxy (404 counts as gone) and the key, dropping the
property once empty. Only people who can write the Installation see the
controls. TODO for #1724's ProxyTrafficNotice, which is not on this base.

Don't contact the proxy for nothing: GET /connections and runtime
registration only run when the manifest declares `proxy` or the
Installation already has integrationConnections, both on the page and in
registerInstallationRuntimes (which app frames call too).

Tests: the connect flow writes integrationConnections; use-existing
delegates and records; disconnect calls the proxy and removes the key
(kept when the proxy refuses); zero fetches to the proxy for a plain
Installation; controls hidden for non-writers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Michiel de Jong and others added 17 commits September 25, 2026 17:17
#1832 wrapped the state-changing routes that existed on its base. This
line added three more POST endpoints after it, and they now go through
the same require_v2 middleware and replay cache:

- POST /app-row-grant (grant and revoke an app's rows grant, #1740)
- POST /plugin-route-consent (the /app/route-consent answer, #1759;
  plugin-routes feature)
- POST /plugin-route-tokens (route-token revoke, #1759; plugin-routes
  feature)

GET on all three still takes v1. /plugin-route-status is GET only and
unchanged. The handlers read the verified agent via get_client_agent_of.

Browser: rowGrant.ts (grant/revoke), routeStatusApi.ts (revoke) and
RouteConsentRoute.tsx (approve/deny) sign those writes with
signedRequestInit.

Tests: the three routes join v2_required_routes (the consent and token
routes under plugin-routes); app_row_grant_test posts everything v2, which
also covers /app-write's row-grant path; route_crypto_test signs its POSTs
v2 and checks a v1 answer is refused; the now unused signed_as helper is
dropped. plugin-manifest-http.test.ts's authentication mock gains
signedRequestInit, which /plugin-release-pin now uses after the merge.
Docs and changelogs list the routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The end-to-end proxy test (added on this line after #1832's base) pins,
installs and runs over HTTP with a v1 signature, which /plugin-release-pin
and /plugin-run now refuse. Writes sign v2 over the exact JSON body sent;
reads keep v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A signed fetch carries x-atomic-* headers, which are not CORS-safelisted,
so every cross-origin vocabulary GET needed a preflight. GitHub Pages
answers that preflight with 405, costing one failed request and a console
error per term per session.

fetchResourceHTTP now signs only requests to the own server: the store's
server URL, a drive on one of its subdomains, or the page's origin (legacy
origins keep their existing signing). Anything else is a plain GET with
only the safelisted Accept header.

Closes #1837

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 31898a0)
fetchResourceHTTP signed the request before swapping in the /path proxy
URL, so the signature covered the external subject while the server
verified it against the proxy URL it received, and refused it (401).
Build the proxy URL first and sign exactly that.

When a direct fetch of a foreign plain document fails (network, CORS,
5xx), the Store now retries once through the own server's /path proxy,
which fetches and keeps external vocabulary. The answer takes the same
applyIncoming path as a direct one, so it is cached the same way. An
origin the proxy served goes to the proxy first for the rest of the
session; if that fails, the origin itself is tried once. Queries,
collections, POSTs and the own server are never proxied.

5xx responses other than 500 are now typed as server errors too.

Closes #1838

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 859cb9d)
GitHub Pages serves the shared ontology's extensionless JSON-AD files as
`application/octet-stream`. None of the external fetch paths looks at the
Content-Type: the browser client, the server behind `/path`, and the
server's own `fetch_resource` all go by the body. JSON-AD parses under any
type, and HTML or binary is refused under any type because it is not JSON.

These tests keep it that way, since a Content-Type check would break the
shared ontology silently:

- Unit stubs serve terms as `application/octet-stream`, like Pages, and the
  client is checked on octet-stream, text/plain and a missing type, and
  refuses HTML and binary served as octet-stream.
- The integration test adds a direct fetch from the octet-stream stub, and
  HTML under that type is refused both directly and through the proxy.
- A Rust test covers `fetch_resource` on a loopback server for the same
  three cases.

A strict `json` Content-Type check, added temporarily, failed each of them.

Refs #1837 #1838

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fa72d4c)
The picker's search goes to the server, which only indexes the drive and
atomicdata.dev, so a shared class published elsewhere (atomic-plugins
Pages) was only reachable by pasting its URL. The search window now also
matches external classes the store has already fetched, on shortname,
name and description, drops the ones the server returned too, and shows
each one's origin next to its title. Pasting a URL still selects it.

Closes #1839

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c4090c1)
useDriveApps read the drive's apps once, on mount, so an app created in
another tab or synced in shortly after navigating (or a change to its
`renders`) stayed out of the table's "+ Add view" and "View type" menus
until a reload.

After the mount query, the hook now listens to the store's
ResourceUpdated and ResourceRemoved events (fired for local saves, remote
UPDATE pushes and drive sync alike) and folds each resource in by reading
its class and `renders` directly, so no /query round trip or index lag is
involved. The listener is attached before the query runs and wins over it
for any subject it has already seen.

Closes #1846

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 1ea2c2d)
The padding rule flags four statements from the base branch; the
pre-commit hook lints the whole browser tree, so fix them first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An App declares per-property row-extras. /app-write lets a granted app
write the extras it declared; a grant covers the list it was given with,
so widening the list asks again and keeps the old grant as superseded.
Another app's properties are refused. The grant dialog names the extras,
the row dialog folds them under a read-only "Kept by apps" section, and
the table shows no extra columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rows of a table changed since an opaque cursor, including rows that left
it, each with its Loro version, for plugin two-way sync ("hook for speed,
change list for correctness").

- atomic_lib::change_log: a durable per-table log in Tree::TableChanges,
  written in the same transaction as the state by apply_commit, replicated
  resource persistence and sync removals. One entry per row, seq allocated
  under a lock held until apply, tombstones pruned after a configurable
  retention (30 days), typed CURSOR_EXPIRED below the floor, backfill of
  pre-existing rows on first read, rebuild when a table's classtype changes.
- GET /changes?table=&since=&limit= with read rights on the table and rows.
- @tomic/lib Store.getTableChanges and fetchTableChanges.
- --table-change-retention-days, docs page, TESTING_COVERAGE entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 6b9243b)
A per-table index of wake-up markers, set by the server. Every change-list
entry for an indexed table also upserts its subscription's marker in the
same transaction, so commits, replicated resources, imports and sync
removals all wake the hook, and a crash cannot keep the change but lose
the wake-up. Claims and out-of-commit upserts take the change-log lock.
Adds table_changes_head: a cursor at the head, after the backfill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Behind --plugin-after-commit / ATOMIC_PLUGIN_AFTER_COMMIT, off by default.

- Subscriptions per (drive, app, table), made by the row-grant gestures:
  Allow editing (op grant) and Read-only (new op follow) both subscribe.
  Ended with the View (removed, kind changed), the table, or the
  activator's read; backfilled at startup for views with a live grant.
- Delivery from the change-log markers through the trigger worker's loop:
  debounce, claim, a page of the change list read as the activator, the
  cursor moved only in the acknowledging batch, a finished journal
  acknowledged without a rerun. CURSOR_EXPIRED is reset: 'expired'; a new
  subscription starts at the head with reset: 'initial'.
- The runtime calls input.entry (afterCommit) and offers ctx.changes for
  the event's table only; reads are limited to the table, its rows, its
  row class and properties, and the app's own subtree.
- Retries with backoff (30 s to 1 h), stopped after 8 failed attempts
  until Retry or a new release; the loop cap of 30 woken runs a minute.
- Echoes dropped by Loro version, not by signer.
- Writes: under a live grant (with #1849 extras) in scope, or to the app's
  own subtree, applied unattended and signed by the app agent; anything
  else held for review (Apply / Allow all, a grant via hook-review /
  Decline), pausing the table until answered.
- GET/POST /app-after-commit for status, answers and Retry.
- A startup and ten-minute sweep recovers lost markers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds planning/durable-after-commit.md: a queued, at-least-once afterCommit
for extension-world plugins on tables showing their view, reading #1850's
change list with a host-kept cursor per (installation, table) and writing
through #1788's row grant. Points plugin-runtime-convergence.md step 4 at it.

Refs #1851

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit aa7d94a)
…ation test (#1851)

- package_of reads a createApp app's code from its entrypoint.
- /app-after-commit and #1850's /changes are reserved server routes.
- @tomic/lib's validateManifest accepts entrypoints.afterCommit, as the
  server does; shared fixtures (and the pinned release id) for both.
- A browser/lib integration test against a real server with the flag:
  a row saved through store.save reaches the hook, and its edit waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Decision 1: the "Let <App> edit rows?" dialog says the app is told when
  rows change, also with the tab closed, when the server has the hook on and
  the app exports it. Read-only now also follows the table (op: follow);
  Allow editing does both on the server.
- Decision 2: a bar above the app's tab, "<App> wants to change 3 rows",
  with Apply, Allow all edits by this view on this table (only when the
  proposal stays within a grant) and Decline; a mark on the tab; the count
  on the Installation page, linking to the table.
- Decision 3: a quiet warning mark on the tab and on the Installation page,
  "<App> stopped following changes to <table>" with the error and Retry.
- Status polled while the tab is open (GET /app-after-commit).

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

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

gitguardian Bot commented Sep 29, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
37584408 Triggered RSA Private Key 7d10b63 testdata/http-signatures/vectors.json View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@michielbdejong

Copy link
Copy Markdown
Contributor Author

Closing as part of the plugin-host stack cleanup (Q-101): not needed by atomic-plugins now. See the inventory at #1726 (comment). The branch is kept, and pin candidates are unaffected. Closed by Claude (VPS coordinator) on behalf of Michiel.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

browser enhancement New feature or request server atomic-server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants