src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
8.5 KiB
Slice 13: the app kit (spec)
Status: approved (owner, 2026-11-01: decisions "The app kit, point 1" to "point 5", then "go ahead, just do all
the things"). Builds on slices 10 (http, workers) and 12 (the plugin model). chord/app in
src/node/app/: an opinionated layer so an app is its API, its built-in plugins and a start script.
Read in full before writing code: this spec; decisions.md entries of 2026-10-31 and 2026-11-01 (the app kit, the
plugin model, "The app kit is opinionated", "Settings are arguments, extensions are registries", "The HTTP
component's security baseline"); design.md §3, §4, §10, §21, §23 (all of it, "The app kit" included); slices.md
(carry-over "Slice 13"); slice-10.md, slice-12.md; the READMEs of node/http, workers, plugins; p2's
src/runtime/server.ts, src/hosts/server.ts, src/hosts/worker.ts, src/runtime/loopback-cookie.ts,
src/hosts/principals.ts, plugins/devices/, plugins/sharing/, plugins/files/ for what exists.
1. startApp and startWorker
// start.ts (the app's start script)
const app = await startApp<P>({
name: "acme", // the default data directory: ~/.acme
dataDir: process.argv[2], // optional; no environment variables
listen: { host: "127.0.0.1", port: 8788 },
api: { name: "@acme/app", versions: ["v3"], modules: { "@acme/app/v3": apiV3, "@acme/app/v3/server": apiV3Server } },
kinds: { server: { platform: "node" }, worker: { platform: "node" }, web: { platform: "browser", packages: { react, "react/jsx-runtime": jsx } } },
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", key: address.key }) },
workers: [{
kind: Project, // a host kind the app defines, any params
entry: "./worker.ts",
start: (address, ctx) => ({ id: address.params.id }),
authorize: (principal, address) => mayReach(principal, address),
idleMs: () => 5 * 60_000,
}],
onError: (error) => log(error),
}, ctx);
// app.host, app.http, app.workers, app.plugins, app.applyChanges(ctx), app.exportAddress(address, ctx),
// app.importAddress(archive, address, ctx), app.ownerLink(), app.close(ctx)
// worker.ts
await startWorker<P>({ api, kinds, onError }); // reads the start message, opens the worker's store, loads plugins
startAppcreates the data directory layout (§4), opens the server store, creates the host (addressServer(), the server principal),createHttp(settings as arguments),createWorkersper worker kind (kindnamed after the host kind; the worker's facet kind isworkerunless the entry says otherwise),createPluginswith the roots of §5 and the app's API (slice 12), loads the server's plugins with the kit's own facets (§2, §3, §4) as core, and resolves once loaded.closecloses in the right order (http, workers, host, store).startWorkerisworkerMainplus: the worker's directory and store under its kind and address, the host with the principal from the start message,connectfirst, plugins loaded with the kit's worker facets,busyfrom the app if given.- The start-data facet: the kit serves
AppInfo(data directory, app name, version) on the server andWorkerInfo(address, start data) on workers as core objects pluginsget. - Zero worker kinds: no workers component.
2. Principals and sessions
- The app's principal type
P; the kit needs onlyprincipal.serverandprincipal.worker(address). The kit adds one variant for its owner:{ kind: "owner" }must be assignable toP(a type check). - Sessions (
app/sessions.ts, a kit object on the server, persisted in the server store):issue(principal, { label, kind: "browser" | "device" }, ctx)returns a token; tokens stored hashed;revoke(id);list(watchable: label, kind, created, last used). The kit's authenticator (contributed toAuthenticators) reads the session cookie (signed,HttpOnly,SameSite=Strict) or anAuthorization: Bearerheader, and gives its principal. - Owner link: at start the kit issues a one-time token (random, single use, valid 10 minutes, replaced at each
start) and
app.ownerLink()returnshttp://127.0.0.1:8788/_chord/owner#<token>; the start script prints it. The routeGET /_chord/ownerserves a page that posts the token (from the fragment, never sent in a URL to logs) toPOST /_chord/owner, which consumes it and sets a browser session cookie for the owner. No owner by loopback alone. - Owner token file:
<dataDir>/owner-token(mode 0600, created at first start, a device session of the owner) for command-line clients on the same machine. - Device pairing (for remote command-line and TUI clients):
POST /_chord/pair(public) returns{ code, poll }; the owner approves the code in a browser (GET /_chord/pair/<code>, owner only, a confirm page); the client polls and receives a device token. Codes expire after 10 minutes. - Deny by default (an HTTP component change): a route or socket without a principal is refused (401) before plugin
code, unless its contribution marks it
public: true(routes:{ handler, public: true }; sockets likewise). The/chordlinks endpoint requires a principal. Foreign requests stay anonymous as before, so they reach only public routes. Slice 10's examples and tests mark their anonymous routes public.
3. Optional kit plugins
Shipped as plugins (in packages/chord/plugins/ or exported facets the app adds to its root; choose one and say
why): users (accounts keyed by id, a profile schema the app passes in, self.get(Users, {}) with get, update,
list), invite (invite links issuing a session for a principal the owner chooses), ephemeral-login (a public
route taking a name, issuing a session for { kind: "user", id: <fresh>, name } that ends with the connection; the
app's P must allow it). Each contributes to Authenticators or issues sessions through §2.
4. Storage, files, export
- Layout:
<dataDir>/server/store.sqlite,<dataDir>/workers/<kind>/<encoded address key>/store.sqliteplus that worker's files,<dataDir>/plugins/(the user's root),<dataDir>/logs/,<dataDir>/owner-token. - Files (a kit facet on workers, and on the server for server-scoped files):
Files(local, params{}, one per host) withput({ name, mime, bytes })for small files,begin/append/finishfor large ones (design §23 chunked transfers),read({ id, offset, length }),remove,list(watchable index: id, name, mime, size, created), caps per host (an option). Stored under the host's directory (files/), the index in the store. - Export / import per address:
app.exportAddress(address, ctx)stops the worker (asworkers.stop), writes one archive: the store's export archive (slice 6a) plus the worker's directory (files), and returns a readable stream or writes to a path;importAddress(archive, address, ctx)refuses an existing address, unpacks, and the next hold starts the worker. Format: a tar ofstore.archiveandfiles/, with a small manifest (app name, API version, kit format version).
5. Plugins
- Roots: the app's
plugins/(required, slice 12) and<dataDir>/plugins/. Installs (app.plugins.install(source, ctx), slice 12'sinstallPlugin) go to the user's root. app.applyChanges(ctx): build the dirty plugins and roll out (server step, then the workers step); returns the rollout result;app.plugins.statefor the app's button. Never applied by itself.
6. Not in slice 13
Web and TUI clients (slice 11, which builds on this kit: startApp gains the web surface there); telemetry (9);
project plugins (§26 #14).
7. Tests, examples, docs
- One test file per source file: start and close order; a server without workers; two worker kinds; the owner link
(single use, expiry, the fragment), sessions (issue, revoke, list, hashed storage), the token file mode, device
pairing, deny by default (a route without principal 401, a public route served,
/chordrefused anonymous), Files (small and chunked, caps, list, remove), export and import (a worker with documents, tables and files round-trips; importing over an existing address refused),applyChanges, required root failure stoppingstartApp. - Examples:
53-app(a small app: start script, API, two plugins, a worker kind, the owner link),54-app-sessions(pairing a command-line client),55-app-export(export and import a worker's address). src/node/app/README.md; the guide "Hosts and Plugins" gets a closing chapter on the app kit.