diff --git a/packages/chord/docs/next/slice-13.md b/packages/chord/docs/next/slice-13.md new file mode 100644 index 000000000..f5b8fb029 --- /dev/null +++ b/packages/chord/docs/next/slice-13.md @@ -0,0 +1,120 @@ +# 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/next/app` in +`src/next-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 `next-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` + +```ts +// start.ts (the app's start script) +const app = await startApp
({ + 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) +``` + +```ts +// worker.ts +await startWorker
({ api, kinds, onError }); // reads the start message, opens the worker's store, loads plugins
+```
+
+- `startApp` creates the data directory layout (§4), opens the server store, creates the host (address `Server()`,
+ the server principal), `createHttp` (settings as arguments), `createWorkers` per worker kind (`kind` named after the
+ host kind; the worker's facet kind is `worker` unless the entry says otherwise), `createPlugins` with 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. `close` closes in the right order (http, workers, host, store).
+- `startWorker` is `workerMain` plus: the worker's directory and store under its kind and address, the host with the
+ principal from the start message, `connect` first, plugins loaded with the kit's worker facets, `busy` from the app
+ if given.
+- The start-data facet: the kit serves `AppInfo` (data directory, app name, version) on the server and `WorkerInfo`
+ (address, start data) on workers as core objects plugins `get`.
+- Zero worker kinds: no workers component.
+
+## 2. Principals and sessions
+
+- The app's principal type `P`; the kit needs only `principal.server` and `principal.worker(address)`. The kit adds
+ one variant for its owner: `{ kind: "owner" }` must be assignable to `P` (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 to `Authenticators`) reads the session cookie
+ (signed, `HttpOnly`, `SameSite=Strict`) or an `Authorization: Bearer` header, 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()` returns `http://127.0.0.1:8788/_chord/owner#`, 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
+ `/chord` links 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: