From 072e413bf3f8ee0cf8d9f2862fd1dbfc5d337afa Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 2 Nov 2026 15:13:36 +0000 Subject: [PATCH] feat(chord): Chord v2 slice 13, the app kit chord/next/app: startApp and startWorker with the fixed app layout, worker kinds, AppInfo and WorkerInfo; sessions (hashed tokens, rolling cookies, bearer tokens), the one-time owner link, the owner token file, device pairing; Files per host with ordered chunked uploads; export and import per address; plugin roots, installs and applyChanges; optional users, invite and ephemeral login facets. HTTP: deny by default (public routes and sockets), remoteAddress, readSignedCookie. Examples 53-55. --- packages/chord/CHANGELOG.md | 4 + .../docs/next/guide-plugins-hosts/book.md | 101 +++- .../docs/next/guide-plugins-hosts/index.html | 100 +++- packages/chord/docs/next/slices.md | 14 +- packages/chord/src/next-node/app/README.md | 198 +++++++ packages/chord/src/next-node/app/app.ts | 488 ++++++++++++++++++ packages/chord/src/next-node/app/archive.ts | 194 +++++++ packages/chord/src/next-node/app/ephemeral.ts | 43 ++ packages/chord/src/next-node/app/files.ts | 196 +++++++ packages/chord/src/next-node/app/index.ts | 24 + packages/chord/src/next-node/app/info.ts | 52 ++ packages/chord/src/next-node/app/invite.ts | 72 +++ packages/chord/src/next-node/app/layout.ts | 105 ++++ packages/chord/src/next-node/app/owner.ts | 123 +++++ packages/chord/src/next-node/app/pairing.ts | 205 ++++++++ packages/chord/src/next-node/app/secrets.ts | 75 +++ packages/chord/src/next-node/app/sessions.ts | 267 ++++++++++ packages/chord/src/next-node/app/tar.ts | 181 +++++++ packages/chord/src/next-node/app/uploads.ts | 172 ++++++ packages/chord/src/next-node/app/users.ts | 68 +++ packages/chord/src/next-node/app/worker.ts | 72 +++ packages/chord/src/next-node/http/README.md | 21 +- packages/chord/src/next-node/http/cookies.ts | 23 +- packages/chord/src/next-node/http/http.ts | 14 +- packages/chord/src/next-node/http/index.ts | 2 +- packages/chord/src/next-node/http/request.ts | 8 + packages/chord/src/next-node/http/router.ts | 24 +- packages/chord/src/next-node/http/upgrades.ts | 7 +- packages/chord/test/next-node/app/app.test.ts | 308 +++++++++++ .../chord/test/next-node/app/archive.test.ts | 142 +++++ .../test/next-node/app/ephemeral.test.ts | 55 ++ .../chord/test/next-node/app/files.test.ts | 190 +++++++ .../chord/test/next-node/app/info.test.ts | 56 ++ .../chord/test/next-node/app/invite.test.ts | 55 ++ .../chord/test/next-node/app/layout.test.ts | 47 ++ .../chord/test/next-node/app/owner.test.ts | 112 ++++ .../chord/test/next-node/app/pairing.test.ts | 148 ++++++ .../chord/test/next-node/app/secrets.test.ts | 65 +++ .../chord/test/next-node/app/sessions.test.ts | 227 ++++++++ packages/chord/test/next-node/app/support.ts | 187 +++++++ packages/chord/test/next-node/app/tar.test.ts | 107 ++++ .../chord/test/next-node/app/uploads.test.ts | 28 + .../chord/test/next-node/app/users.test.ts | 42 ++ .../chord/test/next-node/app/worker.test.ts | 40 ++ .../test/next-node/fixtures/app/worker.ts | 13 + .../chord/test/next-node/http/file.test.ts | 7 +- .../chord/test/next-node/http/http.test.ts | 283 +++++++--- .../chord/test/next-node/http/node.test.ts | 34 +- .../chord/test/next-node/http/request.test.ts | 29 +- .../test/next-node/http/response.test.ts | 43 +- .../chord/test/next-node/http/router.test.ts | 13 +- .../test/next-node/http/upgrades.test.ts | 23 +- .../examples/40-http/plugins/notes/server.ts | 19 +- .../44-http-wraps/plugins/notes/server.ts | 4 +- .../46-allowed-hosts/plugins/tunnel/server.ts | 7 +- .../plugins/notes/server.ts | 5 +- .../next/examples/48-http-baseline/main.ts | 14 +- .../48-http-baseline/plugins/notes/server.ts | 6 +- .../next/examples/53-app/app/principals.ts | 8 + .../chord/test/next/examples/53-app/app/v1.ts | 31 ++ .../next/examples/53-app/app/v1/server.ts | 5 + .../chord/test/next/examples/53-app/client.ts | 44 ++ .../chord/test/next/examples/53-app/main.ts | 56 ++ .../examples/53-app/plugins/home/package.json | 5 + .../examples/53-app/plugins/home/server.ts | 27 + .../examples/53-app/plugins/notes/contract.ts | 22 + .../53-app/plugins/notes/package.json | 5 + .../examples/53-app/plugins/notes/server.ts | 23 + .../examples/53-app/plugins/notes/worker.ts | 49 ++ .../chord/test/next/examples/53-app/start.ts | 50 ++ .../chord/test/next/examples/53-app/worker.ts | 7 + .../next/examples/54-app-sessions/main.ts | 62 +++ .../test/next/examples/55-app-export/main.ts | 66 +++ tsconfig.json | 2 + 74 files changed, 5437 insertions(+), 187 deletions(-) create mode 100644 packages/chord/src/next-node/app/README.md create mode 100644 packages/chord/src/next-node/app/app.ts create mode 100644 packages/chord/src/next-node/app/archive.ts create mode 100644 packages/chord/src/next-node/app/ephemeral.ts create mode 100644 packages/chord/src/next-node/app/files.ts create mode 100644 packages/chord/src/next-node/app/index.ts create mode 100644 packages/chord/src/next-node/app/info.ts create mode 100644 packages/chord/src/next-node/app/invite.ts create mode 100644 packages/chord/src/next-node/app/layout.ts create mode 100644 packages/chord/src/next-node/app/owner.ts create mode 100644 packages/chord/src/next-node/app/pairing.ts create mode 100644 packages/chord/src/next-node/app/secrets.ts create mode 100644 packages/chord/src/next-node/app/sessions.ts create mode 100644 packages/chord/src/next-node/app/tar.ts create mode 100644 packages/chord/src/next-node/app/uploads.ts create mode 100644 packages/chord/src/next-node/app/users.ts create mode 100644 packages/chord/src/next-node/app/worker.ts create mode 100644 packages/chord/test/next-node/app/app.test.ts create mode 100644 packages/chord/test/next-node/app/archive.test.ts create mode 100644 packages/chord/test/next-node/app/ephemeral.test.ts create mode 100644 packages/chord/test/next-node/app/files.test.ts create mode 100644 packages/chord/test/next-node/app/info.test.ts create mode 100644 packages/chord/test/next-node/app/invite.test.ts create mode 100644 packages/chord/test/next-node/app/layout.test.ts create mode 100644 packages/chord/test/next-node/app/owner.test.ts create mode 100644 packages/chord/test/next-node/app/pairing.test.ts create mode 100644 packages/chord/test/next-node/app/secrets.test.ts create mode 100644 packages/chord/test/next-node/app/sessions.test.ts create mode 100644 packages/chord/test/next-node/app/support.ts create mode 100644 packages/chord/test/next-node/app/tar.test.ts create mode 100644 packages/chord/test/next-node/app/uploads.test.ts create mode 100644 packages/chord/test/next-node/app/users.test.ts create mode 100644 packages/chord/test/next-node/app/worker.test.ts create mode 100644 packages/chord/test/next-node/fixtures/app/worker.ts create mode 100644 packages/chord/test/next/examples/53-app/app/principals.ts create mode 100644 packages/chord/test/next/examples/53-app/app/v1.ts create mode 100644 packages/chord/test/next/examples/53-app/app/v1/server.ts create mode 100644 packages/chord/test/next/examples/53-app/client.ts create mode 100644 packages/chord/test/next/examples/53-app/main.ts create mode 100644 packages/chord/test/next/examples/53-app/plugins/home/package.json create mode 100644 packages/chord/test/next/examples/53-app/plugins/home/server.ts create mode 100644 packages/chord/test/next/examples/53-app/plugins/notes/contract.ts create mode 100644 packages/chord/test/next/examples/53-app/plugins/notes/package.json create mode 100644 packages/chord/test/next/examples/53-app/plugins/notes/server.ts create mode 100644 packages/chord/test/next/examples/53-app/plugins/notes/worker.ts create mode 100644 packages/chord/test/next/examples/53-app/start.ts create mode 100644 packages/chord/test/next/examples/53-app/worker.ts create mode 100644 packages/chord/test/next/examples/54-app-sessions/main.ts create mode 100644 packages/chord/test/next/examples/55-app-export/main.ts diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 39a18568b..fb3520709 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -5,9 +5,13 @@ ### Added - Chord v2's plugins component gains the plugin model (slice 12): `createPlugins({ api: { name, versions } })` checks every import of the app's versioned API (`@acme/app/v3`, per-kind modules such as `@acme/app/v3/web`): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be `{ path, required }`: a required root's plugin failing at `load` rejects it with a `PluginError` after the load settled. Files named `..` (`contract.web.tsx`) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (`hello.server.ts` at a root's top, grouped by name, host packages only). `installPlugin({ git, ref } | { npm, version }, root, ctx)` installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in its `package.json` (`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }`), used by discovery and `installPlugin` (without it: one plugin when the package's top has facet files, else its subfolders with a `package.json`). `testPlugin(dir, { kind, modules, stubs, principal }, ctx)` builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide: `docs/next/plugin-authoring.md`. +- Chord v2 gains the app kit (slice 13, `src/next-node/app`, to be exported as `@earendil-works/chord/next/app`): `startApp

({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, install?, onError }, ctx)` lays out the data directory (`server/store.sqlite`, `workers//

/`, `plugins/`, `logs/`, `owner-token`; one server per data directory), creates the server's store and host, HTTP, one workers component per worker kind and the plugins component over the app's required root and the user's root, and loads the server's plugins with the kit's objects; `startWorker({ api, kinds, busy?, onError })` is a worker's entry (its directory and store, the link to the server first, its plugins). The start-data objects `AppInfo` and `WorkerInfo` serve process values to plugins. Sessions (`Sessions`: `issue`, `verify`, `revoke`, `list`; tokens kept as SHA-256 hashes, compared in constant time; persisted ones end after 400 days unused and their browser cookie is renewed daily while used, ephemeral ones live in memory, at most `limits.ephemeralSessions`) authenticate a signed `HttpOnly`, `SameSite=Strict` cookie or `Authorization: Bearer`; a one-time owner link per start (`app.ownerLink()`, the token in the URL's fragment, single use, 10 minutes), an owner token file (mode 0600, written atomically) and device pairing (`POST /_chord/pair`, owner approval, a poll returning the token once; bounded in all and per client address) sign the owner in. The data directory, its stores and secret files are made private (0700, 0600) at every start; the home directory, a file system's root and another user's directory are refused. `Files` per host: small and chunked writes at a stored offset, ranged reads, a watchable index, caps, at most `limits.uploads` uploads under way. `app.exportAddress(address, path, ctx)` and `app.importAddress(archive, address, ctx)` move a worker's store and files as a tar, holding the worker's process lock meanwhile (refusing an existing address, another app's archive, every path leaving the target, and archives past `limits.importBytes` or `limits.importEntries`). `app.applyChanges(ctx)` and `app.plugins.install(source, ctx)`. Optional plugins as exported facets: `defineUsers(profile)`, `inviteFacet()`, `ephemeralLoginFacet()`. +- Chord v2's `HttpRequest` gains `remoteAddress`, the peer's address as the listener knows it (for limits per client). +- Chord v2's HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires. ### Changed +- Chord v2's HTTP component denies by default (slice 13): a request or plugin socket without a principal gets 401 before plugin code runs unless its route or socket is marked `public: true` (`{ handler, public: true }`; a `crossSite` route is public), and the `/chord` links endpoint refuses requests without a principal. - Chord v2 plugins import each other by relative path within a root (copied into the importer's script) and across repositories by package name through the package's `exports`, pinned in `dependencies`; slice 10's imports by plugin name (`@app/notes/contract`) are removed, and a relative path leaving the root is a build error. ## [1.5.0] - 2026-11-02 diff --git a/packages/chord/docs/next/guide-plugins-hosts/book.md b/packages/chord/docs/next/guide-plugins-hosts/book.md index 747cc4e32..ad2d511f2 100644 --- a/packages/chord/docs/next/guide-plugins-hosts/book.md +++ b/packages/chord/docs/next/guide-plugins-hosts/book.md @@ -1,7 +1,7 @@ --- title: Hosts and Plugins: a tutorial for Chord v2 cover: Hosts / and Plugins -kicker: A guide · Chord v2 · 2026-10-31 +kicker: A guide · Chord v2 · 2026-11-02 subtitle: Build a small notes app step by step, from one host to plugins on a server and on workers, edited while it runs stats: - 3 | kinds of host in an app: server, worker, client @@ -10,10 +10,10 @@ stats: - 1 | rule: plugins talk only through object types and registries note: | A tutorial for people building apps on Chord v2, written by the redesign session from `design.md`, `slice-7c.md`, - `slice-10.md`, the decision log and the code in `packages/chord/src/next` and `src/next-node`. API names are the - current ones; where slice 10's fix round or the app kit (`chord/next/app`, being designed) will change what app code - writes, a note says so. -colophon: Hosts and Plugins · second edition · 2026-11-01 · Chord v2 slices 1-10 and 12 + `slice-10.md`, `slice-12.md`, `slice-13.md`, the decision log and the code in `packages/chord/src/next` and + `src/next-node`. API names are the current ones. The early chapters wire hosts by hand to show the parts; the last + chapter shows the app kit (`chord/next/app`), which does that wiring for an app. +colophon: Hosts and Plugins · third edition · 2026-11-02 · Chord v2 slices 1-10, 12 and 13 --- # Before we begin {.preface} @@ -265,10 +265,10 @@ tab.connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server `P` is your app's type of **principal**, who is calling: an owner, a guest, the server, a worker. Links stamp it; a `remote` object is made per principal, so a guest can get a read-only variant of the same notes. -:::note | Coming with the app kit -Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components today -(`createWorkers`, `chord.http`), wired by the app. The opinionated app kit (`chord/next/app`, being designed) will do -this wiring for you: `startApp(...)` and `startWorker()`. +:::note | The app kit does this wiring +Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components +(`createWorkers`, `chord.http`). An app built with the app kit (`chord/next/app`, the last chapter) does not wire them: +`startApp(...)` on the server and `startWorker(...)` in a worker's entry do. ::: # Client Hosts {#clients lede="Browser tabs and terminal UIs are hosts too: what they can do, and what they cannot."} @@ -633,6 +633,84 @@ same name, the first root wins, and the other is reported. as production does, loads it on an in-memory host beside stub facets serving what it uses, collects what it contributes to registries nobody serves, and lists the contracts it waits for (`docs/next/plugin-authoring.md`). +# The App Kit {#kit lede="startApp and startWorker: the hosts, stores, sign-in, files and plugin roots every app needs, so an app is its API, its plugins and a start script."} + +The chapters so far wired an app by hand: a store, a host, `createHttp`, `createWorkers`, `createPlugins`, an +authenticator, a worker entry connecting to its parent. Every app needs the same wiring, and most of it has one right +answer. The **app kit** (`chord/next/app`) is that answer, opinionated on purpose: one data directory layout, one way +to sign in, one place for files. + +## A whole app + +The notes app of example 53 is four files of base code and two plugins: + +```ts +// start.ts +const app = await startApp({ + name: "notes", // the data directory: ~/.notes unless dataDir says otherwise + root: "./plugins", // the app's own plugins: a required root + api: { name: "@notes/app", versions: ["v1"], modules: { "@notes/app/v1": v1, "@notes/app/v1/server": v1server } }, + kinds: { server: { platform: "node" }, worker: { platform: "node" } }, + principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", project: address.key }) }, + workers: [{ + kind: Project, // one worker per project + entry: "./worker.ts", + start: (address) => ({ project: address.key }), + authorize: (principal) => principal.kind === "owner" || principal.kind === "server", + }], + onError: (error) => log(error), +}, ctx); +console.log(`Open ${app.ownerLink()}`); + +// worker.ts +startWorker({ api: API, kinds: KINDS, onError: (error) => log(error) }); +``` + +`startApp` creates the data directory (`server/store.sqlite`, `workers//
/`, `plugins/`, `logs/`, +`owner-token`), opens the server's store, creates the server host, HTTP, a workers component per worker kind and the +plugins component over two roots: the app's `plugins/` and the user's `/plugins/`. Then it loads the server's +plugins with the kit's own facets beside them and resolves. `startWorker` does the worker's half: the project's +directory and store, the host at the project's address, the link to the server first, the worker's plugins. + +## What plugins get from the kit + +Plugins still import only the app's API; the API re-exports the kit's objects it offers: + +- `AppInfo` on the server (data directory, app name, API version) and `WorkerInfo` on workers (the address and the + start data `start(address)` made): the **start-data objects**. Process values reach plugins only through them. +- `Files`, one per host: `put` for small files, `begin`, `append` and `finish` for large ones, ranged `read`, `remove` + and a watchable `list`, stored in the host's directory with caps per host. +- `Sessions` on the server: `issue`, `revoke`, `list`. + +This finishes what the previous chapter started. Base code is the API, the start script and the worker's entry. The +providers of the app's own registries live in its required root: example 53's API declares a `Sections` registry, and +its home plugin serves it and reads `AppInfo`, so it reloads like any other plugin. + +## Who may come in + +HTTP **denies by default**: a request without a principal gets 401 before any plugin code runs, unless the route says +`public: true`. Someone gets a principal through a **session**: a random token, kept in the store only as a hash, sent +as a signed cookie by a browser or as `Authorization: Bearer` by a command-line client. + +The first session is the owner's. At each start the kit makes a one-time **owner link**, +`http://127.0.0.1:8788/_chord/owner#`, valid ten minutes; the start script prints it. The token is in the +fragment, which browsers never send, so it never reaches a log: the page posts it, the kit consumes it and sets the +owner's cookie. A command-line client on the same machine reads `/owner-token` (mode 0600). One on another +machine **pairs**: it asks for a code, the owner approves the code in a signed-in browser, and the client's next poll +returns its token (example 54). + +Users other than the owner come from optional plugins the app adds: `defineUsers(profile)` for accounts, +`inviteFacet()` for invite links, `ephemeralLoginFacet()` for "pick a name and you're in". + +## Applying changes, moving projects + +The kit never reloads by itself. An edit or an install marks plugins dirty; `app.plugins.state` drives the app's +"apply changes" button, and `app.applyChanges(ctx)` builds and rolls out to the server, then the workers. + +A project's data is its directory. `app.exportAddress(Project({ id }), path, ctx)` stops its worker and writes one +archive (a manifest, the store's export, the files); `app.importAddress(path, Project({ id: "copy" }), ctx)` makes a new +project from it and refuses one that already has data (example 55). + # Reference {.appendix} | Call | What it does | @@ -647,3 +725,8 @@ same name, the first root wins, and the other is reported. | `plugins.load(host, { kind, core, modules }, ctx)`, `build(names)`, `rollout(steps, built)` | load, build, roll out | | `plugins.installPlugin(source, root, ctx)`, `testPlugin(dir, options, ctx)` | install a plugin, test one alone | | `hostKind(name, params?)`, `Kind(params)` | host kinds and addresses | +| `startApp

