Repository navigation
Durable afterCommit for user-installed plugins, behind --plugin-after-commit (#1851) - #1906
Closed
michielbdejong wants to merge 137 commits into
Closed
michielbdejong wants to merge 137 commits into
michielbdejong wants to merge 137 commits into
Conversation
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>
…nto claude/page-runtimes
…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>
#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>
#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>
|
| 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
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- 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
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 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.
This was referenced Sep 29, 2026
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 fromclaude/atomic-plugins-pin-candidate14(1432e24). On top of that it carries:-x: e77fe3c. Conflicts with candidate14'sdb.rs,trees.rs,redb_store.rs,tests.rsandindex.tswere resolved by keeping both sides. candidate14 has noDb::from_kvorTree::ALL, so the new tree is wired into its explicit lists.row-extras.Until the stack lands, the diff shows all of it. Please review these commits:
atomic_lib: wake-up markers staged inside the change log's transaction;table_changes_head/app-after-commit, tests@tomic/libmanifest mirror plus shared fixtures;browser/libintegration testTESTING_COVERAGE.mdDecisions, as built
afterCommit. Allow editing (op: "grant") subscribes on the server in the same request. Read-only now sendsop: "follow".via: "hook-review". The tab gets a mark, and the Installation page shows the count, linking to the table.Server
lib/src/after_commit_wake.rs: a table → marker index on the store.stage_change_logupserts 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:afterCommit.CURSOR_EXPIRED→reset: 'expired'. A new subscription starts at the head withreset: 'initial'.hasMorecontinues on the next pass and does not count toward the loop cap.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.ctx.readandctx.queryto 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.plugin-runtimecallsinput.entry(runorafterCommit), andctx.changesgoes throughhost-call. The WIT is unchanged.after_commit::tick).GET/POST /app-after-commit: status, answers and Retry.Deviations from the design
Details are in the doc's "Implementation notes".
after-commit/v1/{sub,wake,delivery,own}/, notplugin-event/v1/. The trigger drain reads everyplugin-event/v1/entry as aQueuedEvent, 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.view-kindnames, as the grant is. Code comes from the Installation above the app if there is one. Otherwise it comes from the app's ownplugin-sourceor itsentrypoint(createAppapps). Apps without an Installation show their state on the tab only.followrecords nothing when the app does not exportafterCommit.Left for later
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:attempt: 2;CURSOR_EXPIRED;ctx.changesrefused for another table;view-kindset by hand, and a kind change ending the subscription;exit, reopen, delivered).manifest.rsplus shared fixtures:afterCommitis accepted inextensionand refused inserver-extension;onResourceGetandbeforeCommitare refused.browser/libintegration (tests/after-commit.integration.test.ts): a real server with the flag. A row saved throughstore.savereaches the hook, and its edit waits for review.AfterCommitBar.test.tsx,afterCommit.test.ts,appViewGrant.test.ts(Read-only follows; Allow editing sends no second request; retried while the view saves).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 routesapp-after-commitand Per-table change list: rows changed since a cursor, including deletions (tombstones) #1850'schanges.-D warningsforlight,wasm-plugins(the hook's set) and forplugin-routes.pnpm typecheck, oxlint and oxfmt..pofiles are settled: a dev-server run afterwards leaves them unchanged.useDriveApps.live.test.tsxfails 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
createAppinside an Installation, shown on a "Team calendar" table outside the app; three rows saved; the hook proposes a description for each.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