({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, onError }, ctx)` | an app's server, with the kit | +| `startWorker({ api, kinds, busy?, onError })` | a worker's entry, with the kit | +| `app.ownerLink()`, `app.applyChanges(ctx)`, `app.plugins.install(source, ctx)` | sign in, apply changes, install | +| `app.exportAddress(address, path, ctx)`, `app.importAddress(path, address, ctx)` | move a worker's data | +| `AppInfo`, `WorkerInfo`, `Files`, `Sessions` | the kit's objects plugins get | diff --git a/packages/chord/docs/next/guide-plugins-hosts/index.html b/packages/chord/docs/next/guide-plugins-hosts/index.html index 9b30188ea..beac6027f 100644 --- a/packages/chord/docs/next/guide-plugins-hosts/index.html +++ b/packages/chord/docs/next/guide-plugins-hosts/index.html @@ -109,14 +109,14 @@ table{max-width:100%}

-
A guide · Chord v2 · 2026-10-31
+
A guide · Chord v2 · 2026-11-02

Hosts
and Plugins

Build a small notes app step by step, from one host to plugins on a server and on workers, edited while it runs

3kinds of host in an app: server, worker, client
1file per host kind in a plugin
0restarts to apply a plugin edit
1rule: plugins talk only through object types and registries

A tutorial for people building apps on Chord v2, written by the redesign session from design.md, slice-7c.md, -slice-10.md, the decision log and the code in packages/chord/src/next and src/next-node. API names are the -current ones; where slice 10's fix round or the app kit (chord/next/app, being designed) will change what app code -writes, a note says so.

+slice-10.md, slice-12.md, slice-13.md, the decision log and the code in packages/chord/src/next and +src/next-node. API names are the current ones. The early chapters wire hosts by hand to show the parts; the last +chapter shows the app kit (chord/next/app), which does that wiring for an app.

@@ -306,9 +307,9 @@ worker.connect(parentLink, { principal: { kind: connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server" } }, ctx);

P is your app's type of principal, who is calling: an owner, a guest, the server, a worker. Links stamp it; a remote object is made per principal, so a guest can get a read-only variant of the same notes.

-
Coming with the app kit

Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components today -(createWorkers, chord.http), wired by the app. The opinionated app kit (chord/next/app, being designed) will do -this wiring for you: startApp(...) and startWorker().

+
The app kit does this wiring

Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components +(createWorkers, chord.http). An app built with the app kit (chord/next/app, the last chapter) does not wire them: +startApp(...) on the server and startWorker(...) in a worker's entry do.

@@ -593,6 +594,69 @@ contributes to registries nobody serves, and lists the contracts it waits for (< +
X

The App Kit

startApp and startWorker: the hosts, stores, sign-in, files and plugin roots every app needs, so an app is its API, its plugins and a start script.

+

The chapters so far wired an app by hand: a store, a host, createHttp, createWorkers, createPlugins, an +authenticator, a worker entry connecting to its parent. Every app needs the same wiring, and most of it has one right +answer. The app kit (chord/next/app) is that answer, opinionated on purpose: one data directory layout, one way +to sign in, one place for files.

+

A whole app

+

The notes app of example 53 is four files of base code and two plugins:

+
// start.ts
+const app = await startApp<NotesPrincipal>({
+	name: "notes",                                  // the data directory: ~/.notes unless dataDir says otherwise
+	root: "./plugins",                              // the app's own plugins: a required root
+	api: { name: "@notes/app", versions: ["v1"], modules: { "@notes/app/v1": v1, "@notes/app/v1/server": v1server } },
+	kinds: { server: { platform: "node" }, worker: { platform: "node" } },
+	principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", project: address.key }) },
+	workers: [{
+		kind: Project,                              // one worker per project
+		entry: "./worker.ts",
+		start: (address) => ({ project: address.key }),
+		authorize: (principal) => principal.kind === "owner" || principal.kind === "server",
+	}],
+	onError: (error) => log(error),
+}, ctx);
+console.log(`Open ${app.ownerLink()}`);
+
+// worker.ts
+startWorker({ api: API, kinds: KINDS, onError: (error) => log(error) });
+

startApp creates the data directory (server/store.sqlite, workers/<kind>/<address>/, plugins/, logs/, +owner-token), opens the server's store, creates the server host, HTTP, a workers component per worker kind and the +plugins component over two roots: the app's plugins/ and the user's <dataDir>/plugins/. Then it loads the server's +plugins with the kit's own facets beside them and resolves. startWorker does the worker's half: the project's +directory and store, the host at the project's address, the link to the server first, the worker's plugins.

+

What plugins get from the kit

+

Plugins still import only the app's API; the API re-exports the kit's objects it offers:

+
    +
  • AppInfo on the server (data directory, app name, API version) and WorkerInfo on workers (the address and the +start data start(address) made): the start-data objects. Process values reach plugins only through them.
  • +
  • Files, one per host: put for small files, begin, append and finish for large ones, ranged read, remove +and a watchable list, stored in the host's directory with caps per host.
  • +
  • Sessions on the server: issue, revoke, list.
  • +
+

This finishes what the previous chapter started. Base code is the API, the start script and the worker's entry. The +providers of the app's own registries live in its required root: example 53's API declares a Sections registry, and +its home plugin serves it and reads AppInfo, so it reloads like any other plugin.

+

Who may come in

+

HTTP denies by default: a request without a principal gets 401 before any plugin code runs, unless the route says +public: true. Someone gets a principal through a session: a random token, kept in the store only as a hash, sent +as a signed cookie by a browser or as Authorization: Bearer by a command-line client.

+

The first session is the owner's. At each start the kit makes a one-time owner link, +http://127.0.0.1:8788/_chord/owner#<token>, valid ten minutes; the start script prints it. The token is in the +fragment, which browsers never send, so it never reaches a log: the page posts it, the kit consumes it and sets the +owner's cookie. A command-line client on the same machine reads <dataDir>/owner-token (mode 0600). One on another +machine pairs: it asks for a code, the owner approves the code in a signed-in browser, and the client's next poll +returns its token (example 54).

+

Users other than the owner come from optional plugins the app adds: defineUsers(profile) for accounts, +inviteFacet() for invite links, ephemeralLoginFacet() for "pick a name and you're in".

+

Applying changes, moving projects

+

The kit never reloads by itself. An edit or an install marks plugins dirty; app.plugins.state drives the app's +"apply changes" button, and app.applyChanges(ctx) builds and rolls out to the server, then the workers.

+

A project's data is its directory. app.exportAddress(Project({ id }), path, ctx) stops its worker and writes one +archive (a manifest, the store's export, the files); app.importAddress(path, Project({ id: "copy" }), ctx) makes a new +project from it and refuses one that already has data (example 55).

+
+
A

Reference

@@ -641,10 +705,30 @@ contributes to registries nobody serves, and lists the contracts it waits for (< + + + + + + + + + + + + + + + + + + + +
hostKind(name, params?), Kind(params) host kinds and addresses
startApp<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, onError }, ctx)an app's server, with the kit
startWorker({ api, kinds, busy?, onError })a worker's entry, with the kit
app.ownerLink(), app.applyChanges(ctx), app.plugins.install(source, ctx)sign in, apply changes, install
app.exportAddress(address, path, ctx), app.importAddress(path, address, ctx)move a worker's data
AppInfo, WorkerInfo, Files, Sessionsthe kit's objects plugins get
-

Hosts and Plugins · second edition · 2026-11-01 · Chord v2 slices 1-10 and 12

+

Hosts and Plugins · third edition · 2026-11-02 · Chord v2 slices 1-10, 12 and 13

", + ].join("\n"); +} + +/** + * The owner token file at `path`: kept when its token still authenticates as the owner (its session exists, its secret + * matches, it has not expired; its mode set to 0600 again), else a new device session of the owner written there (mode + * 0600, in one step). Its token, for the start script. + */ +export async function ownerTokenFile

( + path: string, + host: Host

, + context: Context, +): Promise { + const existing = await readSecret(path); + return host.root(async (ctx) => { + const sessions = await ctx.get(Sessions, {}); + if (existing !== undefined && isOwner(await sessions.verify(existing, ctx))) { + await chmod(path, 0o600); + return existing; + } + const { token } = await sessions.issue({ principal: OWNER, label: "owner token file", kind: "device" }, ctx); + await writeSecret(path, token, existing === undefined ? "create" : "replace"); + return token; + }, context); +} + +/** Whether `principal` (as `Sessions.verify` gives it) is the owner. */ +function isOwner(principal: unknown): boolean { + return typeof principal === "object" && principal !== null && (principal as { kind?: unknown }).kind === "owner"; +} diff --git a/packages/chord/src/next-node/app/pairing.ts b/packages/chord/src/next-node/app/pairing.ts new file mode 100644 index 000000000..8741aa5cd --- /dev/null +++ b/packages/chord/src/next-node/app/pairing.ts @@ -0,0 +1,205 @@ +// Device pairing (slice 13 §2, decision "The app kit, point 3" (c)): a command-line or terminal client on another +// machine asks for a code (`POST /_chord/pair`, public) and polls with a secret it got with it; the owner opens +// `/_chord/pair/` in a signed-in browser and approves; the next poll returns a device session's token, once. +// Codes are short, random, single use and expire after 10 minutes; the poll secret is kept only hashed. Asking is +// public, so pairings anonymous clients start are capped: `maxPending` in all (100 by default) and `perAddress` per +// client address (10). The trade: a party filling the caps locks pairing out for others for up to 10 minutes (behind a +// tunnel every remote client has the tunnel's address), instead of growing the server's memory without bound; a +// signed-in owner's pairings are not counted, so the owner can always pair. + +import { randomInt } from "node:crypto"; +import type { Principal } from "../../next/data/index.ts"; +import { defineFacet, type Facet } from "../../next/host/index.ts"; +import { Http, type HttpRequest, type HttpResponse } from "../http/index.ts"; +import { OWNER } from "./owner.ts"; +import { hashToken, newToken } from "./secrets.ts"; +import { Sessions } from "./sessions.ts"; + +/** How long a pairing code is valid: 10 minutes. */ +const PAIRING_MS = 10 * 60_000; + +/** The code's letters: no 0/O, 1/I/L. */ +const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + +/** Who starts a pairing: the signed-in owner (not counted), or an anonymous client at an address (counted). */ +export type PairingStarter = { readonly kind: "owner" } | { readonly kind: "anonymous"; readonly address: string }; + +/** One pairing under way: its code, the device's label, who started it, its expiry, its token once approved. */ +type Pairing = { + readonly code: string; + readonly label: string; + readonly by: PairingStarter; + readonly expires: number; + token?: string; +}; + +/** The limits of pairings under way: in all (100 by default) and per client address (10 by default). */ +export type PairingLimits = { readonly maxPending?: number; readonly perAddress?: number }; + +/** The pairings under way, by code and by their poll secret's hash, with the anonymous ones counted. */ +export class Pairings { + readonly #byCode = new Map(); + readonly #byPoll = new Map(); + readonly #perAddress = new Map(); + #anonymous = 0; + readonly #maxPending: number; + readonly #maxPerAddress: number; + + constructor(limits: PairingLimits = {}) { + this.#maxPending = limits.maxPending ?? 100; + this.#maxPerAddress = limits.perAddress ?? 10; + } + + /** + * A new pairing for a device labelled `label`, started `by` the owner or an anonymous client: its code (for the + * owner) and its poll secret (for the device); `undefined` when an anonymous client reached a limit. + */ + begin(label: string, by: PairingStarter): { readonly code: string; readonly poll: string } | undefined { + this.#expire(); + if (by.kind === "anonymous") { + const full = this.#anonymous >= this.#maxPending; + if (full || (this.#perAddress.get(by.address) ?? 0) >= this.#maxPerAddress) return undefined; + this.#anonymous++; + this.#perAddress.set(by.address, (this.#perAddress.get(by.address) ?? 0) + 1); + } + let code: string; + do code = `${letters(4)}-${letters(4)}`; + while (this.#byCode.has(code)); + const poll = newToken(); + const pairing: Pairing = { code, label, by, expires: Date.now() + PAIRING_MS }; + this.#byCode.set(code, pairing); + this.#byPoll.set(hashToken(poll), pairing); + return { code, poll }; + } + + /** The pairing waiting for approval under `code` (any case), unexpired. */ + waiting(code: string): Pairing | undefined { + this.#expire(); + const pairing = this.#byCode.get(code.toUpperCase()); + return pairing?.token === undefined ? pairing : undefined; + } + + /** What a poll with `poll` learns: still waiting, the device's token (once: the pairing ends), or nothing. */ + poll( + poll: string, + ): { readonly kind: "waiting" } | { readonly kind: "approved"; readonly token: string } | undefined { + this.#expire(); + const hash = hashToken(poll); + const pairing = this.#byPoll.get(hash); + if (pairing === undefined) return undefined; + if (pairing.token === undefined) return { kind: "waiting" }; + this.#end(hash, pairing); + return { kind: "approved", token: pairing.token }; + } + + /** Drops the expired pairings. */ + #expire(): void { + const now = Date.now(); + for (const [hash, pairing] of this.#byPoll) { + if (pairing.expires <= now) this.#end(hash, pairing); + } + } + + /** Ends `pairing` (its poll secret's hash `hash`). */ + #end(hash: string, pairing: Pairing): void { + this.#byPoll.delete(hash); + this.#byCode.delete(pairing.code); + const { by } = pairing; + if (by.kind === "owner") return; + this.#anonymous--; + const left = (this.#perAddress.get(by.address) ?? 1) - 1; + if (left === 0) this.#perAddress.delete(by.address); + else this.#perAddress.set(by.address, left); + } +} + +/** `count` random letters of `ALPHABET` (`crypto.randomInt`). */ +function letters(count: number): string { + let text = ""; + for (let index = 0; index < count; index++) text += ALPHABET[randomInt(ALPHABET.length)]; + return text; +} + +/** The pairing routes' prefix. */ +export const PAIR_PATH = "/_chord/pair"; + +/** + * The kit's facet for device pairing: `POST /_chord/pair` (public: `{ label? }` → `{ code, poll }`, 429 past a limit + * unless the owner asks), `POST /_chord/pair/poll` (public: `{ poll }` → `{ status: "waiting" }`, `{ status: + * "approved", token }` once, else 404), `GET` and `POST /_chord/pair/:code` (the owner's confirm page and approval: a + * device session of the owner). + */ +export function pairingFacet

(pairings: Pairings): Facet

{ + return defineFacet

((_host, self) => + self.contribute(Http, { + routes: { + [`POST ${PAIR_PATH}`]: { + public: true, + handler: async (request, ctx): Promise => { + const label = await labelOf(request); + const by: PairingStarter = + ctx.principal?.kind === "owner" + ? { kind: "owner" } + : { kind: "anonymous", address: request.remoteAddress ?? "" }; + const begun = pairings.begin(label, by); + return begun === undefined ? { kind: "error", status: 429 } : { kind: "json", value: begun }; + }, + }, + [`POST ${PAIR_PATH}/poll`]: { + public: true, + handler: async (request): Promise => { + // A trust boundary: anyone may poll. + const body = (await request.json()) as { readonly poll?: unknown } | null; + const polled = typeof body?.poll === "string" ? pairings.poll(body.poll) : undefined; + if (polled === undefined) return { kind: "error", status: 404 }; + if (polled.kind === "waiting") return { kind: "json", value: { status: "waiting" } }; + return { kind: "json", value: { status: "approved", token: polled.token } }; + }, + }, + [`GET ${PAIR_PATH}/:code`]: async (request, ctx) => { + if (ctx.principal?.kind !== "owner") return { kind: "error", status: 403 }; + const pairing = pairings.waiting(request.params.code ?? ""); + if (pairing === undefined) + return { kind: "html", status: 404, body: "

No such pairing (or it expired).

" }; + return { kind: "html", body: confirmPage(pairing.code, pairing.label) }; + }, + [`POST ${PAIR_PATH}/:code`]: async (request, ctx) => { + if (ctx.principal?.kind !== "owner") return { kind: "error", status: 403 }; + const pairing = pairings.waiting(request.params.code ?? ""); + if (pairing === undefined) + return { kind: "html", status: 404, body: "

No such pairing (or it expired).

" }; + const sessions = await ctx.get(Sessions, {}); + const { token } = await sessions.issue({ principal: OWNER, label: pairing.label, kind: "device" }, ctx); + pairing.token = token; + return { + kind: "html", + body: `

Approved: ${escapeHtml(pairing.label)} is signed in as the owner.

`, + }; + }, + }, + }), + ); +} + +/** The device's label from a pairing request's body (`{ label }`, at most 100 characters), else `device`. */ +async function labelOf(request: HttpRequest): Promise { + const declared = Number(request.headers.get("content-length") ?? "0"); + if (declared === 0 && request.headers.get("transfer-encoding") === null) return "device"; + // A trust boundary: anyone may ask for a code. + const body = (await request.json(4096)) as { readonly label?: unknown } | null; + return typeof body?.label === "string" && body.label.trim() !== "" ? body.label.trim().slice(0, 100) : "device"; +} + +/** The owner's confirm page for pairing `code` (a form posting to itself: same origin, the session cookie). */ +function confirmPage(code: string, label: string): string { + return [ + "Pair a device", + `

Sign in ${escapeHtml(label)} as the owner? Its code: ${escapeHtml(code)} (compare it with the device).

`, + '
', + ].join("\n"); +} + +/** `text` safe in HTML. */ +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); +} diff --git a/packages/chord/src/next-node/app/secrets.ts b/packages/chord/src/next-node/app/secrets.ts new file mode 100644 index 000000000..6de1d870f --- /dev/null +++ b/packages/chord/src/next-node/app/secrets.ts @@ -0,0 +1,75 @@ +// Secrets of the app kit (slice 13 §2): tokens from `crypto.randomBytes`, kept only as their SHA-256 hashes and +// compared in constant time, and secret files (the cookie key, the owner token file) created with mode 0600 in one +// step, so no other OS user and no half-written file ever reads them. + +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { link, open, readFile, rename, unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +/** A fresh random token of `bytes` bytes, base64url. */ +export function newToken(bytes = 32): string { + return randomBytes(bytes).toString("base64url"); +} + +/** The hash a token is kept as (SHA-256, hex): what leaks from a store is no credential. */ +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** Whether `token` hashes to `hash`, compared in constant time. */ +export function matchesHash(token: string, hash: string): boolean { + const given = Buffer.from(hashToken(token), "hex"); + const kept = Buffer.from(hash, "hex"); + return given.length === kept.length && timingSafeEqual(given, kept); +} + +/** The secret in the file at `path`, created (mode 0600, in one step) with a fresh token when it does not exist yet. */ +export async function secretFile(path: string): Promise { + const existing = await readSecret(path); + if (existing !== undefined) return existing; + const secret = newToken(); + const written = await writeSecret(path, secret, "create"); + return written === "exists" ? ((await readSecret(path)) ?? secret) : secret; +} + +/** The text of the secret file at `path`, or `undefined` when it does not exist. */ +export async function readSecret(path: string): Promise { + try { + return (await readFile(path, "utf8")).trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +/** + * Writes `text` to `path` with mode 0600 in one step: a temporary file of the same directory (made exclusively, 0600) + * linked into place (`create`: refused when `path` exists, `"exists"` returned) or renamed over it (`replace`). + */ +export async function writeSecret( + path: string, + text: string, + mode: "create" | "replace", +): Promise<"written" | "exists"> { + const temporary = join(dirname(path), `.${newToken(9)}.tmp`); + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(`${text}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + try { + if (mode === "replace") { + await rename(temporary, path); + return "written"; + } + await link(temporary, path); + return "written"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists"; + throw error; + } finally { + await unlink(temporary).catch(() => {}); + } +} diff --git a/packages/chord/src/next-node/app/sessions.ts b/packages/chord/src/next-node/app/sessions.ts new file mode 100644 index 000000000..f42a4e5a1 --- /dev/null +++ b/packages/chord/src/next-node/app/sessions.ts @@ -0,0 +1,267 @@ +// Sessions (slice 13 §2, decision "The app kit, point 3: authentication"): the kit's object on the server that issues +// and revokes sessions, and its authenticator. A session is a principal behind a token `.` (random); only +// the secret's hash is kept. A browser carries its token in the kit's signed session cookie (`HttpOnly`, +// `SameSite=Strict`), a command-line client in `Authorization: Bearer `. Browser and device sessions persist in +// the server's store and expire after 400 days unused; ephemeral sessions (a pick-a-name login, a public route) live +// in memory only, at most `maxEphemeral` of them (past it the oldest ends), so a public login costs the same whatever +// came before, and none outlives the server's run. + +import type { Context } from "../../context/index.ts"; +import type { JsonValue } from "../../json.ts"; +import { arr, bool, local, map, method, num, obj, oneOf, opt, str, unknown } from "../../next/contracts/index.ts"; +import type { Principal } from "../../next/data/index.ts"; +import { defineState } from "../../next/documents/index.ts"; +import { defineFacet, type Facet } from "../../next/host/index.ts"; +import { + Authenticators, + cookie, + Http, + type HttpRequest, + type HttpWrap, + type Provenance, + readCookie, + readSignedCookie, +} from "../http/index.ts"; +import { hashToken, matchesHash, newToken } from "./secrets.ts"; + +/** What a session is for: a browser (its cookie), a device (a command-line client's token), an ephemeral login. */ +export type SessionKind = "browser" | "device" | "ephemeral"; + +/** A browser or device session as `Sessions.list` shows it: never its token or hash. */ +export type SessionInfo = { + readonly id: string; + readonly label: string; + readonly kind: "browser" | "device"; + readonly principal: unknown; + readonly created: number; + readonly lastUsed: number; +}; + +/** The kit's sessions (one per server): issue, verify, revoke, and the watchable list of persisted ones. */ +export const Sessions = local("chord.app.sessions", { + version: 1, + params: {}, + members: { + /** The browser and device sessions (ephemeral ones are not listed: there may be many, and they are short). */ + list: arr( + obj({ + id: str(), + label: str(), + kind: oneOf("browser", "device"), + principal: unknown(), + created: num(), + lastUsed: num(), + }), + ), + /** + * A new session for `principal`: its id, its token (shown once, kept only hashed) and the session cookie carrying + * it for a browser (`Set-Cookie`; `secure` for a remote one). + */ + issue: method( + obj({ + principal: unknown(), + label: str(), + kind: oneOf("browser", "device", "ephemeral"), + secure: opt(bool()), + }), + obj({ id: str(), token: str(), cookie: str() }), + ), + /** The principal `token` authenticates as, or `null` (unknown, revoked, expired, malformed). */ + verify: method(str(), unknown()), + /** Ends session `id` at once: its token authenticates nothing more. */ + revoke: method(str()), + }, +}); + +/** The name of the kit's session cookie. */ +export const SESSION_COOKIE = "chord-session"; + +/** The stored sessions: by id, each with its secret's hash and its principal (as JSON text: any of the app's). */ +const SessionsDocument = defineState("chord.app.sessions", { + schema: obj({ + sessions: map( + obj({ + hash: str(), + label: str(), + kind: oneOf("browser", "device"), + principal: str(), + created: num(), + lastUsed: num(), + }), + ), + }), + version: 1, + initial: () => ({ sessions: {} }), + persist: true, + checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 50, +}); + +/** An ephemeral session, in memory: its secret's hash and its principal. */ +type EphemeralSession = { readonly hash: string; readonly principal: Principal }; + +/** How often a session's `lastUsed` is written at most: once a minute (not one commit per request). */ +const TOUCH_MS = 60_000; + +/** How long a persisted session lives unused: 400 days, as its cookie. */ +const SESSION_IDLE_MS = 400 * 24 * 60 * 60_000; + +/** The sessions' limits: how many ephemeral sessions live at once (1,000 by default; past it the oldest ends). */ +export type SessionLimits = { readonly maxEphemeral?: number }; + +/** + * The kit's facet serving `Sessions` (a host-lifetime object) and contributing its authenticator: the bearer token + * first, else the session cookie signed with `cookieSecret`. + */ +export function sessionsFacet

(cookieSecret: string, limits: SessionLimits = {}): Facet

{ + const maxEphemeral = limits.maxEphemeral ?? 1000; + return defineFacet

((host) => + host.serve(Sessions, (_params, self) => { + const document = self.state(SessionsDocument); + // Insertion order is age order: the first entry is the oldest. + const ephemeral = new Map(); + const change = (fn: (sessions: Record) => void, context: Context) => + // Not awaited by authentication: the request goes on; a write that fails is made again by a later request. + void document.change((draft) => fn(draft.sessions), context).catch(() => {}); + /** The principal of `token`, or `undefined`; touches or lazily removes the persisted session it names. */ + const authenticate = (token: string, context: Context): Principal | undefined => { + const dot = token.indexOf("."); + if (dot <= 0) return undefined; + const id = token.slice(0, dot); + const secret = token.slice(dot + 1); + const short = ephemeral.get(id); + if (short !== undefined) return matchesHash(secret, short.hash) ? short.principal : undefined; + // A trust boundary: `id` is the client's (`Object.hasOwn`: no inherited member is a session). + const sessions = document.value.sessions; + if (!Object.hasOwn(sessions, id)) return undefined; + const session = sessions[id]!; + if (!matchesHash(secret, session.hash)) return undefined; + const now = Date.now(); + if (now - session.lastUsed > SESSION_IDLE_MS) { + change((draft) => { + delete draft[id]; + }, context); + return undefined; + } + if (now - session.lastUsed > TOUCH_MS) { + change((draft) => { + if (Object.hasOwn(draft, id)) draft[id]!.lastUsed = now; + }, context); + } + // Stored by `issue` from a principal of the app's type. + return JSON.parse(session.principal) as Principal; + }; + self.contribute(Authenticators, (request, context) => { + const token = bearerToken(request) ?? readCookie(request, SESSION_COOKIE, cookieSecret); + return token === undefined ? undefined : authenticate(token, context); + }); + self.contribute(Http, { wraps: [rollingCookie(cookieSecret, authenticate)] }); + return { + list: self.derive([document], ([value]) => + Object.entries(value.sessions).map(([id, { label, kind, principal, created, lastUsed }]) => ({ + id, + label, + kind, + // Stored by `issue` as JSON text. + principal: JSON.parse(principal) as JsonValue, + created, + lastUsed, + })), + ), + issue: async ({ principal, label, kind, secure }, ctx) => { + const id = newToken(12); + const secret = newToken(32); + if (kind === "ephemeral") { + // Principals are the app's: `issue`'s callers pass one of its type. + ephemeral.set(id, { hash: hashToken(secret), principal: principal as Principal }); + for (const [oldest] of ephemeral) { + if (ephemeral.size <= maxEphemeral) break; + ephemeral.delete(oldest); + } + } else { + const now = Date.now(); + const stored = JSON.stringify(principal); + await document.change((draft) => { + draft.sessions[id] = { + hash: hashToken(secret), + label, + kind, + principal: stored, + created: now, + lastUsed: now, + }; + }, ctx); + } + const token = `${id}.${secret}`; + // Only whether the browser is remote matters to the cookie (`Secure`). + const provenance: Provenance = secure === true ? { kind: "remote", host: "" } : { kind: "local" }; + const set = sessionCookie(token, kind, { provenance }, cookieSecret); + return { id, token, cookie: set }; + }, + verify: async (token, ctx) => (authenticate(token, ctx) as JsonValue | undefined) ?? null, + revoke: async (id, ctx) => { + if (ephemeral.delete(id)) return null; + await document.change((draft) => { + delete draft.sessions[id]; + }, ctx); + return null; + }, + }; + }), + ); +} + +/** + * The session cookie carrying `token` (a session of `kind`) for a browser of `request`'s provenance: a persisted + * session's lives as long as the session does unused (400 days, renewed by `rollingCookie`), an ephemeral one's only + * while the browser runs. + */ +function sessionCookie( + token: string, + kind: SessionKind, + request: Pick, + secret: string, +): string { + const maxAge = kind === "ephemeral" ? undefined : SESSION_IDLE_MS / 1000; + return cookie(SESSION_COOKIE, token, { secret, request, maxAge }); +} + +/** How old a session cookie gets before a response renews it: a day. */ +const ROLL_MS = 24 * 60 * 60_000; + +/** + * The wrap around every route renewing the session cookie a request signed in with once it is older than a day: the + * response sets it again with a fresh signed expiry, so a browser used within every 400 days stays signed in. Not when + * the route sets a cookie itself, nor for an ephemeral session's (it has no expiry) or a token that no longer + * authenticates. + */ +function rollingCookie( + secret: string, + authenticate: (token: string, context: Context) => Principal | undefined, +): HttpWrap { + return { + name: "chord.app.rolling-cookie", + phase: "headers", + scope: "all", + wrap: async (request, next, ctx) => { + const response = await next(request); + if (ctx.principal === undefined || Object.keys(response.headers ?? {}).some(isSetCookie)) return response; + const signed = readSignedCookie(request, SESSION_COOKIE, secret); + if (signed?.expires === undefined || signed.expires - Date.now() > SESSION_IDLE_MS - ROLL_MS) return response; + if (authenticate(signed.value, ctx) === undefined) return response; + const renewed = sessionCookie(signed.value, "browser", request, secret); + return { ...response, headers: { ...response.headers, "Set-Cookie": renewed } }; + }, + }; +} + +/** Whether a header name is `Set-Cookie` (any case). */ +function isSetCookie(name: string): boolean { + return name.toLowerCase() === "set-cookie"; +} + +/** The token of an `Authorization: Bearer ` header. */ +function bearerToken(request: HttpRequest): string | undefined { + const header = request.headers.get("authorization"); + const match = header === null ? null : /^Bearer\s+(\S+)$/i.exec(header); + return match?.[1]; +} diff --git a/packages/chord/src/next-node/app/tar.ts b/packages/chord/src/next-node/app/tar.ts new file mode 100644 index 000000000..7c1cde0ad --- /dev/null +++ b/packages/chord/src/next-node/app/tar.ts @@ -0,0 +1,181 @@ +// A minimal ustar (POSIX tar) writer and reader for the kit's address archives (slice 13 §4): regular files and +// directories, paths of at most 255 bytes, nothing else. The reader is a trust boundary: an archive may come from +// anywhere, so it refuses every entry that is no regular file or directory, a path that is absolute, holds `..`, a +// backslash or NUL, or names an empty segment, and a header whose checksum does not hold. + +/** One entry to write: its path (`/`-separated, relative) and its bytes, as a size and the chunks that make it up. */ +export type TarEntry = { readonly path: string; readonly size: number; readonly data: AsyncIterable }; + +/** One entry read: a regular file (its data, to be consumed before the next entry) or a directory. */ +export type TarRead = + | { readonly kind: "file"; readonly path: string; readonly size: number; readonly data: AsyncIterable } + | { readonly kind: "directory"; readonly path: string }; + +/** A tar block. */ +const BLOCK = 512; + +/** The archive of `entries`, as the chunks of its bytes (two zero blocks end it). */ +export async function* writeTar(entries: AsyncIterable | Iterable): AsyncGenerator { + for await (const entry of entries) { + yield header(entry.path, entry.size); + let written = 0; + for await (const chunk of entry.data) { + written += chunk.length; + if (written > entry.size) throw new Error(`tar: ${entry.path} is larger than its size ${entry.size}`); + yield chunk; + } + if (written !== entry.size) throw new Error(`tar: ${entry.path} is ${written} bytes, not ${entry.size}`); + const pad = (BLOCK - (entry.size % BLOCK)) % BLOCK; + if (pad > 0) yield new Uint8Array(pad); + } + yield new Uint8Array(BLOCK * 2); +} + +/** The ustar header of a regular file at `path` of `size` bytes. */ +function header(path: string, size: number): Uint8Array { + checkPath(path); + const block = new Uint8Array(BLOCK); + const bytes = new TextEncoder().encode(path); + let name = bytes; + let prefix = new Uint8Array(); + if (bytes.length > 100) { + // Split at a `/` so the name has at most 100 bytes and the prefix at most 155. + const at = path.lastIndexOf("/", path.length - 1); + const split = new TextEncoder().encode(path.slice(0, at)).length; + if (at < 0 || split > 155 || bytes.length - split - 1 > 100) throw new Error(`tar: the path ${path} is too long`); + prefix = bytes.subarray(0, split); + name = bytes.subarray(split + 1); + } + block.set(name, 0); + field(block, 100, 8, "0000644"); + field(block, 108, 8, "0000000"); + field(block, 116, 8, "0000000"); + field(block, 124, 12, size.toString(8).padStart(11, "0")); + field( + block, + 136, + 12, + Math.floor(Date.now() / 1000) + .toString(8) + .padStart(11, "0"), + ); + block.fill(0x20, 148, 156); + block[156] = 0x30; + block.set(new TextEncoder().encode("ustar\u000000"), 257); + block.set(prefix, 345); + const sum = block.reduce((total, byte) => total + byte, 0); + field(block, 148, 8, `${sum.toString(8).padStart(6, "0")}\u0000 `); + return block; +} + +/** Writes `text` (ASCII) at `offset` of `block`, NUL-terminated within `length`. */ +function field(block: Uint8Array, offset: number, length: number, text: string): void { + const bytes = new TextEncoder().encode(text); + if (bytes.length > length) throw new Error(`tar: ${text} does not fit ${length} bytes`); + block.set(bytes, offset); +} + +/** Refuses a path an archive may not hold: absolute, with `..`, `.`, an empty segment, a backslash or a NUL. */ +function checkPath(path: string): void { + const segments = path.split("/"); + const bad = path === "" || path.includes("\\") || path.includes("\u0000") || path.startsWith("/"); + if (bad || segments.some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new Error(`tar: the path ${JSON.stringify(path)} is refused`); + } +} + +/** + * Reads the archive `source` entry by entry (each file's data read fully before the next entry is asked for), refusing + * what it may not hold (see the file's header). + */ +export async function* readTar(source: AsyncIterable): AsyncGenerator { + const reader = new ByteReader(source); + for (;;) { + const block = await reader.read(BLOCK); + if (block.length < BLOCK) throw new Error("tar: the archive ends inside a header"); + if (block.every((byte) => byte === 0)) return; + const { path, size, type } = parseHeader(block); + const padded = Math.ceil(size / BLOCK) * BLOCK; + if (type === "directory") { + yield { kind: "directory", path }; + await reader.skip(padded); + continue; + } + let left = size; + const data = (async function* () { + while (left > 0) { + const chunk = await reader.read(Math.min(left, 64 * 1024)); + if (chunk.length === 0) throw new Error(`tar: the archive ends inside ${path}`); + left -= chunk.length; + yield chunk; + } + })(); + yield { kind: "file", path, size, data }; + // What the consumer left unread (also when it ended the data's iteration early), then the padding. + await reader.skip(left + padded - size); + } +} + +/** A header's path, size and type, its checksum checked; anything but a regular file or a directory refused. */ +function parseHeader(block: Uint8Array): { + readonly path: string; + readonly size: number; + readonly type: "file" | "directory"; +} { + const stored = Number.parseInt(text(block, 148, 8).trim(), 8); + let sum = 0; + for (let index = 0; index < BLOCK; index++) sum += index >= 148 && index < 156 ? 0x20 : block[index]!; + if (stored !== sum) throw new Error("tar: a header's checksum does not hold"); + const flag = String.fromCharCode(block[156]!); + const type = flag === "0" || flag === "\u0000" ? "file" : flag === "5" ? "directory" : undefined; + if (type === undefined) throw new Error(`tar: an entry of type ${JSON.stringify(flag)} is refused (files only)`); + const prefix = text(block, 345, 155); + const name = text(block, 0, 100); + const path = (prefix === "" ? name : `${prefix}/${name}`).replace(/\/$/, ""); + checkPath(path); + const size = Number.parseInt(text(block, 124, 12).trim() || "0", 8); + if (!Number.isSafeInteger(size) || size < 0) throw new Error(`tar: ${path} has no valid size`); + return { path, size: type === "directory" ? 0 : size, type }; +} + +/** The NUL-terminated text of a header field. */ +function text(block: Uint8Array, offset: number, length: number): string { + const bytes = block.subarray(offset, offset + length); + const end = bytes.indexOf(0); + return new TextDecoder().decode(end < 0 ? bytes : bytes.subarray(0, end)); +} + +/** Reads exactly as many bytes as asked from a stream of chunks (fewer only at its end). */ +class ByteReader { + readonly #source: AsyncIterator; + #buffer = new Uint8Array(); + + constructor(source: AsyncIterable) { + this.#source = source[Symbol.asyncIterator](); + } + + /** Up to `length` bytes: `length` unless the stream ends first. */ + async read(length: number): Promise { + while (this.#buffer.length < length) { + const next = await this.#source.next(); + if (next.done === true) break; + const joined = new Uint8Array(this.#buffer.length + next.value.length); + joined.set(this.#buffer); + joined.set(next.value, this.#buffer.length); + this.#buffer = joined; + } + const taken = this.#buffer.subarray(0, Math.min(length, this.#buffer.length)); + this.#buffer = this.#buffer.subarray(taken.length); + return taken; + } + + /** Skips `length` bytes. */ + async skip(length: number): Promise { + let left = length; + while (left > 0) { + const skipped = await this.read(Math.min(left, 64 * 1024)); + if (skipped.length === 0) throw new Error("tar: the archive ends early"); + left -= skipped.length; + } + } +} diff --git a/packages/chord/src/next-node/app/uploads.ts b/packages/chord/src/next-node/app/uploads.ts new file mode 100644 index 000000000..6ce14e0b0 --- /dev/null +++ b/packages/chord/src/next-node/app/uploads.ts @@ -0,0 +1,172 @@ +// Chunked uploads of one `Files` object (slice 13 §4, design §23 "Chunked transfers"): each upload one file handle in +// the run's directory, written at positions. An append is accepted at the offset the accepted appends reached, so a +// writer may send several without waiting (the call window allows it): each write lands at its own position whatever +// order the thread pool completes them in. `uploaded`, `finish` and expiry wait for an upload's writes in flight; a +// failed write ends its upload. + +import type { FileHandle } from "node:fs/promises"; +import { mkdir, open, rename, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { ChordError } from "../../next/data/index.ts"; +import { invalid } from "../../next/data/internal.ts"; +import { newToken } from "./secrets.ts"; + +/** An upload under way: its file, the bytes accepted, its writes in flight, its first failed write, its end. */ +type Upload = { + readonly name: string; + readonly mime: string; + readonly handle: FileHandle; + size: number; + touched: number; + readonly writes: Set>; + failed: boolean; + ended: Promise | undefined; +}; + +/** A finished upload: its name, type and size, its bytes moved to the path `finish` was given. */ +export type Finished = { readonly name: string; readonly mime: string; readonly size: number }; + +/** + * The uploads under way of one `Files` object, in the directory `run` (this object's own, made on first use), at most + * `max` at once: each holds a file handle until it ends, so a caller beginning uploads without end would exhaust the + * process's descriptors. + */ +export class Uploads { + readonly #run: string; + readonly #max: number; + readonly #uploads = new Map(); + + constructor(run: string, max: number) { + this.#run = run; + this.#max = max; + } + + /** The bytes accepted by every upload under way (they count against the host's cap). */ + get size(): number { + let size = 0; + for (const upload of this.#uploads.values()) size += upload.size; + return size; + } + + /** A new upload of `name` and `mime`: its id; `unavailable` (`busy`) while `max` are under way. */ + async begin(name: string, mime: string): Promise { + if (this.#uploads.size >= this.#max) { + throw new ChordError({ + code: "unavailable", + reason: "busy", + message: `files: ${this.#max} uploads are under way; finish one, or wait for an idle one to expire`, + }); + } + const id = newToken(12); + await mkdir(this.#run, { recursive: true, mode: 0o700 }); + const handle = await open(join(this.#run, id), "wx", 0o600); + const upload: Upload = { + name, + mime, + handle, + size: 0, + touched: Date.now(), + writes: new Set(), + failed: false, + ended: undefined, + }; + this.#uploads.set(id, upload); + return id; + } + + /** + * Writes `bytes` at `offset` of upload `id`, which must be where the accepted appends reached (else `invalid`, its + * details the expected `{ offset }`); `room(adding, fileSize)` refuses what the caps do not allow, before anything + * is accepted. A failed write ends the upload. + */ + async append( + id: string, + offset: number, + bytes: Uint8Array, + room: (adding: number, fileSize: number) => void, + ): Promise { + const upload = this.#underway(id); + if (offset !== upload.size) { + throw invalid("args", `files: upload ${id} is at offset ${upload.size}, not ${offset}`, { + offset: upload.size, + }); + } + room(bytes.length, upload.size + bytes.length); + // Accepted now, with no await before the write starts: the next append may follow at once, its write going to + // its own position. + upload.size += bytes.length; + upload.touched = Date.now(); + const write = upload.handle.write(bytes, 0, bytes.length, offset); + upload.writes.add(write); + try { + await write; + } catch (error) { + upload.writes.delete(write); + if (!upload.failed) { + upload.failed = true; + this.#uploads.delete(id); + await this.#end(upload); + await rm(join(this.#run, id), { force: true }); + } + throw error; + } + upload.writes.delete(write); + } + + /** The bytes of upload `id` written so far, once its writes in flight landed: where a writer resumes. */ + async uploaded(id: string): Promise { + const upload = this.#underway(id); + await Promise.allSettled([...upload.writes]); + if (upload.failed) throw failedUpload(id); + return upload.size; + } + + /** Ends upload `id` once its writes landed and moves its file to `path`; refused when a write of it failed. */ + async finish(id: string, path: string): Promise { + const upload = this.#underway(id); + // Taken out first: an append arriving now finds no upload. + this.#uploads.delete(id); + await this.#end(upload); + if (upload.failed) throw failedUpload(id); + await rename(join(this.#run, id), path); + return { name: upload.name, mime: upload.mime, size: upload.size }; + } + + /** Drops the uploads unwritten for `idleMs`, once their writes landed. */ + async expire(idleMs: number): Promise { + const now = Date.now(); + for (const [id, upload] of this.#uploads) { + if (now - upload.touched >= idleMs) await this.#discard(id, upload); + } + } + + /** Drops every upload under way (their object ended). */ + async close(): Promise { + for (const [id, upload] of this.#uploads) await this.#discard(id, upload); + } + + /** The upload `id` under way; else `invalid`. */ + #underway(id: string): Upload { + const upload = this.#uploads.get(id); + if (upload === undefined) throw invalid("args", `files: no upload ${id} (finished, failed, expired or unknown)`); + return upload; + } + + /** Takes upload `id` out, closes its file once its writes landed and removes it. */ + async #discard(id: string, upload: Upload): Promise { + this.#uploads.delete(id); + await this.#end(upload); + await rm(join(this.#run, id), { force: true }); + } + + /** Closes `upload`'s file once its writes in flight landed; once, whoever ends it first. */ + #end(upload: Upload): Promise { + upload.ended ??= Promise.allSettled([...upload.writes]).then(() => upload.handle.close()); + return upload.ended; + } +} + +/** The refusal of an upload one of whose writes failed. */ +function failedUpload(id: string): Error { + return invalid("args", `files: upload ${id} failed to write and was dropped`); +} diff --git a/packages/chord/src/next-node/app/users.ts b/packages/chord/src/next-node/app/users.ts new file mode 100644 index 000000000..2461ad407 --- /dev/null +++ b/packages/chord/src/next-node/app/users.ts @@ -0,0 +1,68 @@ +// The users plugin (slice 13 §3, decision "The app kit, point 2: principals and users"): accounts keyed by the user's +// id with a profile the app describes (a schema it passes in: a Slack-like profile or just a name), kept in the +// server's store. User data never rides in the principal: a principal names the user, `Users` holds the rest. +// An exported facet, not a plugin directory: it takes the app's schema as an argument (settings are arguments), and a +// plugin directory could not import it (plugins import only the app's API). The app loads it in its root by +// re-exporting it through its API (`export const users = defineUsers(Profile)` in `/v3/server`, a one-line plugin). + +import type { JsonValue } from "../../json.ts"; +import { arr, local, map, method, nul, obj, type Schema, str, union } from "../../next/contracts/index.ts"; +import type { Principal } from "../../next/data/index.ts"; +import { invalid } from "../../next/data/internal.ts"; +import { defineState } from "../../next/documents/index.ts"; +import { defineFacet, type Facet } from "../../next/host/index.ts"; + +/** The users plugin for profiles of schema `profile`: its contract and the facet serving it on the server. */ +export function defineUsers(profile: Schema) { + const Users = local("chord.app.users", { + version: 1, + params: {}, + members: { + /** Every user: id and profile. */ + list: arr(obj({ id: str(), profile })), + /** The profile of user `id`, or `null`. */ + get: method(str(), union([profile, nul()])), + /** Sets the profile of user `id` (a new user: an account made). */ + update: method(obj({ id: str(), profile })), + /** Removes user `id`'s account. */ + remove: method(str()), + }, + }); + // Stored as JSON: the document's types cannot follow a generic profile, the contract's members do. + const stored = profile as Schema; + const Accounts = defineState("chord.app.users", { + schema: obj({ users: map(stored) }), + version: 1, + initial: () => ({ users: {} }), + persist: true, + checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 50, + }); + const facet: Facet

= defineFacet

((host) => + host.serve(Users, (_params, self) => { + const accounts = self.state(Accounts); + return { + list: self.derive([accounts], ([value]) => + Object.entries(value.users).map(([id, each]) => ({ id, profile: each as T })), + ), + // A trust boundary: `id` is the caller's (`Object.hasOwn`: no inherited member is a user). Written by + // `update` from a `T`. + get: async (id) => (Object.hasOwn(accounts.value.users, id) ? (accounts.value.users[id] as T) : null), + update: async ({ id, profile: next }, ctx) => { + // As a key it would set the record's prototype, not add a user. + if (id === "__proto__") throw invalid("args", "users: __proto__ is no user id"); + await accounts.change((draft) => { + draft.users[id] = next as JsonValue; + }, ctx); + return null; + }, + remove: async (id, ctx) => { + await accounts.change((draft) => { + delete draft.users[id]; + }, ctx); + return null; + }, + }; + }), + ); + return { Users, facet }; +} diff --git a/packages/chord/src/next-node/app/worker.ts b/packages/chord/src/next-node/app/worker.ts new file mode 100644 index 000000000..edb398434 --- /dev/null +++ b/packages/chord/src/next-node/app/worker.ts @@ -0,0 +1,72 @@ +// `startWorker` (slice 13 §1): a worker of an app built with the kit, in its entry module. `workerMain` plus the kit's +// part: the worker's directory under its kind and address with its store, the host at the worker's address acting as +// the principal the server gave it, connected to the server first, its plugins loaded with the kit's worker facets +// (`WorkerInfo`, `Files`). + +import { join } from "node:path"; +import type { JsonValue } from "../../json.ts"; +import type { Principal } from "../../next/data/index.ts"; +import { createHost } from "../../next/host/index.ts"; +import { openStore, sqliteBackend } from "../../next/store/index.ts"; +import { openNodeSqliteDatabase } from "../../sqlite/node.ts"; +import type { Watchable } from "../../watch.ts"; +import { workerMain } from "../workers/index.ts"; +import type { AppApi, AppKind, KitStart } from "./app.ts"; +import { filesFacet } from "./files.ts"; +import { workerInfoFacet } from "./info.ts"; +import { privateDirectory, privateStore } from "./layout.ts"; + +/** What `startWorker` takes: the app's API and kinds as the server's `startApp` has them, this process's modules. */ +export type WorkerOptions = { + readonly api: AppApi; + readonly kinds: Readonly>; + /** Extra keep-alive counts of the app (a record of numbers, all zero when idle). */ + readonly busy?: Watchable>>; + /** The worker host's errors (`HostError`) and a failed start's. */ + readonly onError: (error: unknown) => void; +}; + +/** Runs this process as a worker of the app: once the start message arrived, its host is up and its plugins loaded. */ +export function startWorker

(options: WorkerOptions): void { + // The worker's facet kind comes with the start message: every kind's packages are offered, its own are imported. + const packages = Object.assign({}, ...Object.values(options.kinds).map((kind) => kind.packages ?? {})); + workerMain( + async ({ address, start, parent, plugins, onClose }, context) => { + // The server's `startApp` sends `{ kit, app }`. + const { kit, app } = start as { readonly kit: KitStart; readonly app: JsonValue }; + await privateDirectory(kit.dir); + await privateStore(join(kit.dir, "store.sqlite")); + const store = await openStore( + sqliteBackend(await openNodeSqliteDatabase(join(kit.dir, "store.sqlite"))), + context, + ); + onClose((ctx) => store.close(ctx)); + // Principals are the app's: the server made these with its `principal` options. + const host = createHost

({ + address, + principal: kit.principal as unknown as P, + store, + onError: options.onError, + }); + onClose((ctx) => host.close(ctx)); + // First: the server sees the worker started, then the load runs. + host.connect(parent, { principal: kit.server as unknown as P, trusted: true }, context); + const info = { + address: { kind: address.kind, key: address.key, params: address.params as JsonValue }, + start: app, + }; + const core = [ + { name: "chord.app.worker-info", facet: workerInfoFacet

(info) }, + { name: "chord.app.files", facet: filesFacet

(join(kit.dir, "files"), kit.files, kit.uploads) }, + ]; + await plugins.load(host, { kind: kit.facet, core }, context); + return options.busy === undefined ? {} : { busy: options.busy }; + }, + { + hostPackages: { ...options.api.modules, ...packages }, + // Worker errors are public (decision "worker errors in public"): a fixed message, never the error's own. + redact: () => ({ message: "worker failed" }), + onError: options.onError, + }, + ); +} diff --git a/packages/chord/src/next-node/http/README.md b/packages/chord/src/next-node/http/README.md index 266ba00d0..8d482c464 100644 --- a/packages/chord/src/next-node/http/README.md +++ b/packages/chord/src/next-node/http/README.md @@ -35,12 +35,16 @@ await server.close(ctx); // a plugin's server facet self.contribute(Http, { routes: { - "GET /notes/:id": async (request, ctx) => ({ kind: "json", value: … }), + "GET /notes/:id": async (request, ctx) => ({ kind: "json", value: … }), // needs a principal (else 401) + "GET /status": { public: true, handler: async () => ({ kind: "json", value: "ok" }) }, "GET /": { page: true, handler: async (request) => ({ kind: "html", body: `` }; + "GET /page": { + public: true, + handler: async (request) => { + nonce = request.nonce; + return { kind: "html", body: `` }; + }, }, }, framers: ["https://shell.example.com"], @@ -273,16 +319,22 @@ describe("routes and wraps failing", () => { const server = await served({ "@app/a": contributing({ routes: { - "GET /throws": async () => { - throw new Error("secret"); + "GET /throws": { + public: true, + handler: async () => { + throw new Error("secret"); + }, }, - "GET /failure": async () => { - throw new HttpFailure(422, "no"); + "GET /failure": { + public: true, + handler: async () => { + throw new HttpFailure(422, "no"); + }, }, }, }), "@app/b": contributing({ - routes: { "GET /wrapped": async () => ({ kind: "json", value: 1 }) }, + routes: { "GET /wrapped": { public: true, handler: async () => ({ kind: "json", value: 1 }) } }, wraps: [ { name: "broken", @@ -310,9 +362,12 @@ describe("routes and wraps failing", () => { const server = await served({ "@app/a": contributing({ routes: { - "GET /r": async (_request, ctx) => { - order.push(`route as ${ctx.principal?.kind ?? "anonymous"}`); - return { kind: "json", value: 1 }; + "GET /r": { + public: true, + handler: async (_request, ctx) => { + order.push(`route as ${ctx.principal?.kind ?? "anonymous"}`); + return { kind: "json", value: 1 }; + }, }, }, wraps: [ @@ -347,16 +402,21 @@ describe("reloads and close", () => { const gate = Promise.withResolvers(); const old = contributing({ routes: { - "GET /v": async () => { - await gate.promise; - return { kind: "html", body: "old" }; + "GET /v": { + public: true, + handler: async () => { + await gate.promise; + return { kind: "html", body: "old" }; + }, }, }, }); const server = await served({ "@app/v": old }); const running = send(server.port, "GET", "/v"); await new Promise((resolve) => setTimeout(resolve, 50)); - const next = contributing({ routes: { "GET /v": async () => ({ kind: "html", body: "new" }) } }); + const next = contributing({ + routes: { "GET /v": { public: true, handler: async () => ({ kind: "html", body: "new" }) } }, + }); await server.host.reload(server.facets({ "@app/v": next }), context); expect((await send(server.port, "GET", "/v")).body.toString()).toBe("new"); gate.resolve(); @@ -370,13 +430,19 @@ describe("reloads and close", () => { { "@app/slow": contributing({ routes: { - "GET /quick": async () => { - await new Promise((resolve) => setTimeout(resolve, 30)); - return { kind: "html", body: "done" }; + "GET /quick": { + public: true, + handler: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return { kind: "html", body: "done" }; + }, }, - "GET /stuck": async (_request, ctx: Ctx) => { - sealed = ctx.abortSignal; - return new Promise(() => {}); + "GET /stuck": { + public: true, + handler: async (_request, ctx: Ctx) => { + sealed = ctx.abortSignal; + return new Promise(() => {}); + }, }, }, }), @@ -405,9 +471,12 @@ describe("reloads and close", () => { { "@app/r": contributing({ routes: { - "GET /": async () => { - calls++; - return { kind: "json", value: 1 }; + "GET /": { + public: true, + handler: async () => { + calls++; + return { kind: "json", value: 1 }; + }, }, }, }), @@ -446,7 +515,9 @@ describe("cancellation", () => { let later = 0; const server = await served( { - "@app/r": contributing({ routes: { "GET /": async () => ({ kind: "html", body: "ok" }) } }), + "@app/r": contributing({ + routes: { "GET /": { public: true, handler: async () => ({ kind: "html", body: "ok" }) } }, + }), "@app/z-later": defineFacet((_host, self) => self.contribute(Authenticators, () => { later++; @@ -476,7 +547,11 @@ describe("cancellation", () => { it("asks no authenticator for a request aborted before it arrived", async () => { let calls = 0; const server = await served( - { "@app/r": contributing({ routes: { "GET /": async () => ({ kind: "html", body: "ok" }) } }) }, + { + "@app/r": contributing({ + routes: { "GET /": { public: true, handler: async () => ({ kind: "html", body: "ok" }) } }, + }), + }, { config: { listen: undefined }, authenticate: () => { @@ -503,18 +578,21 @@ describe("cancellation", () => { { "@app/b": contributing({ routes: { - "GET /": async (_request, ctx) => { - entered.resolve(); - if (when === "inside the route") abort.abort(new Error("client gone")); - bornSealed = ctx.abortSignal?.aborted; - return { - kind: "bytes", - mime: "application/octet-stream", - read: async () => { - reads++; - return new Uint8Array([1]); - }, - }; + "GET /": { + public: true, + handler: async (_request, ctx) => { + entered.resolve(); + if (when === "inside the route") abort.abort(new Error("client gone")); + bornSealed = ctx.abortSignal?.aborted; + return { + kind: "bytes", + mime: "application/octet-stream", + read: async () => { + reads++; + return new Uint8Array([1]); + }, + }; + }, }, }, }), @@ -539,11 +617,14 @@ describe("cancellation", () => { { "@app/b": contributing({ routes: { - "GET /": async () => ({ - kind: "bytes", - mime: "application/octet-stream", - read: async () => new Uint8Array([1]), - }), + "GET /": { + public: true, + handler: async () => ({ + kind: "bytes", + mime: "application/octet-stream", + read: async () => new Uint8Array([1]), + }), + }, }, }), }, @@ -587,10 +668,13 @@ describe("cancellation", () => { { "@app/wait": contributing({ routes: { - "GET /wait": async () => { - entered.resolve(); - await gate.promise; - return { kind: "html", body: "done" }; + "GET /wait": { + public: true, + handler: async () => { + entered.resolve(); + await gate.promise; + return { kind: "html", body: "done" }; + }, }, }, }), @@ -612,7 +696,11 @@ describe("cancellation", () => { const entered = Promise.withResolvers(); const gate = Promise.withResolvers(); const server = await served( - { "@app/r": contributing({ routes: { "GET /": async () => ({ kind: "html", body: "ok" }) } }) }, + { + "@app/r": contributing({ + routes: { "GET /": { public: true, handler: async () => ({ kind: "html", body: "ok" }) } }, + }), + }, { config: { listen: undefined, closeTimeout: 10 }, authenticate: async () => { @@ -640,15 +728,18 @@ describe("cancellation", () => { { "@app/bytes": contributing({ routes: { - "GET /stream": async () => ({ - kind: "bytes", - mime: "application/octet-stream", - read: async (_offset, _length, ctx) => { - reads++; - readAfterSeal ||= ctx.abortSignal?.aborted ?? false; - return new Uint8Array([reads]); - }, - }), + "GET /stream": { + public: true, + handler: async () => ({ + kind: "bytes", + mime: "application/octet-stream", + read: async (_offset, _length, ctx) => { + reads++; + readAfterSeal ||= ctx.abortSignal?.aborted ?? false; + return new Uint8Array([reads]); + }, + }), + }, }, }), }, @@ -672,10 +763,13 @@ describe("cancellation", () => { { "@app/body": contributing({ routes: { - "POST /body": async (request) => { - const value = await request.json(); - writes++; - return { kind: "json", value }; + "POST /body": { + public: true, + handler: async (request) => { + const value = await request.json(); + writes++; + return { kind: "json", value }; + }, }, }, }), @@ -700,7 +794,11 @@ describe("answers", () => { // Sol's recheck N8: `Response` threw on a body for these statuses. it.each([204, 205, 304])("answers %s without a body", async (status) => { const server = await served( - { "@app/s": contributing({ routes: { "GET /": async () => ({ kind: "json", value: null, status }) } }) }, + { + "@app/s": contributing({ + routes: { "GET /": { public: true, handler: async () => ({ kind: "json", value: null, status }) } }, + }), + }, { config: { listen: undefined } }, ); const response = await server.http.fetch(loopback("/"), LOOPBACK); @@ -714,8 +812,11 @@ describe("answers", () => { { "@app/r": contributing({ routes: { - "GET /": async () => { - throw new ChordError({ code: "forbidden", message: "no" }); + "GET /": { + public: true, + handler: async () => { + throw new ChordError({ code: "forbidden", message: "no" }); + }, }, }, }), @@ -731,7 +832,12 @@ describe("answers", () => { it("sends several Set-Cookie headers", async () => { const server = await served({ "@app/r": contributing({ - routes: { "GET /": async () => ({ kind: "json", value: 1, headers: { "Set-Cookie": ["a=1", "b=2"] } }) }, + routes: { + "GET /": { + public: true, + handler: async () => ({ kind: "json", value: 1, headers: { "Set-Cookie": ["a=1", "b=2"] } }), + }, + }, }), }); expect((await send(server.port, "GET", "/")).headers["set-cookie"]).toEqual(["a=1", "b=2"]); @@ -752,9 +858,16 @@ describe("GC", () => { { "@app/b": contributing({ routes: { - "GET /": async (_request, ctx) => { - refs.push(new WeakRef(ctx)); - return { kind: "bytes", mime: "application/octet-stream", read: async () => new Uint8Array([1]) }; + "GET /": { + public: true, + handler: async (_request, ctx) => { + refs.push(new WeakRef(ctx)); + return { + kind: "bytes", + mime: "application/octet-stream", + read: async () => new Uint8Array([1]), + }; + }, }, }, }), @@ -777,10 +890,17 @@ describe("GC", () => { { "@app/b": contributing({ routes: { - "GET /": async (_request, ctx) => { - refs.push(new WeakRef(ctx)); - aborts.at(-1)?.abort(new Error("client gone")); - return { kind: "bytes", mime: "application/octet-stream", read: async () => new Uint8Array([1]) }; + "GET /": { + public: true, + handler: async (_request, ctx) => { + refs.push(new WeakRef(ctx)); + aborts.at(-1)?.abort(new Error("client gone")); + return { + kind: "bytes", + mime: "application/octet-stream", + read: async () => new Uint8Array([1]), + }; + }, }, }, }), @@ -804,9 +924,12 @@ describe("GC", () => { const server = await served({ "@app/a": contributing({ routes: { - "GET /r": async (request, ctx) => { - refs.push(new WeakRef(ctx), new WeakRef(request)); - return { kind: "json", value: 1 }; + "GET /r": { + public: true, + handler: async (request, ctx) => { + refs.push(new WeakRef(ctx), new WeakRef(request)); + return { kind: "json", value: 1 }; + }, }, }, }), diff --git a/packages/chord/test/next-node/http/node.test.ts b/packages/chord/test/next-node/http/node.test.ts index 8349bd751..c8aaf0de6 100644 --- a/packages/chord/test/next-node/http/node.test.ts +++ b/packages/chord/test/next-node/http/node.test.ts @@ -27,8 +27,8 @@ async function raw(port: number, text: string): Promise<{ readonly status: strin /** A route answering its path and params, under `GET /who` and `GET /n/:name`. */ const echo = contributing({ routes: { - "GET /who": async (request) => ({ kind: "json", value: request.path }), - "GET /n/:name": async (request) => ({ kind: "json", value: request.params }), + "GET /who": { public: true, handler: async (request) => ({ kind: "json", value: request.path }) }, + "GET /n/:name": { public: true, handler: async (request) => ({ kind: "json", value: request.params }) }, }, }); @@ -118,9 +118,15 @@ describe("Web requests", () => { { "@app/r": contributing({ routes: { - "GET /plain": async (request) => ({ kind: "json", value: request.path }), - "GET /headers": async (request) => ({ kind: "json", value: request.headers.get("x-thing") }), - "POST /body": async (request) => ({ kind: "json", value: await request.json() }), + "GET /plain": { public: true, handler: async (request) => ({ kind: "json", value: request.path }) }, + "GET /headers": { + public: true, + handler: async (request) => ({ kind: "json", value: request.headers.get("x-thing") }), + }, + "POST /body": { + public: true, + handler: async (request) => ({ kind: "json", value: await request.json() }), + }, }, }), }, @@ -157,9 +163,12 @@ describe("the request's scope", () => { { "@app/large": contributing({ routes: { - "GET /large": async (_request, ctx) => { - signal = ctx.abortSignal; - return { kind: "html", body: "x".repeat(size) }; + "GET /large": { + public: true, + handler: async (_request, ctx) => { + signal = ctx.abortSignal; + return { kind: "html", body: "x".repeat(size) }; + }, }, }, }), @@ -195,9 +204,12 @@ describe("the request's scope", () => { const server = await served({ "@app/slow": contributing({ routes: { - "GET /slow": async (_request, ctx) => { - entered.resolve(ctx.abortSignal!); - return new Promise(() => {}); + "GET /slow": { + public: true, + handler: async (_request, ctx) => { + entered.resolve(ctx.abortSignal!); + return new Promise(() => {}); + }, }, }, }), diff --git a/packages/chord/test/next-node/http/request.test.ts b/packages/chord/test/next-node/http/request.test.ts index a48a2429e..9bbfb491a 100644 --- a/packages/chord/test/next-node/http/request.test.ts +++ b/packages/chord/test/next-node/http/request.test.ts @@ -12,9 +12,12 @@ describe("the request a route gets", () => { const server = await served({ "@app/a": contributing({ routes: { - "GET /notes/:id": async (request) => { - seen = request; - return { kind: "json", value: null }; + "GET /notes/:id": { + public: true, + handler: async (request) => { + seen = request; + return { kind: "json", value: null }; + }, }, }, }), @@ -39,11 +42,17 @@ describe("bodies", () => { served({ "@app/a": contributing({ routes: { - "POST /json": async (request) => ({ kind: "json", value: await request.json(16) }), - "POST /body": async (request) => { - let bytes = 0; - for await (const chunk of request.body(16)) bytes += chunk.byteLength; - return { kind: "json", value: bytes }; + "POST /json": { + public: true, + handler: async (request) => ({ kind: "json", value: await request.json(16) }), + }, + "POST /body": { + public: true, + handler: async (request) => { + let bytes = 0; + for await (const chunk of request.body(16)) bytes += chunk.byteLength; + return { kind: "json", value: bytes }; + }, }, }, }), @@ -91,7 +100,9 @@ describe("bodies", () => { it("drains a small body nobody read and keeps the connection; a large one closes it", async () => { const server = await served({ - "@app/a": contributing({ routes: { "POST /ignore": async () => ({ kind: "json", value: 1 }) } }), + "@app/a": contributing({ + routes: { "POST /ignore": { public: true, handler: async () => ({ kind: "json", value: 1 }) } }, + }), }); const agent = new Agent({ keepAlive: true, maxSockets: 1 }); const post = (body: string) => diff --git a/packages/chord/test/next-node/http/response.test.ts b/packages/chord/test/next-node/http/response.test.ts index 3c953ba85..8913f459b 100644 --- a/packages/chord/test/next-node/http/response.test.ts +++ b/packages/chord/test/next-node/http/response.test.ts @@ -11,7 +11,7 @@ import { contributing, send, served } from "./support.ts"; /** A server answering `GET /r` with `response`. */ async function answering(response: HttpResponse) { - return served({ "@app/r": contributing({ routes: { "GET /r": async () => response } }) }); + return served({ "@app/r": contributing({ routes: { "GET /r": { public: true, handler: async () => response } } }) }); } /** A `bytes` response over `data` that records its reads. */ @@ -64,8 +64,11 @@ describe("descriptions", () => { const server = await served({ "@app/r": contributing({ routes: { - "GET /header": async () => ({ kind: "json", value: 1, headers: { "X-Split": "a\r\nSet-Cookie: x=1" } }), - "GET /bigint": async () => ({ kind: "json", value: { n: 1n } as never }), + "GET /header": { + public: true, + handler: async () => ({ kind: "json", value: 1, headers: { "X-Split": "a\r\nSet-Cookie: x=1" } }), + }, + "GET /bigint": { public: true, handler: async () => ({ kind: "json", value: { n: 1n } as never }) }, }, }), }); @@ -185,21 +188,27 @@ describe("bytes", () => { const server = await served({ "@app/r": contributing({ routes: { - "GET /short": async () => ({ - kind: "bytes", - mime: "text/plain", - size: 10, - read: async () => Buffer.from("abc"), - }), + "GET /short": { + public: true, + handler: async () => ({ + kind: "bytes", + mime: "text/plain", + size: 10, + read: async () => Buffer.from("abc"), + }), + }, // Astra's build review: a throwing read hung the response, unreported. - "GET /throws": async () => ({ - kind: "bytes", - mime: "text/plain", - size: 10, - read: async () => { - throw new Error("read failed"); - }, - }), + "GET /throws": { + public: true, + handler: async () => ({ + kind: "bytes", + mime: "text/plain", + size: 10, + read: async () => { + throw new Error("read failed"); + }, + }), + }, }, }), }); diff --git a/packages/chord/test/next-node/http/router.test.ts b/packages/chord/test/next-node/http/router.test.ts index 9854820c6..8bcff127e 100644 --- a/packages/chord/test/next-node/http/router.test.ts +++ b/packages/chord/test/next-node/http/router.test.ts @@ -104,18 +104,25 @@ describe("patterns and precedence (D8)", () => { expect(matched(router, "GET", "/v/b")).toEqual({ pattern: "GET /:name/b", params: { name: "v" } }); }); - it("takes a route with options: page and crossSite", () => { + it("takes a route with options: page, crossSite and public (a cross-site route is public: always anonymous)", () => { const { router } = compose([ "@app/a", { routes: { "GET /p": { handler: route("p"), page: true }, "POST /hook": { handler: route("h"), crossSite: true }, + "GET /open": { handler: route("o"), public: true }, + "GET /closed": route("c"), }, + sockets: { "/s": { handler: () => {}, public: true }, "/t": () => {} }, }, ]); - expect(router.route("GET", "/p")).toMatchObject({ page: true, crossSite: false }); - expect(router.route("POST", "/hook")).toMatchObject({ page: false, crossSite: true }); + expect(router.route("GET", "/p")).toMatchObject({ page: true, crossSite: false, public: false }); + expect(router.route("POST", "/hook")).toMatchObject({ page: false, crossSite: true, public: true }); + expect(router.route("GET", "/open")).toMatchObject({ public: true }); + expect(router.route("GET", "/closed")).toMatchObject({ public: false }); + expect(router.socket("/s")).toMatchObject({ public: true }); + expect(router.socket("/t")).toMatchObject({ public: false }); expect(router.route("GET", "/notes")).toBeUndefined(); }); }); diff --git a/packages/chord/test/next-node/http/upgrades.test.ts b/packages/chord/test/next-node/http/upgrades.test.ts index f4de24124..616099779 100644 --- a/packages/chord/test/next-node/http/upgrades.test.ts +++ b/packages/chord/test/next-node/http/upgrades.test.ts @@ -75,7 +75,7 @@ describe("the links endpoint", () => { await server.close(); }); - it("refuses a socket without a principal, from another Origin or Host, foreign, or whose links.options throws (403)", async () => { + it("refuses a socket without a principal (401), from another Origin or Host, foreign, or whose links.options throws (403)", async () => { const server = await served( { "@app/hosts": contributing({ hosts: ["a.example.com"] }) }, { @@ -92,8 +92,9 @@ describe("the links endpoint", () => { ); const { port } = server; const owner = { "x-principal": "owner" }; + // Nobody: the links endpoint needs a principal (deny by default, slice 13). + await expect(socketTo(port, "/chord")).rejects.toThrow("refused 401"); const refusals = [ - socketTo(port, "/chord"), socketTo(port, "/chord", { ...owner, origin: "http://evil.com" }), socketTo(port, "/chord", { ...owner, origin: "null" }), socketTo(port, "/chord", { ...owner, origin: `HTTP://127.0.0.1:${port}` }), @@ -139,7 +140,7 @@ describe("the links endpoint", () => { describe("plugin sockets", () => { it("hands the socket and its request to the handler in a scope with its principal; no principal is refused", async () => { const server = await served({ "@app/echo": echo }); - await expect(socketTo(server.port, "/echo/lobby")).rejects.toThrow("refused 403"); + await expect(socketTo(server.port, "/echo/lobby")).rejects.toThrow("refused 401"); await expect(socketTo(server.port, "/nope", { "x-principal": "owner" })).rejects.toThrow("refused 404"); const socket = await socketTo(server.port, "/echo/lobby", { "x-principal": "owner" }); expect(await nextMessage(socket)).toBe("lobby as owner"); @@ -149,6 +150,22 @@ describe("plugin sockets", () => { await server.close(); }); + it("accepts a public socket without a principal, its scope anonymous (deny by default, slice 13 §2)", async () => { + const lobby = contributing({ + sockets: { + "/lobby": { + public: true, + handler: (socket, _request, ctx) => socket.send(`as ${ctx.principal?.kind ?? "nobody"}`), + }, + }, + }); + const server = await served({ "@app/lobby": lobby }); + const socket = await socketTo(server.port, "/lobby"); + expect(await nextMessage(socket)).toBe("as nobody"); + socket.close(); + await server.close(); + }); + it("an oversized frame closes only that socket (1009), never the server", async () => { const server = await served({ "@app/echo": echo }); const [big, other] = await Promise.all([ diff --git a/packages/chord/test/next/examples/40-http/plugins/notes/server.ts b/packages/chord/test/next/examples/40-http/plugins/notes/server.ts index 48e6b9388..d784376e0 100644 --- a/packages/chord/test/next/examples/40-http/plugins/notes/server.ts +++ b/packages/chord/test/next/examples/40-http/plugins/notes/server.ts @@ -10,6 +10,7 @@ export default defineFacet((_host, self) => routes: { // A page: a link from another site may open it. Its inline script carries the server's nonce (the CSP). "GET /": { + public: true, page: true, handler: async (request) => ({ kind: "html", @@ -17,13 +18,19 @@ export default defineFacet((_host, self) => }), }, // `:name` is one path segment, decoded. - "GET /notes/:name": async (request) => ({ kind: "json", value: { note: request.params.name } }), + "GET /notes/:name": { + public: true, + handler: async (request) => ({ kind: "json", value: { note: request.params.name } }), + }, // A file of this plugin, saved under its name, with ranges. - "GET /notes/:name/download": async (request) => - fileResponse(pluginFile(`files/${request.params.name}.md`), { - download: `${request.params.name}.md`, - ranges: true, - }), + "GET /notes/:name/download": { + public: true, + handler: async (request) => + fileResponse(pluginFile(`files/${request.params.name}.md`), { + download: `${request.params.name}.md`, + ranges: true, + }), + }, }, }), ); diff --git a/packages/chord/test/next/examples/44-http-wraps/plugins/notes/server.ts b/packages/chord/test/next/examples/44-http-wraps/plugins/notes/server.ts index 0c2a83103..db5441651 100644 --- a/packages/chord/test/next/examples/44-http-wraps/plugins/notes/server.ts +++ b/packages/chord/test/next/examples/44-http-wraps/plugins/notes/server.ts @@ -5,7 +5,9 @@ import { Http } from "@earendil-works/chord/next/http"; export default defineFacet((_host, self) => self.contribute(Http, { - routes: { "GET /notes": async () => ({ kind: "json", value: ["buy milk", "call mom"] }) }, + routes: { + "GET /notes": { public: true, handler: async () => ({ kind: "json", value: ["buy milk", "call mom"] }) }, + }, wraps: [ { name: "cache", diff --git a/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/server.ts b/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/server.ts index 2e1239c10..da5783e73 100644 --- a/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/server.ts +++ b/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/server.ts @@ -12,7 +12,12 @@ export const Tunnel = local("example.tunnel", { export default defineFacet((host, self) => { self.contribute(Http, { - routes: { "GET /hello": async (request) => ({ kind: "json", value: { origin: request.origin } }) }, + routes: { + "GET /hello": { + public: true, + handler: async (request) => ({ kind: "json", value: { origin: request.origin } }), + }, + }, }); host.serve(Tunnel, (_params, tunnel) => { const allowed = tunnel.writable({ hosts: [] }); diff --git a/packages/chord/test/next/examples/47-http-own-server/plugins/notes/server.ts b/packages/chord/test/next/examples/47-http-own-server/plugins/notes/server.ts index 2b7a0abd9..f08442bd0 100644 --- a/packages/chord/test/next/examples/47-http-own-server/plugins/notes/server.ts +++ b/packages/chord/test/next/examples/47-http-own-server/plugins/notes/server.ts @@ -6,7 +6,10 @@ import { Http } from "@earendil-works/chord/next/http"; export default defineFacet((_host, self) => self.contribute(Http, { routes: { - "GET /notes": async (request) => ({ kind: "json", value: { notes: ["buy milk"], via: request.origin } }), + "GET /notes": { + public: true, + handler: async (request) => ({ kind: "json", value: { notes: ["buy milk"], via: request.origin } }), + }, }, }), ); diff --git a/packages/chord/test/next/examples/48-http-baseline/main.ts b/packages/chord/test/next/examples/48-http-baseline/main.ts index 764fcce21..ee1483cd7 100644 --- a/packages/chord/test/next/examples/48-http-baseline/main.ts +++ b/packages/chord/test/next/examples/48-http-baseline/main.ts @@ -1,7 +1,8 @@ // The security baseline (slice 10): on by default, the app decides only who someone is. Here the app signs people in // with a signed cookie (`cookie`, `readCookie`) and the notes plugin contributes a page, routes and a webhook. Requests -// another site's page makes in a browser (Fetch Metadata says so) are never authenticated, a foreign `Origin` is -// refused, but a link to a page still opens it signed in, and a cross-site webhook runs anonymous. +// another site's page makes in a browser (Fetch Metadata says so) are never authenticated (so a route not marked +// public answers them 401: deny by default, slice 13), a foreign `Origin` is refused, but a link to a page still opens +// it signed in, and a cross-site webhook runs anonymous. // Run from packages/chord: // node --conditions=source --experimental-strip-types test/next/examples/48-http-baseline/main.ts import { request } from "node:http"; @@ -21,9 +22,12 @@ const http = chordHttp.createHttp(server, { listen: { host: "127.0.0.1", port: 0 const app = defineFacet((_host, self) => { self.contribute(chordHttp.Http, { routes: { - "POST /sign-in": async (request) => { - const set = chordHttp.cookie("user", String(await request.json()), { secret, request, sameSite: "Lax" }); - return { kind: "json", value: "signed in", headers: { "Set-Cookie": set } }; + "POST /sign-in": { + public: true, + handler: async (request) => { + const set = chordHttp.cookie("user", String(await request.json()), { secret, request, sameSite: "Lax" }); + return { kind: "json", value: "signed in", headers: { "Set-Cookie": set } }; + }, }, }, }); diff --git a/packages/chord/test/next/examples/48-http-baseline/plugins/notes/server.ts b/packages/chord/test/next/examples/48-http-baseline/plugins/notes/server.ts index 0980b62f1..52b8c142f 100644 --- a/packages/chord/test/next/examples/48-http-baseline/plugins/notes/server.ts +++ b/packages/chord/test/next/examples/48-http-baseline/plugins/notes/server.ts @@ -1,5 +1,6 @@ // The notes plugin's server facet: the notes page (a link from another site may open it), the notes as JSON, adding -// a note, and a webhook another site calls (cross-site: it always runs anonymous). +// a note, and a webhook another site calls (cross-site: it always runs anonymous, so it is public). Nothing else is +// public: a request nobody signed in gets 401 before this code runs (deny by default). import { defineFacet } from "@earendil-works/chord/next"; import { Http } from "@earendil-works/chord/next/http"; @@ -14,8 +15,7 @@ export default defineFacet((_host, self) => handler: async (_request, ctx) => ({ kind: "html", body: `hi ${who(ctx.principal)}` }), }, "GET /notes": async (_request, ctx) => ({ kind: "json", value: { notes, as: who(ctx.principal) } }), - "POST /notes": async (request, ctx) => { - if (ctx.principal === undefined) return { kind: "error", status: 401 }; + "POST /notes": async (request) => { notes.push(String(await request.json())); return { kind: "json", value: notes }; }, diff --git a/packages/chord/test/next/examples/53-app/app/principals.ts b/packages/chord/test/next/examples/53-app/app/principals.ts new file mode 100644 index 000000000..cde89e19a --- /dev/null +++ b/packages/chord/test/next/examples/53-app/app/principals.ts @@ -0,0 +1,8 @@ +// The notes app's principals: its server and workers, the kit's owner, and the users its plugins may sign in. + +/** Who someone is in the notes app. */ +export type NotesPrincipal = + | { readonly kind: "server" } + | { readonly kind: "owner" } + | { readonly kind: "worker"; readonly project: string } + | { readonly kind: "user"; readonly id: string; readonly name: string }; diff --git a/packages/chord/test/next/examples/53-app/app/v1.ts b/packages/chord/test/next/examples/53-app/app/v1.ts new file mode 100644 index 000000000..45cb45764 --- /dev/null +++ b/packages/chord/test/next/examples/53-app/app/v1.ts @@ -0,0 +1,31 @@ +// The notes app's API, version 1, shared by every host kind (`@notes/app/v1`): what its plugins import from their +// host. The part of Chord the app supports, the kit's start-data objects and files, the app's own host kind (a +// project, one worker per project) and its registry of home-page sections. The API only declares the registry: its +// provider is the home plugin of the app's required root, not base code. Examples 53 to 55 run this app; a real app +// keeps it in `src/api/v1.ts`. + +import { defineRegistry, hostKind, str } from "../../../../../src/next/index.ts"; + +export { + arr, + constant, + defineFacet, + defineRegistry, + defineState, + defineTable, + int, + local, + method, + obj, + remote, + str, +} from "../../../../../src/next/index.ts"; +export { AppInfo, Files, WorkerInfo } from "../../../../../src/next-node/app/index.ts"; + +/** A project of the app: its own worker process, store and files. */ +export const Project = hostKind("project", { id: str() }); + +/** The home page's sections, one per contributing plugin (title and the path it links to). */ +export const Sections = defineRegistry<{ readonly title: string; readonly path: string }, string[]>("notes.sections", { + version: 1, +}); diff --git a/packages/chord/test/next/examples/53-app/app/v1/server.ts b/packages/chord/test/next/examples/53-app/app/v1/server.ts new file mode 100644 index 000000000..de7f5a42b --- /dev/null +++ b/packages/chord/test/next/examples/53-app/app/v1/server.ts @@ -0,0 +1,5 @@ +// The notes app's API v1 for server facets only (`@notes/app/v1/server`): the HTTP registry plugins contribute routes +// to, and the kit's sessions. + +export { Sessions } from "../../../../../../src/next-node/app/index.ts"; +export { Http } from "../../../../../../src/next-node/http/index.ts"; diff --git a/packages/chord/test/next/examples/53-app/client.ts b/packages/chord/test/next/examples/53-app/client.ts new file mode 100644 index 000000000..2c26b82d6 --- /dev/null +++ b/packages/chord/test/next/examples/53-app/client.ts @@ -0,0 +1,44 @@ +// A small HTTP client for examples 53 to 55, standing in for a browser and a command-line client: a request to the +// app on loopback, its status, `Set-Cookie` and JSON body. + +import { request } from "node:http"; + +/** An answer: status, the cookie it set (name=value), JSON body (or text). */ +export type Answer = { readonly status: number; readonly cookie: string | undefined; readonly body: unknown }; + +/** `method path` on the app at `port`, with `headers` and a JSON `body`. */ +export function call( + port: number, + method: string, + path: string, + headers: Readonly> = {}, + body?: unknown, +): Promise { + return new Promise((resolve, reject) => { + const sent = request( + { + host: "127.0.0.1", + port, + method, + path, + headers: { ...headers, ...(body === undefined ? {} : { "content-type": "application/json" }) }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => { + const text = Buffer.concat(chunks).toString(); + const type = response.headers["content-type"] ?? ""; + const cookie = response.headers["set-cookie"]?.[0]?.split(";")[0]; + resolve({ + status: response.statusCode ?? 0, + cookie, + body: type.includes("json") ? JSON.parse(text) : text, + }); + }); + }, + ); + sent.on("error", reject); + sent.end(body === undefined ? undefined : JSON.stringify(body)); + }); +} diff --git a/packages/chord/test/next/examples/53-app/main.ts b/packages/chord/test/next/examples/53-app/main.ts new file mode 100644 index 000000000..f99fbd3ac --- /dev/null +++ b/packages/chord/test/next/examples/53-app/main.ts @@ -0,0 +1,56 @@ +// The app kit (slice 13): an app is its API (`app/`), its built-in plugins (`plugins/`: home and notes) and a start +// script (`start.ts`) calling `startApp`; its workers' entry (`worker.ts`) calls `startWorker`. The kit makes the data +// directory, the server's store and host, HTTP (deny by default: no session, 401), one worker per project with its own +// store and files, and the owner's sign-in: a one-time owner link for the browser, an owner token file for command-line +// clients on the same machine. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/53-app/main.ts +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { call } from "./client.ts"; +import { startNotes } from "./start.ts"; + +const dataDir = join(await mkdtemp(join(tmpdir(), "notes-")), "data"); + +// 1. Start the app. The start script prints the owner link (single use, 10 minutes, a new one at each start). +const app = await startNotes(dataDir, context); +const link = new URL(app.ownerLink()); +const port = Number(link.port); +console.log("1. owner link:", `${link.origin}${link.pathname}#…`); +console.log(" data directory:", (await readdir(dataDir)).sort().join(", ")); + +// 2. Nobody is signed in: every plugin route answers 401 before the plugin runs. +console.log("2. anonymous GET /:", (await call(port, "GET", "/")).status); + +// 3. The owner opens the link: the page posts the token from the URL's fragment (never sent in a URL, so never in a +// log) and gets the session cookie. The link is used up. +const page = await call(port, "GET", link.pathname); +console.log( + "3. the link's page:", + page.status, + String(page.body).includes("location.hash") ? "(reads the fragment)" : "", +); +const signedIn = await call(port, "POST", link.pathname, {}, { token: link.hash.slice(1) }); +const browser = { cookie: signedIn.cookie ?? "" }; +console.log(" signed in:", signedIn.status, signedIn.body); +console.log(" the link again:", (await call(port, "POST", link.pathname, {}, { token: link.hash.slice(1) })).status); + +// 4. Signed in, the home plugin reads the kit's `AppInfo` and lists the sections of its registry (the notes plugin's); +// the notes plugin's routes reach the project's worker (it starts on the first request) with its store and the +// start data the app gave it. +console.log("4. GET /:", JSON.stringify((await call(port, "GET", "/", browser)).body)); +await call(port, "POST", "/notes/p1", browser, "milk, eggs"); +console.log(" GET /notes/p1:", JSON.stringify((await call(port, "GET", "/notes/p1", browser)).body)); +console.log(" worker directories:", (await readdir(join(dataDir, "workers", "project"))).join(", ")); + +// 5. A command-line client on the same machine reads the owner token file (mode 0600) and sends it as a bearer token. +const token = (await readFile(app.ownerTokenFile, "utf8")).trim(); +const cli = { authorization: `Bearer ${token}` }; +console.log("5. CLI GET /:", JSON.stringify((await call(port, "GET", "/", cli)).body)); + +// 6. Close: HTTP, workers, host, store, in that order. +await app.close(context); +await rm(join(dataDir, ".."), { recursive: true, force: true }); +console.log("6. closed"); diff --git a/packages/chord/test/next/examples/53-app/plugins/home/package.json b/packages/chord/test/next/examples/53-app/plugins/home/package.json new file mode 100644 index 000000000..c64b0510f --- /dev/null +++ b/packages/chord/test/next/examples/53-app/plugins/home/package.json @@ -0,0 +1,5 @@ +{ + "name": "@notes/home", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/53-app/plugins/home/server.ts b/packages/chord/test/next/examples/53-app/plugins/home/server.ts new file mode 100644 index 000000000..608e869b8 --- /dev/null +++ b/packages/chord/test/next/examples/53-app/plugins/home/server.ts @@ -0,0 +1,27 @@ +// The home plugin's server facet: it serves the app's `Sections` registry (the provider of a core registry is a plugin +// of the app's required root, so it reloads like any plugin) and an object reading it, and `GET /` says who is signed +// in to which app (the kit's `AppInfo`, the start data every server plugin can read) with the sections plugins +// contributed. + +import { AppInfo, arr, defineFacet, local, Sections, str } from "@notes/app/v1"; +import { Http } from "@notes/app/v1/server"; + +/** The home page's data: the composed sections. */ +const Home = local("notes.home", { version: 1, params: {}, members: { sections: arr(str()) } }); + +export default defineFacet((host, self) => { + host.serve(Sections, { compose: (table) => table.map(({ value }) => `${value.title}: ${value.path}`) }); + host.serve(Home, (_params, home) => ({ + sections: home.derive([home.registry(Sections)], ([sections]) => sections), + })); + self.contribute(Http, { + routes: { + "GET /": async (_request, ctx) => { + const info = await ctx.get(AppInfo, {}); + const page = await ctx.get(Home, {}); + const app = `${info.name.value} ${info.version.value}`; + return { kind: "json", value: { app, you: ctx.principal ?? null, sections: page.sections.value } }; + }, + }, + }); +}); diff --git a/packages/chord/test/next/examples/53-app/plugins/notes/contract.ts b/packages/chord/test/next/examples/53-app/plugins/notes/contract.ts new file mode 100644 index 000000000..3304ba83f --- /dev/null +++ b/packages/chord/test/next/examples/53-app/plugins/notes/contract.ts @@ -0,0 +1,22 @@ +// A project's notes: served by the project's worker (`Project({ id })`), reached from the server by id. + +import { method, obj, Project, remote, str } from "@notes/app/v1"; + +export const Notes = remote("notes.notes", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => Project({ id }), + variants: { + main: { + state: { text: str(), start: str() }, + methods: { + /** Replaces the project's text. */ + write: method(str()), + /** Stores an attachment in the project's files; its id. */ + attach: method(obj({ name: str(), body: str() }), str()), + /** An attachment's text. */ + attachment: method(str(), str()), + }, + }, + }, +}); diff --git a/packages/chord/test/next/examples/53-app/plugins/notes/package.json b/packages/chord/test/next/examples/53-app/plugins/notes/package.json new file mode 100644 index 000000000..c345893f7 --- /dev/null +++ b/packages/chord/test/next/examples/53-app/plugins/notes/package.json @@ -0,0 +1,5 @@ +{ + "name": "@notes/notes", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/53-app/plugins/notes/server.ts b/packages/chord/test/next/examples/53-app/plugins/notes/server.ts new file mode 100644 index 000000000..535c60157 --- /dev/null +++ b/packages/chord/test/next/examples/53-app/plugins/notes/server.ts @@ -0,0 +1,23 @@ +// The notes plugin's server facet: routes reading and writing a project's notes, and its section of the home page. +// No route is public: the kit refuses anyone without a session (401) before they run. + +import { defineFacet, Sections } from "@notes/app/v1"; +import { Http } from "@notes/app/v1/server"; +import { Notes } from "./contract.ts"; + +export default defineFacet((_host, self) => { + self.contribute(Sections, { title: "Notes", path: "/notes/:id" }); + self.contribute(Http, { + routes: { + "GET /notes/:id": async (request, ctx) => { + const notes = await ctx.get(Notes, { id: request.params.id ?? "" }); + return { kind: "json", value: { text: notes.text.value, start: notes.start.value } }; + }, + "POST /notes/:id": async (request, ctx) => { + const notes = await ctx.get(Notes, { id: request.params.id ?? "" }); + await notes.write(String(await request.json()), ctx); + return { kind: "json", value: "saved" }; + }, + }, + }); +}); diff --git a/packages/chord/test/next/examples/53-app/plugins/notes/worker.ts b/packages/chord/test/next/examples/53-app/plugins/notes/worker.ts new file mode 100644 index 000000000..f034c7171 --- /dev/null +++ b/packages/chord/test/next/examples/53-app/plugins/notes/worker.ts @@ -0,0 +1,49 @@ +// The notes plugin's worker facet: the project's text in a persisted document (kept by a local object with empty +// params, the worker's one copy: a persisted state needs a stable identity), its attachments in the project's files +// (the kit's `Files`), and the start data the app gave the worker (`WorkerInfo`). + +import { defineFacet, defineState, Files, local, method, obj, str, WorkerInfo } from "@notes/app/v1"; +import { Notes } from "./contract.ts"; + +const Text = defineState("notes.text", { + schema: obj({ text: str() }), + version: 1, + initial: () => ({ text: "" }), + persist: true, + checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 20, +}); + +/** The project's text, one per worker. */ +const Store = local("notes.store", { version: 1, params: {}, members: { text: str(), write: method(str()) } }); + +export default defineFacet((host) => { + host.serve(Store, (_params, self) => { + const text = self.state(Text); + return { + text: self.derive([text], ([value]) => value.text), + write: async (next, ctx) => { + await text.change((draft) => { + draft.text = next; + }, ctx); + return null; + }, + }; + }); + host.serve(Notes, (_params, _principal, self) => { + const store = self.get(Store, {}); + const info = self.get(WorkerInfo, {}); + const files = self.get(Files, {}); + return { + variant: "main", + members: { + text: self.derive([store.text], ([text]) => text), + start: self.derive([info.start], ([start]) => JSON.stringify(start)), + write: (next, ctx) => store.write(next, ctx), + attach: ({ name, body }, ctx) => + files.put({ name, mime: "text/plain", bytes: new TextEncoder().encode(body) }, ctx), + attachment: async (id, ctx) => + new TextDecoder().decode(await files.read({ id, offset: 0, length: 65536 }, ctx)), + }, + }; + }); +}); diff --git a/packages/chord/test/next/examples/53-app/start.ts b/packages/chord/test/next/examples/53-app/start.ts new file mode 100644 index 000000000..382f5db29 --- /dev/null +++ b/packages/chord/test/next/examples/53-app/start.ts @@ -0,0 +1,50 @@ +// The notes app's start: everything the app itself decides, given to `startApp`. The kit does the rest: the data +// directory, the store, the host, HTTP with sessions and the owner link, a workers component for projects, and the +// plugins (`plugins/` here, the app's required root, and `/plugins/`, the user's). +// Examples 53 to 55 call `startNotes`; a real app's `start.ts` calls it with `process.argv[2]` and prints the link. + +import { fileURLToPath } from "node:url"; +import type { Context } from "../../../../src/context/index.ts"; +import { type App, startApp } from "../../../../src/next-node/app/index.ts"; +import type { NotesPrincipal } from "./app/principals.ts"; +import * as v1server from "./app/v1/server.ts"; +import * as v1 from "./app/v1.ts"; + +/** The app's API: its name, versions (newest first) and this process's modules (the worker's entry has the same). */ +export const API = { + name: "@notes/app", + versions: ["v1"], + modules: { "@notes/app/v1": v1, "@notes/app/v1/server": v1server }, +}; + +/** The app's facet kinds. */ +export const KINDS = { server: { platform: "node" }, worker: { platform: "node" } } as const; + +/** Starts the notes app on a free loopback port with its data in `dataDir`. */ +export function startNotes(dataDir: string, context: Context): Promise> { + return startApp( + { + name: "notes", + dataDir, + root: fileURLToPath(new URL("./plugins/", import.meta.url)), + listen: { host: "127.0.0.1", port: 0 }, + api: API, + kinds: KINDS, + principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", project: address.key }) }, + workers: [ + { + kind: v1.Project, + entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + // What each project's worker gets as `WorkerInfo.start`. + start: (address) => ({ project: address.key }), + // Who may reach a project: the owner (through the server's routes) and the server itself. + authorize: (principal) => principal.kind === "owner" || principal.kind === "server", + idleMs: () => 60_000, + }, + ], + onError: (error) => console.error("app:", error.kind, error.error), + }, + context, + ); +} diff --git a/packages/chord/test/next/examples/53-app/worker.ts b/packages/chord/test/next/examples/53-app/worker.ts new file mode 100644 index 000000000..bffa65fd2 --- /dev/null +++ b/packages/chord/test/next/examples/53-app/worker.ts @@ -0,0 +1,7 @@ +// The notes app's worker entry: one process per project. `startWorker` opens the project's store in its directory, +// connects to the server and loads the plugins' worker facets. + +import { startWorker } from "../../../../src/next-node/app/index.ts"; +import { API, KINDS } from "./start.ts"; + +startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("worker:", error) }); diff --git a/packages/chord/test/next/examples/54-app-sessions/main.ts b/packages/chord/test/next/examples/54-app-sessions/main.ts new file mode 100644 index 000000000..775a54d84 --- /dev/null +++ b/packages/chord/test/next/examples/54-app-sessions/main.ts @@ -0,0 +1,62 @@ +// Sessions and device pairing (slice 13 §2), with example 53's notes app. A command-line client on another machine +// has no owner token file: it asks for a pairing code (`POST /_chord/pair`, public), the owner approves the code in a +// signed-in browser (`/_chord/pair/`, owner only), and the client's next poll returns its device token, once. +// The owner lists the sessions (`Sessions`, the kit's object; never a token or its hash) and revokes the device's. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/54-app-sessions/main.ts +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { Sessions } from "../../../../src/next-node/app/index.ts"; +import { call } from "../53-app/client.ts"; +import { startNotes } from "../53-app/start.ts"; + +const base = await mkdtemp(join(tmpdir(), "notes-")); +const app = await startNotes(join(base, "data"), context); +const link = new URL(app.ownerLink()); +const port = Number(link.port); + +// 1. The owner signs in in the browser with the owner link (example 53). +const browser = { cookie: (await call(port, "POST", link.pathname, {}, { token: link.hash.slice(1) })).cookie ?? "" }; + +// 2. The client asks for a code and shows it; it keeps the poll secret to itself. +const { code, poll } = (await call(port, "POST", "/_chord/pair", {}, { label: "laptop CLI" })).body as { + code: string; + poll: string; +}; +console.log("1. pairing code:", code.replace(/[A-Z0-9]/g, "X")); +console.log(" poll:", JSON.stringify((await call(port, "POST", "/_chord/pair/poll", {}, { poll })).body)); + +// 3. Only the owner may see and approve it: an anonymous request is refused before the kit's route runs. +console.log("2. anonymous approval:", (await call(port, "POST", `/_chord/pair/${code}`)).status); +const confirm = await call(port, "GET", `/_chord/pair/${code}`, browser); +console.log( + " the owner's confirm page:", + confirm.status, + String(confirm.body).includes("laptop CLI") ? "(names the device)" : "", +); +console.log(" approved:", (await call(port, "POST", `/_chord/pair/${code}`, browser)).status); + +// 4. The client's next poll returns its token, once; it signs in with it as a bearer token. +const approved = (await call(port, "POST", "/_chord/pair/poll", {}, { poll })).body as { + status: string; + token: string; +}; +console.log("3. poll:", approved.status); +console.log(" poll again:", (await call(port, "POST", "/_chord/pair/poll", {}, { poll })).status); +const device = { authorization: `Bearer ${approved.token}` }; +console.log(" CLI GET /:", JSON.stringify((await call(port, "GET", "/", device)).body)); + +// 5. The owner lists the sessions (a server plugin would do this with `self.get(Sessions, {})`) and revokes the +// device's: its next request is refused. +const sessions = await app.host.get(Sessions, {}, context); +console.log("4. sessions:"); +for (const { kind, label } of sessions.list.value) console.log(` ${kind}: ${label}`); +const laptop = sessions.list.value.find(({ label }) => label === "laptop CLI"); +await sessions.revoke(laptop?.id ?? "", context); +console.log(" revoked; CLI GET /:", (await call(port, "GET", "/", device)).status); +sessions.release(); + +await app.close(context); +await rm(base, { recursive: true, force: true }); diff --git a/packages/chord/test/next/examples/55-app-export/main.ts b/packages/chord/test/next/examples/55-app-export/main.ts new file mode 100644 index 000000000..749617947 --- /dev/null +++ b/packages/chord/test/next/examples/55-app-export/main.ts @@ -0,0 +1,66 @@ +// Export and import per address (slice 13 §4), with example 53's notes app. `app.exportAddress` stops a project's +// worker and writes one archive: a manifest (app, API version, format), the project's store export and its files. +// `app.importAddress` makes another address from it (refusing one that has data) and the next request starts its +// worker on the imported store and files. Archives of another app are refused. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/55-app-export/main.ts +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { method, obj, Project, remote, str } from "../53-app/app/v1.ts"; +import { startNotes } from "../53-app/start.ts"; + +// The notes plugin's contract, declared again with the API's builders (contracts are data: the same name, version and +// schema bind). The plugin's own files import `@notes/app/v1`, a host package this process has no module path for. +const Notes = remote("notes.notes", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => Project({ id }), + variants: { + main: { + state: { text: str(), start: str() }, + methods: { + write: method(str()), + attach: method(obj({ name: str(), body: str() }), str()), + attachment: method(str(), str()), + }, + }, + }, +}); + +const base = await mkdtemp(join(tmpdir(), "notes-")); +const app = await startNotes(join(base, "data"), context); + +// 1. Project p1 gets a text and an attachment (its worker's store and files). The server reaches it as `Notes`. +const p1 = await app.host.get(Notes, { id: "p1" }, context); +await p1.write("the plan", context); +const attachment = await p1.attach({ name: "plan.txt", body: "step 1, step 2" }, context); +p1.release(); + +// 2. Export p1: its worker stops, the archive is written (mode 0600), and p1 goes on (the next hold starts it). +const archive = join(base, "p1.tar"); +await app.exportAddress(Project({ id: "p1" }), archive, context); +console.log( + "1. exported:", + `${(await stat(archive)).size} bytes`, + `mode ${((await stat(archive)).mode & 0o777).toString(8)}`, +); + +// 3. Import it as p2: a copy with p1's text and attachment, its own worker and directory. +await app.importAddress(archive, Project({ id: "p2" }), context); +const p2 = await app.host.get(Notes, { id: "p2" }, context); +console.log("2. p2:", JSON.stringify(p2.text.value), JSON.stringify(await p2.attachment(attachment, context))); +await p2.write("p2's own plan", context); +p2.release(); +const again = await app.host.get(Notes, { id: "p1" }, context); +console.log(" p1 unchanged:", JSON.stringify(again.text.value)); +again.release(); + +// 4. An address that has data is never overwritten. +await app + .importAddress(archive, Project({ id: "p1" }), context) + .catch((error: Error) => console.log("3. refused:", error.message.replace(base, "…"))); + +await app.close(context); +await rm(base, { recursive: true, force: true }); diff --git a/tsconfig.json b/tsconfig.json index a6a826276..e8e536464 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,10 @@ "@earendil-works/chord/next/plugins": ["./packages/chord/src/next-node/plugins/index.ts"], "@earendil-works/chord/next/plugins/runtime": ["./packages/chord/src/next-node/plugins/runtime.ts"], "@acme/app/*": ["./packages/chord/test/next/examples/49-plugin-api/app/*.ts"], + "@notes/app/*": ["./packages/chord/test/next/examples/53-app/app/*.ts"], "@earendil-works/chord/next/http": ["./packages/chord/src/next-node/http/index.ts"], "@earendil-works/chord/next/workers": ["./packages/chord/src/next-node/workers/index.ts"], + "@earendil-works/chord/next/app": ["./packages/chord/src/next-node/app/index.ts"], "@earendil-works/chord/node": ["./packages/chord/src/node.ts"], "@earendil-works/chord/output": ["./packages/chord/src/output.ts"], "@earendil-works/chord/telemetry-sqlite": ["./packages/chord/src/telemetry-sqlite/index.ts"],