mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(chord): slice 10 as built (design 23, spec, explainer)
This commit is contained in:
parent
8959ad6e68
commit
b22c5f6941
6 changed files with 221 additions and 212 deletions
|
|
@ -1877,7 +1877,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
reload, swapping the committed plugin back silently. Rejected: resolving with the report after the commit despite
|
||||
the abort (it would change the rule that an abort after the commit rejects). Examples for 7c (owner): full coverage,
|
||||
each example a folder with the host code and real plugin directories (35-39).
|
||||
- **Slice 10's points (owner, 2026-10-30; `slice-10.md`):** (D1, "a") the HTTP component defines the registry `Http`
|
||||
- **Slice 10's points (owner, 2026-10-30; `slice-10.md`):** (D1, "a"; `httpServer` **superseded** by `createHttp`, decisions "The HTTP core is a fetch handler" and "Settings are arguments") the HTTP component defines the registry `Http`
|
||||
(`"chord.http"`) and serves it through a facet of its own (`http.facet`, plugin name `chord.http`) that the app loads
|
||||
with its plugins; its `compose` builds the router; the server reads it with `host.registry(Http, ctx)` per request.
|
||||
(D2, "a") Wraps are built in slice 10 as designed (decision "HTTP routes and wraps", design §23): a contribution's
|
||||
|
|
@ -1885,7 +1885,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
phase in contributor order, by default around their own plugin's routes (`scope: "own"`; `"all"` explicit; `route`
|
||||
narrows to one, a missing one a reported conflict). The server's `headers` option stays for every response, also
|
||||
those no route makes (a 404, a refused request).
|
||||
(D3, "a") Authentication is one app function, `authenticate(request, ctx) => P | undefined`, called once per request
|
||||
(D3, "a"; **superseded** by "The HTTP server is a native object": the `Authenticators` registry) Authentication is one app function, `authenticate(request, ctx) => P | undefined`, called once per request
|
||||
and per upgrade; no Chord registry of resolvers (p3 composes its own from sharing, devices and the relay, with its
|
||||
own precedence rules).
|
||||
(D4, "k": a) Request bodies: `request.json(limit?)` (parsed JSON, 1 MiB by default, malformed: 400) and
|
||||
|
|
@ -1948,7 +1948,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
(R3, "a"; refines D9) The child reports only the flip: `{ idle: true }` when every count (`host.activity` and `busy`)
|
||||
becomes zero, `{ idle: false }` when one becomes nonzero; the server's idle clock starts at `true` (a status message
|
||||
costs about 6 µs on each side, a local call 2.9 µs: no report per call).
|
||||
(R4, "a if configurable at runtime so allowed domains can be added and removed by a plugin") Before `authenticate`, the
|
||||
(R4; **superseded in part** by "allowed hosts only through contributions" and "The HTTP component's security baseline": no `allowedHosts` option, provenance by the socket; "a if configurable at runtime so allowed domains can be added and removed by a plugin") Before `authenticate`, the
|
||||
component refuses (403) a `Host` that is neither loopback nor allowed, and an upgrade (`/chord`, a plugin socket)
|
||||
whose `Origin` is not the request's own origin; `request.origin` comes only from an accepted `Host` (`http://` on
|
||||
loopback, `https://` for an allowed host); `headers(request, principal)` gets the principal. Allowed hosts are the
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ host). The workers component (§23) does this.
|
|||
| `hold(Type, params, context, { readyTimeout? })` | a reference at once that follows its object until released (section 7) |
|
||||
| `close(context)` | closes the links (children, routes, then the parent), then ends every object at once and tears them down, the last to go live first (reason `close`); rejects with an `AggregateError` of the native closes that threw |
|
||||
| `revoke(r, context)` | revocation (section 4; slice 4b) |
|
||||
| `root(fn, context, { principal? })` | a call scope for code outside objects (a Durable hook, a CLI): `fn` gets a handler's `ctx`, sealed when `fn` settles, `context` aborts or the host closes (slice 8 D3, section 8) |
|
||||
| `root(fn, context, { principal? })` | a call scope for code outside objects (a Durable hook, a CLI, an HTTP request): `fn` gets a handler's `ctx`, sealed when `fn` settles, `context` aborts or the host closes (slice 8 D3, section 8); an omitted `principal` acts as the host, an explicit `undefined` runs anonymous (slice 10) |
|
||||
| `registry(R, context, { readyTimeout? })` | registry `R`'s composed value for code outside objects once it is usable; waits for the publication, then while it has none up to its `readyTimeout`, then rejects with the last error (slice 8, 7a, section 17) |
|
||||
|
||||
**Options of `createHost`:** `address`, `principal`, `principalKey` (section 4; the same function on every host),
|
||||
|
|
@ -2113,32 +2113,70 @@ public API; nothing in the kernel depends on them.
|
|||
|
||||
### HTTP (`@earendil-works/chord/next/http`)
|
||||
|
||||
`httpServer(host, { listen, allowedHosts?, authenticate, headers?, links?, closeTimeout?, onError }, ctx)` listens
|
||||
first; requests and upgrades wait only for the router's publication (`host.registry(Http)`, resolved once), never for
|
||||
the start barrier. `Host` (loopback or allowed: the option plus contributed `hosts`) and an upgrade's `Origin` are
|
||||
checked before anything else (R4); connections through a host no longer allowed close. `authenticate(request, ctx) => P | undefined` is the app's one hook (D3: who a
|
||||
request is, and which credential wins, is app policy; no Chord registry of resolvers); `headers` are added to every
|
||||
response, also those no route makes; `links.path` (`/chord`) upgrades to `host.accept(webSocketLink(ws), { principal,
|
||||
limits })`, a socket without a principal refused. `ws` is an optional peer dependency (D7).
|
||||
|
||||
The registry `Http` (`"chord.http"`) is defined by the component and served by its own facet (`http.facet`, loaded
|
||||
with the plugins, D1); plugins contribute `routes`, `wraps` and `sockets`. A route is `(req, ctx) =>
|
||||
Promise<HttpResponse>`, run in a `host.root` scope with the request's principal (`ctx.principal`) and a signal aborted
|
||||
when the client disconnects; `req` has `method`, `path`, `params`, `query`, `headers`, `origin`, `json(limit?)` and
|
||||
`body(limit?)` (D4: nothing read until asked; 1 MiB by default; past the limit 413). Patterns (D8): exact segments,
|
||||
`:name`, a trailing `/*`; the most specific match wins, segment by segment from the left; one pattern from two
|
||||
contributions is a reported conflict, the first by plugin name wins; `HEAD` falls back to `GET`. A request uses the
|
||||
router as it was when it started.
|
||||
The core is one Web-standard handler built from the registry `Http` (decision "The HTTP core is a fetch handler"):
|
||||
|
||||
```ts
|
||||
type HttpResponse = { readonly headers?: Readonly<Record<string, string>> } & ( // D5
|
||||
const http = createHttp(server, { // settings are arguments, fixed for the component's life
|
||||
listen: { host: "127.0.0.1", port: 8788 }, // Chord's own Node listener; none: the app mounts `fetch`
|
||||
links: { path: "/chord", options: (request, principal) => ({ limits, maxPayload }) },
|
||||
closeTimeout: 30_000,
|
||||
onError, // HttpError
|
||||
}, ctx);
|
||||
await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }, appCore], modules }, ctx);
|
||||
// an app with its own server: hono.all("/*", (c) => http.fetch(c.req.raw, { localAddress, localPort, remoteAddress }))
|
||||
// plain Node: nodeRequest(message, res) converts with the listener's checks; server.on("upgrade", http.upgrade)
|
||||
await http.close(ctx); // before host.close: drains running requests
|
||||
```
|
||||
|
||||
- **The facet** (plugin `chord.http`, passed as the same object at every reload) serves the registries `Http`
|
||||
(`compose` builds the router) and `Authenticators`, and with `listen` the listener: a host-lifetime object whose
|
||||
native is Chord's Node server, opened right after `load`'s publication, so a reload never closes it (decisions "The
|
||||
HTTP server is a native object", "Settings are arguments, extensions are registries"). `fetch` resolves the
|
||||
registries once; requests before the publication wait (503 past `readyTimeout`).
|
||||
- **Contributions to `Http`:** `{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }`, a value
|
||||
or a writable (a change recomposes the router). A request uses the router as it was when it started.
|
||||
- **The security baseline** (decision "The HTTP component's security baseline", on for every request and upgrade
|
||||
before any plugin code): (1) **provenance**: local only when the connection's local address is loopback, `Host` is
|
||||
a loopback name with the port and no `Forwarded`/`X-Forwarded-*` header; a forwarded request on loopback only
|
||||
through a contributed relay whose `verify` names its origin (`{ kind: "relayed", relay, origin }`); otherwise only a
|
||||
contributed host (https); duplicate or missing `Host`, userinfo in it, absolute-form targets: 403; an unparsable
|
||||
target 400; `TRACE`, `TRACK`, `CONNECT` 501 (a route for them is refused at compose). A mounted handler without
|
||||
`localAddress` and `localPort` is never local. (2) **Foreign requests** (Fetch Metadata) never reach the
|
||||
authenticators: anonymous, except a top-level `GET` navigation to a `page: true` route. (3) A foreign `Origin` is
|
||||
403; an unsafe method from a browser needs the app's own `Origin`; a `crossSite: true` route opts out and always
|
||||
runs anonymous. (4) **Default headers** on every answer, the component's own included: `no-store`, `nosniff`,
|
||||
`no-referrer`, `X-Frame-Options: DENY` (without framers), a CSP (`default-src 'none'`, scripts `'self'` and a
|
||||
per-response nonce `request.nonce`, `connect-src` self and its WebSocket, `frame-ancestors` the framers or
|
||||
`'none'`, plus contributed `csp` sources); a response's own headers replace same-named ones. (5) `cookie(name,
|
||||
value, { secret, request, sameSite?, maxAge? })` and `readCookie(request, name, secret)`: `HttpOnly`, `Path=/`,
|
||||
`SameSite=Strict` or `Lax`, `Secure` on a remote origin, value and expiry signed. (6) `bytes` refuse foreign
|
||||
requests, are attachments unless `inline` (then with a `sandbox` CSP), download names stripped of control and
|
||||
bidirectional characters.
|
||||
- **Who:** the `Authenticators` registry, in contributor order (an app needing precedence contributes one that
|
||||
decides); the first principal wins, a throwing one is reported and skipped, none: anonymous. They run raced against
|
||||
the request's signal and stop once it aborts. Who someone is stays app policy (D3); the registry replaces D3's
|
||||
single function so plugins (a device pairing, a share) can add credentials.
|
||||
- **A request's scope:** one `host.root` with its principal (an explicit `undefined`: anonymous; an anonymous scope
|
||||
gets no remote object served here, `forbidden` → 403) spans matching, wraps, the route and the response until its
|
||||
body is written or abandoned; its signal aborts on disconnect. `HttpRequest`: `method`, `path`, `params`, `query`,
|
||||
`headers`, `origin`, `provenance`, `nonce`, `json(limit?)` (1 MiB; 400, 413), `body(limit?)`.
|
||||
- **Patterns** (D8): exact segments, `:name`, a trailing `/*`; decoded segments with `/`, `\`, NUL, `.` or `..` are
|
||||
400; the most specific match wins segment by segment; a pattern from two contributions is a reported conflict, the
|
||||
first by plugin name wins; `HEAD` falls back to `GET`; no route: 404. A route is a function or `{ handler, page?,
|
||||
crossSite? }`. Wraps (D2) as below.
|
||||
|
||||
```ts
|
||||
type HttpResponse = { readonly headers?: Readonly<Record<string, string | readonly string[]>> } & ( // D5
|
||||
| { kind: "json"; value: JsonValue; status?: number } | { kind: "html"; body: string; status?: number }
|
||||
| { kind: "bytes"; mime: string; size?: number; read(offset, length, ctx): Promise<Uint8Array>; download?: string; ranges?: boolean } // no size: chunked (R6)
|
||||
| { kind: "bytes"; mime: string; size?: number; read(offset, length, ctx): Promise<Uint8Array>; download?: string; inline?: true; ranges?: boolean }
|
||||
| { kind: "redirect"; to: string; status?: 302 | 303 | 307 } | { kind: "error"; status: number; message?: string });
|
||||
```
|
||||
|
||||
The component does ranges (206/416), `HEAD`, safe download headers, 64 KiB reads with backpressure stopped on
|
||||
disconnect, and a 500 without the message for a throwing route; `fileResponse(path, options)` serves a file.
|
||||
`bytes` are pulled in 256 KiB reads as the consumer reads (ranges 206/416, `HEAD`, chunked without `size`, R6); no
|
||||
read starts once the request's scope is sealed; a short or throwing read destroys the connection. 204, 205 and 304
|
||||
carry no body. `fileResponse(path, { download?, inline?, ranges?, mime?, root? })` stats at once (missing: 404; with
|
||||
`root`, a real path outside it: 403), opens its handle at the first read, closes it when the scope seals. A throwing
|
||||
route or wrap is a 500 without its message.
|
||||
|
||||
```ts
|
||||
type Wrap = {
|
||||
|
|
@ -2152,26 +2190,47 @@ type Wrap = {
|
|||
|
||||
Wraps (D2) run within a phase in contributor order (no numeric priorities: plugins have no single registration
|
||||
order; Fastify's lifecycle phases and encapsulation are the model). Wraps may add a security boundary on top of a
|
||||
route's checks, never instead of them. `sockets` (D6): `{ "/terminal": (socket, req, ctx) => … }`, a `ws` WebSocket
|
||||
after the origin check and `authenticate` (no principal: 403 before plugin code), for a plugin's own protocol; wraps do
|
||||
not apply; closed (1012) when the contribution that handed them out leaves the table (§17).
|
||||
route's checks, never instead of them.
|
||||
|
||||
- **Sockets** (D6): `sockets: { "/terminal/:id": (socket, request, ctx) => … }`, a `ws` WebSocket after admission
|
||||
and the authenticators (no principal: 403 before plugin code), `maxPayload` per contribution (1 MiB), pinged after
|
||||
15 s of silence and terminated 15 s later. **Links:** `links.path` upgrades to `host.accept(webSocketLink(socket), {
|
||||
principal, limits })`; a client that stops reading is cut off past 4 MiB waiting. **What is removed is closed at
|
||||
once** (decisions Q6, "allowed hosts only through contributions"): a link or socket whose host, relay or socket
|
||||
handler leaves the router is terminated and its scope sealed; an upgrade is checked again against the current router
|
||||
after its authenticators answered.
|
||||
- **`close(ctx)`:** new requests and upgrades refused (503), sockets and links closed (1001, terminated at
|
||||
`closeTimeout`), running requests waited for up to `closeTimeout`, then sealed; before `host.close`.
|
||||
- **Cost** (decision "a route's overhead accepted"): +20-21 µs per request over a plain Node handler (standard bench),
|
||||
+12-13 µs warmed. `ws` is an optional peer dependency (D7).
|
||||
|
||||
### Workers (`@earendil-works/chord/next/workers`)
|
||||
|
||||
`createWorkers(host, Kind, { kind, entry, start, plugins, lock, idleMs?, onOutput?, onError }, ctx)` routes `Kind`
|
||||
itself (a forked worker per address over IPC, the link made at the fork, a start message with the app's start data and
|
||||
the worker kind's complete scripts; the 60 s start timer covers fork, lock and connect only, Q4), with a `state`
|
||||
watchable per address, `stop`, `retry` and `close`; `workerMain(fn, { hostPackages, redact, onError })` is the child's
|
||||
side. One live worker per address (a kernel lock the child takes, waiting for a draining old worker without polling).
|
||||
Keep-alive (D9, R3, Q5): the child reports when `host.activity`'s references, jobs and lingering objects and the app's
|
||||
`busy` counts flip between all zero and not (local calls do not count; the first state with `ready`); the server stops
|
||||
a worker idle (all zero) for `idleMs` (5 min). Workers start on demand and never restart by themselves (R2): a crash,
|
||||
a `fatal`, a link lost while its child lives (killed after the grace) or a failed start leaves the address failed with
|
||||
its error; its holders see `failed { waiting }` and stay on the path (Q1); `workers.retry(address)` or a rollout
|
||||
reaching workers clears it and starts the held ones (Q2). `workers.stop` suspends the address while it has holders
|
||||
(R1, Q3). Workers never build (D11): the server's plugins component builds every plugin; workers evaluate shipped
|
||||
complete sets (`createPluginRuntime`, no esbuild), and `workers.step(name)` ships a rollout's set to every worker (D12:
|
||||
one result per host; a worker stopped cleanly during it is listed `stopped`).
|
||||
`createWorkers(host, Kind, { kind, entry, execArgv?, env?, authorize, principal, start, lock, idleMs?, plugins,
|
||||
onOutput?, onError }, ctx)` routes `Kind` itself (`host.route`, keeping its `RouteHandle`): a forked worker per
|
||||
address over IPC, the link made at the fork, a start message `{ address, start, scripts, lock }` with the app's start
|
||||
data and the kind's complete current scripts (`plugins.scripts(kind)`); the 60 s start timer covers fork, lock and
|
||||
connect only (Q4). `workers.state` (a JSON watchable per address key), `stop`, `retry`, `step(name)`, `close`;
|
||||
`workerMain(fn, { hostPackages, redact, onError })` is the child's side (`fn({ address, start, parent, plugins,
|
||||
onClose }, ctx)`, `plugins` a `WorkerPlugins` evaluating the shipped scripts, closers in reverse order). One live
|
||||
worker per address (`processLock`: an abstract socket on Linux, a named pipe on Windows, a socket file elsewhere that
|
||||
fails closed when stale; a start waits for a draining old holder without polling).
|
||||
|
||||
- **Keep-alive** (D9, R3, Q5): the child reports when `host.activity`'s references, jobs and lingering objects and the
|
||||
app's `busy` counts flip between all zero and not (local calls do not count; the first state with `ready`, which
|
||||
also carries the load report); the server stops a worker idle for `idleMs` (5 min).
|
||||
- **Never restarted by itself** (R2): a crash, a `fatal`, a link lost while its child lives (killed after a 5 s grace)
|
||||
or a failed start leaves the address failed; its holders see `failed { waiting }` and stay on the path (Q1);
|
||||
`retry(address)` or a rollout reaching workers clears it and starts the held ones (Q2). `stop` suspends the address
|
||||
while it has holders (R1, Q3).
|
||||
- **Errors in public and private** (decision "worker errors in public"): `state` and holders get a fixed message
|
||||
(`worker failed to start`, `worker exited (SIGSEGV)`) or the child's redacted `fatal`; the full error and the stderr
|
||||
tail go only to `onError` (`worker_failed`). Every app hook of a start (`start`, `principal`, `lock`, the fork) is
|
||||
inside that boundary; a throwing `authorize` refuses the hold and is reported `authorize_failed`. `worker_exited` is
|
||||
reported only for an exit nobody asked for.
|
||||
- **Workers never build** (D11): the server's plugins component builds every plugin; workers evaluate shipped
|
||||
complete sets (`createPluginRuntime`, no esbuild); `workers.step(name)` ships a rollout's set to every worker (D12:
|
||||
`{ host, report } | { host, stopped: true } | { host, error }` per worker).
|
||||
|
||||
### Channels
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ stats:
|
|||
note: |
|
||||
Written by the redesign session from `slice-10.md`, the decision entries "Slice 10's points", "Origins only from
|
||||
relays", "The HTTP component's security baseline" and their neighbours in `decisions.md`, design §3 and §23, and the
|
||||
build in `/home/badlogic/pi-7b`. The build exists; its fix round (the security baseline among it) is next. Where the
|
||||
build and the decisions differ, this book says what is decided.
|
||||
build after its four fix rounds (security baseline, `fetch` core, error boundaries).
|
||||
colophon: Serving and Spawning · first edition · 2026-10-31 · Chord v2 slice 10
|
||||
---
|
||||
|
||||
|
|
@ -75,13 +74,14 @@ const plugins = createPlugins({
|
|||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
const http = await httpServer(server, { listen: { host: "127.0.0.1", port: 8788 }, authenticate, onError }, ctx);
|
||||
const http = createHttp(server, { listen: { host: "127.0.0.1", port: 8788 }, onError }, ctx); // settings are arguments
|
||||
const workers = createWorkers(server, Session, {
|
||||
kind: "worker", entry: "./worker.ts", plugins, principal: (a) => ({ kind: "worker", session: a.params.id }),
|
||||
start: (address, ctx) => ({ id: address.params.id, dir: sessionDir(address) }),
|
||||
lock: (address) => `chat:${dataDir}:${address.key}`, authorize: mayReach, onError,
|
||||
}, ctx);
|
||||
await plugins.load(server, { kind: "server", core: [core, { name: "chord.http", facet: http.facet }], modules }, ctx);
|
||||
// `core` contributes the app's authenticator: self.contribute(Authenticators, authenticate)
|
||||
```
|
||||
|
||||
```ts
|
||||
|
|
@ -198,27 +198,29 @@ A route that throws is a 500 without its message: error text often holds paths o
|
|||
|
||||
# Who Is Asking {#who lede="The app's one security function, and everything the component does so the app does not have to."}
|
||||
|
||||
The app writes exactly one function about identity:
|
||||
The app writes one function about identity and contributes it to the registry `Authenticators` (plugins may add
|
||||
more, a device pairing for instance; the first principal wins):
|
||||
|
||||
```ts
|
||||
async function authenticate(request: AuthRequest, ctx: Context): Promise<P | undefined> {
|
||||
const device = verifyCookie(request.cookies.device, secret);
|
||||
if (device) return { kind: "owner", access: "device", device: device.id };
|
||||
const guest = verifyCookie(request.cookies.guest, secret);
|
||||
if (guest) return { kind: "guest", session: guest.session, grant: guest.id };
|
||||
const authenticate: Authenticator = async (request, ctx) => {
|
||||
const device = readCookie(request, "device", secret); // signature and expiry checked
|
||||
if (device) return { kind: "owner", access: "device", device };
|
||||
const guest = readCookie(request, "guest", secret);
|
||||
if (guest) return { kind: "guest", grant: guest };
|
||||
if (request.provenance.kind === "local") return { kind: "owner", access: "local" };
|
||||
return undefined; // anonymous
|
||||
}
|
||||
return undefined; // anonymous
|
||||
};
|
||||
self.contribute(Authenticators, authenticate); // in the app's core facet
|
||||
```
|
||||
|
||||
It never parses `Host`, never checks `Origin` and never sees a foreign request: the component already did all of it.
|
||||
It may read `request.provenance` (`local`, `remote` with the host, or `relayed` through a verified relay such as
|
||||
Radius). Cookies are written with the component's helper, which sets `HttpOnly`, `SameSite`, `Secure` on a remote
|
||||
origin and a signature.
|
||||
Radius). Cookies are written with `cookie(name, value, { secret, request, maxAge })`, which sets `HttpOnly`,
|
||||
`SameSite`, `Secure` on a remote origin, and signs the value with its expiry.
|
||||
|
||||
Every response, the component's own 404s and 403s included, carries the same headers: no caching, no MIME sniffing, no
|
||||
referrer, no framing (`X-Frame-Options: DENY` and `frame-ancestors 'none'`, except for framers the app allowed), and a
|
||||
Content Security Policy with a per-server script nonce, so an injected `<script>` does not run.
|
||||
Content Security Policy with a fresh script nonce per response (`request.nonce`), so an injected `<script>` does not run.
|
||||
|
||||
| Attack | What stops it |
|
||||
| --- | --- |
|
||||
|
|
@ -230,10 +232,8 @@ Content Security Policy with a per-server script nonce, so an injected `<script>
|
|||
| a path like `/files/..%2F..%2Fsecret` | decoded `/`, `.`, `..`, `\` in a path segment are 400 |
|
||||
| a crafted request target crashes the server | the target is parsed by the component, a failure is 400 |
|
||||
|
||||
:::note | Decided, not yet built
|
||||
The security baseline was decided on 2026-10-31 after the security audit and comes with the build's fix round. The
|
||||
build as reviewed checks `Host` and `Origin` on upgrades only.
|
||||
:::
|
||||
The core is a Web `fetch` handler, so an app with its own server (Hono, Express, plain Node with `nodeRequest`) mounts
|
||||
it and gets the same checks; without the socket's addresses a mounted request is never local.
|
||||
|
||||
# Sockets and Links {#sockets lede="Chord's own WebSocket for tabs, a plugin's own WebSocket for its protocol, and what closes them."}
|
||||
|
||||
|
|
@ -346,29 +346,30 @@ self.contribute(Http, hosts);
|
|||
self.on(tunnel.publicHost, ({ value }) => hosts.set({ hosts: value ? [value] : [] }));
|
||||
```
|
||||
|
||||
Functions the components call at each use (`authenticate`, the security headers' extra sources, a worker's start
|
||||
data, its idle time) can read the app's own registries, which plugins contribute to. What needs a restart is only the
|
||||
Authenticators, framers and extra CSP sources are contributions too; functions the workers component calls at each
|
||||
use (a worker's start data, its idle time) can read the app's own registries, which plugins contribute to. What needs a restart is only the
|
||||
bootstrap: the port, the `/chord` path, the worker's entry file and its Node flags.
|
||||
|
||||
# What's Next {.epilogue lede="The fix round, and the decisions it carries."}
|
||||
# What's Next {.epilogue lede="What the reviews found, and what comes after this slice."}
|
||||
|
||||
The build exists and passed its tests; four build reviews and three security audits found the issues the fix round now
|
||||
addresses: a malformed request crashing the server, the `Host`-only provenance, origins invented by a tab (a kernel
|
||||
hole since slice 4a, now refused on tab links), secrets reaching guests through worker errors, several close and
|
||||
shutdown races in workers and HTTP, and two leaks. With it come the decisions of 2026-10-31: the security baseline,
|
||||
relays, allowed hosts only through contributions, public worker errors as fixed messages.
|
||||
Four build reviews, three security audits and three rounds of rechecks shaped the slice. They found a malformed request
|
||||
crashing the server, `Host`-only provenance, origins invented by a tab (a kernel hole since slice 4a, now refused on tab
|
||||
links), secrets reaching guests through worker errors, close and shutdown races, and leaks of request scopes; each is
|
||||
fixed with a regression test. A route costs about 20 µs more than a plain Node handler (12 µs warmed), accepted for
|
||||
what it buys: a scope per request, admission and the baseline headers.
|
||||
|
||||
Still to decide: `fileResponse` and symbolic links leading out of a served directory; the worker lock on macOS, where
|
||||
a stale lock file cannot be taken over safely; and a route's overhead, measured at 11 to 19 percent of a plain Node
|
||||
handler against a bar of 10 percent.
|
||||
Next comes the app kit (`chord/next/app`), which wires these components with Chord's conventions so an app writes
|
||||
`startApp` and `startWorker`, then slice 11: the browser page and the terminal UI as hosts.
|
||||
|
||||
# API in Brief {.appendix}
|
||||
|
||||
| Call | What it does |
|
||||
| --- | --- |
|
||||
| `httpServer(host, { listen, authenticate, links?, closeTimeout?, onError }, ctx)` | listens, admits, serves `Http` |
|
||||
| `createHttp(host, { listen?, links?, closeTimeout?, onError }, ctx)` | the component: `facet`, `fetch`, `upgrade`, `close` |
|
||||
| `Authenticators` contribution | `(request, ctx) => P \| undefined`: who a request is |
|
||||
| `nodeRequest(message, res)` | a Node request as a Web `Request`, with the listener's checks |
|
||||
| `http.facet` | the facet serving `Http`; load it with the plugins (in `core`) |
|
||||
| `Http` contribution | `{ routes?, wraps?, sockets?, hosts?, framers?, relays? }` |
|
||||
| `Http` contribution | `{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }` |
|
||||
| route | `(request, ctx) => Promise<HttpResponse>`, or `{ handler, page?, crossSite? }` |
|
||||
| `fileResponse(path, options)`, `cookie(name, value, options)` | a file as bytes; a safe cookie |
|
||||
| `createWorkers(host, Kind, { kind, entry, start, lock, principal, authorize, plugins, idleMs?, onError }, ctx)` | a worker per address |
|
||||
|
|
|
|||
|
|
@ -115,8 +115,7 @@ table{max-width:100%}
|
|||
<div class="cover-stats"><div class="stat"><b>1</b><span>registry plugins contribute routes, wraps, sockets and hosts to</span></div><div class="stat"><b>0</b><span>automatic worker restarts</span></div><div class="stat"><b>5 min</b><span>idle before a worker stops (default)</span></div><div class="stat"><b>6</b><span>security rules on by default</span></div></div>
|
||||
<div class="cover-note"><p>Written by the redesign session from <code>slice-10.md</code>, the decision entries "Slice 10's points", "Origins only from
|
||||
relays", "The HTTP component's security baseline" and their neighbours in <code>decisions.md</code>, design §3 and §23, and the
|
||||
build in <code>/home/badlogic/pi-7b</code>. The build exists; its fix round (the security baseline among it) is next. Where the
|
||||
build and the decisions differ, this book says what is decided.</p>
|
||||
build after its four fix rounds (security baseline, <code>fetch</code> core, error boundaries).</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
|
|
@ -185,13 +184,14 @@ workers component, then loads its plugins:</p>
|
|||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
<span class="tk-k">const</span> http = <span class="tk-k">await</span> <span class="tk-f">httpServer</span>(server, { listen: { host: <span class="tk-s">"127.0.0.1"</span>, port: <span class="tk-n">8788</span> }, authenticate, onError }, ctx);
|
||||
<span class="tk-k">const</span> http = <span class="tk-f">createHttp</span>(server, { listen: { host: <span class="tk-s">"127.0.0.1"</span>, port: <span class="tk-n">8788</span> }, onError }, ctx); <span class="tk-c">// settings are arguments</span>
|
||||
<span class="tk-k">const</span> workers = <span class="tk-f">createWorkers</span>(server, <span class="tk-t">Session</span>, {
|
||||
kind: <span class="tk-s">"worker"</span>, entry: <span class="tk-s">"./worker.ts"</span>, plugins, principal: (a) => ({ kind: <span class="tk-s">"worker"</span>, session: a.params.id }),
|
||||
start: (address, ctx) => ({ id: address.params.id, dir: <span class="tk-f">sessionDir</span>(address) }),
|
||||
lock: (address) => <span class="tk-s">`chat:${dataDir}:${address.key}`</span>, authorize: mayReach, onError,
|
||||
}, ctx);
|
||||
<span class="tk-k">await</span> plugins.<span class="tk-f">load</span>(server, { kind: <span class="tk-s">"server"</span>, core: [core, { name: <span class="tk-s">"chord.http"</span>, facet: http.facet }], modules }, ctx);</code></pre>
|
||||
<span class="tk-k">await</span> plugins.<span class="tk-f">load</span>(server, { kind: <span class="tk-s">"server"</span>, core: [core, { name: <span class="tk-s">"chord.http"</span>, facet: http.facet }], modules }, ctx);
|
||||
<span class="tk-c">// `core` contributes the app's authenticator: self.contribute(Authenticators, authenticate)</span></code></pre>
|
||||
<pre><code><span class="tk-c">// worker.ts: one per session</span>
|
||||
<span class="tk-k">await</span> <span class="tk-f">workerMain</span>(<span class="tk-k">async</span> ({ address, start, parent, plugins, onClose }, ctx) => {
|
||||
<span class="tk-k">const</span> store = <span class="tk-k">await</span> <span class="tk-f">openStore</span>(<span class="tk-f">sqliteBackend</span>(<span class="tk-k">await</span> <span class="tk-f">openNodeSqliteDatabase</span>(<span class="tk-f">join</span>(start.dir, <span class="tk-s">"session.sqlite"</span>))), ctx);
|
||||
|
|
@ -266,22 +266,24 @@ chunks for an upload streamed to disk. A body past its limit gets 413; nothing i
|
|||
</section>
|
||||
|
||||
<section class="chapter" id="who"><header class="opener"><div class="numeral">IV</div><h2>Who Is Asking</h2><p class="lede">The app's one security function, and everything the component does so the app does not have to.</p></header>
|
||||
<p class="dropcap">The app writes exactly one function about identity:</p>
|
||||
<pre><code><span class="tk-k">async</span> <span class="tk-k">function</span> <span class="tk-f">authenticate</span>(request: <span class="tk-t">AuthRequest</span>, ctx: <span class="tk-t">Context</span>): <span class="tk-t">Promise</span><P | <span class="tk-k">undefined</span>> {
|
||||
<span class="tk-k">const</span> device = <span class="tk-f">verifyCookie</span>(request.cookies.device, secret);
|
||||
<span class="tk-k">if</span> (device) <span class="tk-k">return</span> { kind: <span class="tk-s">"owner"</span>, access: <span class="tk-s">"device"</span>, device: device.id };
|
||||
<span class="tk-k">const</span> guest = <span class="tk-f">verifyCookie</span>(request.cookies.guest, secret);
|
||||
<span class="tk-k">if</span> (guest) <span class="tk-k">return</span> { kind: <span class="tk-s">"guest"</span>, session: guest.session, grant: guest.id };
|
||||
<p class="dropcap">The app writes one function about identity and contributes it to the registry <code>Authenticators</code> (plugins may add
|
||||
more, a device pairing for instance; the first principal wins):</p>
|
||||
<pre><code><span class="tk-k">const</span> authenticate: <span class="tk-t">Authenticator</span> = <span class="tk-k">async</span> (request, ctx) => {
|
||||
<span class="tk-k">const</span> device = <span class="tk-f">readCookie</span>(request, <span class="tk-s">"device"</span>, secret); <span class="tk-c">// signature and expiry checked</span>
|
||||
<span class="tk-k">if</span> (device) <span class="tk-k">return</span> { kind: <span class="tk-s">"owner"</span>, access: <span class="tk-s">"device"</span>, device };
|
||||
<span class="tk-k">const</span> guest = <span class="tk-f">readCookie</span>(request, <span class="tk-s">"guest"</span>, secret);
|
||||
<span class="tk-k">if</span> (guest) <span class="tk-k">return</span> { kind: <span class="tk-s">"guest"</span>, grant: guest };
|
||||
<span class="tk-k">if</span> (request.provenance.kind === <span class="tk-s">"local"</span>) <span class="tk-k">return</span> { kind: <span class="tk-s">"owner"</span>, access: <span class="tk-s">"local"</span> };
|
||||
<span class="tk-k">return</span> <span class="tk-k">undefined</span>; <span class="tk-c">// anonymous</span>
|
||||
}</code></pre>
|
||||
<span class="tk-k">return</span> <span class="tk-k">undefined</span>; <span class="tk-c">// anonymous</span>
|
||||
};
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Authenticators</span>, authenticate); <span class="tk-c">// in the app's core facet</span></code></pre>
|
||||
<p>It never parses <code>Host</code>, never checks <code>Origin</code> and never sees a foreign request: the component already did all of it.
|
||||
It may read <code>request.provenance</code> (<code>local</code>, <code>remote</code> with the host, or <code>relayed</code> through a verified relay such as
|
||||
Radius). Cookies are written with the component's helper, which sets <code>HttpOnly</code>, <code>SameSite</code>, <code>Secure</code> on a remote
|
||||
origin and a signature.</p>
|
||||
Radius). Cookies are written with <code>cookie(name, value, { secret, request, maxAge })</code>, which sets <code>HttpOnly</code>,
|
||||
<code>SameSite</code>, <code>Secure</code> on a remote origin, and signs the value with its expiry.</p>
|
||||
<p>Every response, the component's own 404s and 403s included, carries the same headers: no caching, no MIME sniffing, no
|
||||
referrer, no framing (<code>X-Frame-Options: DENY</code> and <code>frame-ancestors 'none'</code>, except for framers the app allowed), and a
|
||||
Content Security Policy with a per-server script nonce, so an injected <code><script></code> does not run.</p>
|
||||
Content Security Policy with a fresh script nonce per response (<code>request.nonce</code>), so an injected <code><script></code> does not run.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -318,9 +320,8 @@ Content Security Policy with a per-server script nonce, so an injected <code><
|
|||
<td>the target is parsed by the component, a failure is 400</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<div class="note"><b class="tag">Decided, not yet built</b><p>The security baseline was decided on 2026-10-31 after the security audit and comes with the build's fix round. The
|
||||
build as reviewed checks <code>Host</code> and <code>Origin</code> on upgrades only.</p>
|
||||
</div>
|
||||
<p>The core is a Web <code>fetch</code> handler, so an app with its own server (Hono, Express, plain Node with <code>nodeRequest</code>) mounts
|
||||
it and gets the same checks; without the socket's addresses a mounted request is never local.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="sockets"><header class="opener"><div class="numeral">V</div><h2>Sockets and Links</h2><p class="lede">Chord's own WebSocket for tabs, a plugin's own WebSocket for its protocol, and what closes them.</p></header>
|
||||
|
|
@ -392,20 +393,19 @@ writable, and the router recomposes on each change:</p>
|
|||
<pre><code><span class="tk-k">const</span> hosts = self.<span class="tk-f">writable</span>({ hosts: [] <span class="tk-k">as</span> string[] });
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, hosts);
|
||||
self.<span class="tk-f">on</span>(tunnel.publicHost, ({ value }) => hosts.<span class="tk-f">set</span>({ hosts: value ? [value] : [] }));</code></pre>
|
||||
<p>Functions the components call at each use (<code>authenticate</code>, the security headers' extra sources, a worker's start
|
||||
data, its idle time) can read the app's own registries, which plugins contribute to. What needs a restart is only the
|
||||
<p>Authenticators, framers and extra CSP sources are contributions too; functions the workers component calls at each
|
||||
use (a worker's start data, its idle time) can read the app's own registries, which plugins contribute to. What needs a restart is only the
|
||||
bootstrap: the port, the <code>/chord</code> path, the worker's entry file and its Node flags.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="what-s-next"><header class="opener"><div class="numeral">∴</div><h2>What's Next</h2><p class="lede">The fix round, and the decisions it carries.</p></header>
|
||||
<p class="dropcap">The build exists and passed its tests; four build reviews and three security audits found the issues the fix round now
|
||||
addresses: a malformed request crashing the server, the <code>Host</code>-only provenance, origins invented by a tab (a kernel
|
||||
hole since slice 4a, now refused on tab links), secrets reaching guests through worker errors, several close and
|
||||
shutdown races in workers and HTTP, and two leaks. With it come the decisions of 2026-10-31: the security baseline,
|
||||
relays, allowed hosts only through contributions, public worker errors as fixed messages.</p>
|
||||
<p>Still to decide: <code>fileResponse</code> and symbolic links leading out of a served directory; the worker lock on macOS, where
|
||||
a stale lock file cannot be taken over safely; and a route's overhead, measured at 11 to 19 percent of a plain Node
|
||||
handler against a bar of 10 percent.</p>
|
||||
<section class="chapter epilogue" id="what-s-next"><header class="opener"><div class="numeral">∴</div><h2>What's Next</h2><p class="lede">What the reviews found, and what comes after this slice.</p></header>
|
||||
<p class="dropcap">Four build reviews, three security audits and three rounds of rechecks shaped the slice. They found a malformed request
|
||||
crashing the server, <code>Host</code>-only provenance, origins invented by a tab (a kernel hole since slice 4a, now refused on tab
|
||||
links), secrets reaching guests through worker errors, close and shutdown races, and leaks of request scopes; each is
|
||||
fixed with a regression test. A route costs about 20 µs more than a plain Node handler (12 µs warmed), accepted for
|
||||
what it buys: a scope per request, admission and the baseline headers.</p>
|
||||
<p>Next comes the app kit (<code>chord/next/app</code>), which wires these components with Chord's conventions so an app writes
|
||||
<code>startApp</code> and <code>startWorker</code>, then slice 11: the browser page and the terminal UI as hosts.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter appendix" id="api-in-brief"><header class="opener"><div class="numeral">A</div><h2>API in Brief</h2></header>
|
||||
|
|
@ -417,8 +417,16 @@ handler against a bar of 10 percent.</p>
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td><code>httpServer(host, { listen, authenticate, links?, closeTimeout?, onError }, ctx)</code></td>
|
||||
<td>listens, admits, serves <code>Http</code></td>
|
||||
<td><code>createHttp(host, { listen?, links?, closeTimeout?, onError }, ctx)</code></td>
|
||||
<td>the component: <code>facet</code>, <code>fetch</code>, <code>upgrade</code>, <code>close</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Authenticators</code> contribution</td>
|
||||
<td><code>(request, ctx) => P | undefined</code>: who a request is</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nodeRequest(message, res)</code></td>
|
||||
<td>a Node request as a Web <code>Request</code>, with the listener's checks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>http.facet</code></td>
|
||||
|
|
@ -426,7 +434,7 @@ handler against a bar of 10 percent.</p>
|
|||
</tr>
|
||||
<tr>
|
||||
<td><code>Http</code> contribution</td>
|
||||
<td><code>{ routes?, wraps?, sockets?, hosts?, framers?, relays? }</code></td>
|
||||
<td><code>{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>route</td>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Slice 10: HTTP and workers (spec)
|
||||
|
||||
Status: **approved, building** (owner, 2026-10-30: "just build it"; D1-D12, D10b, round 1's R1-R6 and round 2's Q1-Q6 in the
|
||||
Status: **built** (2026-10-31; build reviews by Fable, Opus, Astra and Sol, security audits by Fable, Opus and Astra, four fix rounds with rechecks; the shape after the fix rounds is in §2, §3 and design §23). Approved by the owner (2026-10-30: "just build it"; D1-D12, D10b, round 1's R1-R6 and round 2's Q1-Q6 in the
|
||||
decision "Slice 10's points"; spec reviews round 1 by Fable and Opus, `/tmp/s10-spec-review-{fable,opus}.md`, round 2
|
||||
by Fable, Opus, Astra and Sol, `/tmp/s10-spec-review2-*.md`, folded in).
|
||||
Slice 10 builds two Node components of the app kit (decision "Slice 7b's open points" (6)): `chord/next/http` (the
|
||||
|
|
@ -39,119 +39,48 @@ and port, the links path, the worker entry with its `execArgv` and `env`, the ho
|
|||
|
||||
## 2. `chord/next/http`
|
||||
|
||||
As built after the fix rounds (decisions "The HTTP core is a fetch handler", "The HTTP server is a native object",
|
||||
"Settings are arguments, extensions are registries", "The HTTP component's security baseline", "allowed hosts only
|
||||
through contributions", "A CSP nonce per response", "three small points", "a route's overhead accepted"); design §23
|
||||
states every rule, `src/next-node/http/README.md` the API.
|
||||
|
||||
```ts
|
||||
const http = await httpServer(server, {
|
||||
listen: { host: "127.0.0.1", port: 8788 }, // bootstrap
|
||||
allowedHosts: () => app.allowedHosts(), // besides loopback and contributed `hosts` (R4)
|
||||
authenticate: (request, ctx) => app.principalOf(request, ctx), // D3: the app's policy, per request and upgrade
|
||||
headers: (request, principal) => app.securityHeaders(request, principal), // every response, also a 404 or a 403
|
||||
const http = createHttp(server, {
|
||||
listen: { host: "127.0.0.1", port: 8788 }, // settings: arguments, a restart to change
|
||||
links: { path: "/chord", options: (request, principal) => ({ limits: linkLimits(principal), maxPayload: 64 * MiB }) },
|
||||
closeTimeout: 30_000, // `close`'s bound on running requests
|
||||
closeTimeout: 30_000,
|
||||
onError: (error) => log(error), // `HttpError`, below
|
||||
}, ctx);
|
||||
// the component's facet goes into the plugins component's core, so every reload passes it unchanged (7c)
|
||||
await plugins.load(server, { kind: "server", core: [{ name: "@app/core", facet: core }, { name: "chord.http", facet: http.facet }] }, ctx);
|
||||
const app = defineFacet((_host, self) => {
|
||||
self.contribute(Authenticators, (request, ctx) => principalFromCookie(readCookie(request, "session", secret)));
|
||||
self.contribute(Http, { hosts: ["notes.example.com"], relays: [radiusRelay] });
|
||||
});
|
||||
await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }, { name: "@app/core", facet: app }], modules }, ctx);
|
||||
…
|
||||
await http.close(ctx); // first (below): no new requests, sockets closed, running requests waited for up to `closeTimeout`
|
||||
await http.close(ctx); // first: no new requests, sockets closed, running requests waited for up to `closeTimeout`
|
||||
await workers.close(ctx);
|
||||
await server.close(ctx);
|
||||
```
|
||||
|
||||
- **Order:** `httpServer` listens at once (plugins see the real port; a page reconnecting while plugins load is not
|
||||
lost). The component resolves `host.registry(Http, ctx)` once (the publication and the router's first `compose`);
|
||||
requests and upgrades arriving before it wait for it (503 after `readyTimeout`), later ones read the router's `.value`
|
||||
per request (no getter, timer or listener per request). Nothing waits for the start barrier: a route's `ctx.get`
|
||||
waits for its object, and `/chord` holds are placed by the publication gate. The plugin name `chord.http` is the
|
||||
component's.
|
||||
- **Checks before anything else** (R4), once per request and per upgrade at its start, against the router of that
|
||||
moment: the `Host` header is loopback (`127.0.0.1:<port>`, `localhost:<port>`, `[::1]:<port>`, without a port when it
|
||||
is 80) or allowed (`allowedHosts()` plus every contribution's `hosts`), else 403; an upgrade's `Origin`, when sent,
|
||||
equals the request's origin, else 403; `request.origin` is built only from an accepted `Host` (`http://` on loopback,
|
||||
`https://` for an allowed host). Then `authenticate`. A hook that throws (`allowedHosts`, `authenticate`, `headers`,
|
||||
`links.options`) is a 500, or a refused upgrade (403), reported `hook_failed { hook, error }`.
|
||||
- **`Http`** (D1): the registry `"chord.http"` (version 1), defined and served by the component's own facet; its
|
||||
`compose` builds the router from every contribution `{ routes?, wraps?, sockets?, hosts? }`; a contribution may be a
|
||||
writable (a plugin that adds and removes its tunnel host at runtime). Each request uses the router as it was when it
|
||||
started.
|
||||
|
||||
```ts
|
||||
// plugins/notes/server.ts
|
||||
export default defineFacet((host, self) => {
|
||||
self.contribute(Http, {
|
||||
routes: {
|
||||
"GET /notes/:id/export": async (request, ctx) => {
|
||||
if (ctx.principal?.kind !== "owner") return { kind: "error", status: 403 };
|
||||
const notes = await ctx.get(NoteStore, {});
|
||||
return { kind: "json", value: await notes.export({ id: request.params.id }, ctx) };
|
||||
},
|
||||
"GET /notes/template.md": async () => fileResponse(pluginFile("template.md"), { download: "template.md" }),
|
||||
},
|
||||
wraps: [{ name: "audit", phase: "observe", wrap: async (request, next, ctx) => audited(await next(request), ctx) }],
|
||||
sockets: { "/terminal": (socket, request, ctx) => terminals.attach(socket, ctx) },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- **A request's scope:** one `host.root` scope with the request's principal spans matching, wraps, the route and the
|
||||
whole write of its response; its signal aborts when the client disconnects. `ctx.principal` is `undefined` when
|
||||
`authenticate` found none: the kernel's `host.root(fn, ctx, { principal: undefined })` runs anonymous (§6), never as
|
||||
the server. A socket handler's scope lasts until the socket closes.
|
||||
- **`HttpRequest`:** `method`, `path`, `params`, `query` (`URLSearchParams`), `headers` (lowercased, read-only),
|
||||
`origin`, `json(limit?)`, `body(limit?)` (D4). `json` parses at most `limit` bytes (1 MiB by default) into a
|
||||
`JsonValue`; `body` is an async iterable of `Uint8Array` chunks, failing past `limit`. Both throw an `HttpFailure`
|
||||
carrying its status (400 malformed, 413 too large); if it escapes the route, that status is the answer (not 500). A
|
||||
body nobody read is drained up to 64 KiB after the response, else the connection closes (`Connection: close`).
|
||||
- **Patterns** (D8): exact segments, `:name` (one non-empty segment, percent-decoded) and a trailing `/*` (the rest,
|
||||
possibly empty: `/files/*` matches `/files`); `params["*"]` is decoded per segment, and a segment that decodes to
|
||||
contain `/` or is `.` or `..` is answered 400. The most specific match wins, segment by segment from the left (exact
|
||||
before `:name` before `*`); one pattern from two contributions is a reported conflict (`registry_conflict`), the first
|
||||
by plugin name wins. `HEAD` falls back to the `GET` route. No route: 404.
|
||||
- **Wraps** (D2): `{ name, phase, scope?, route?, wrap(request, next, ctx) }` in the phases `observe`, `headers`,
|
||||
`transform` (outermost first), within a phase in contributor order (plugin name, then type); `scope: "own"` (default:
|
||||
around the contributing plugin's own routes) or `"all"`; `route` narrows to one pattern (a missing one is a reported
|
||||
conflict). A wrap adds a boundary on top of a route's checks, never instead of them. A throwing wrap is a 500,
|
||||
reported naming its plugin.
|
||||
- **Responses** (D5, R6):
|
||||
|
||||
```ts
|
||||
type HttpResponse = { readonly headers?: Readonly<Record<string, string>> } & (
|
||||
| { readonly kind: "json"; readonly value: JsonValue; readonly status?: number }
|
||||
| { readonly kind: "html"; readonly body: string; readonly status?: number }
|
||||
| { readonly kind: "bytes"; readonly mime: string; readonly size?: number;
|
||||
readonly read: (offset: number, length: number, ctx: Ctx) => Promise<Uint8Array>;
|
||||
readonly download?: string; readonly ranges?: boolean }
|
||||
| { readonly kind: "redirect"; readonly to: string; readonly status?: 302 | 303 | 307 }
|
||||
| { readonly kind: "error"; readonly status: number; readonly message?: string });
|
||||
```
|
||||
|
||||
Headers: the `headers` option's first, then the response's own, replacing same-named ones (case-insensitive: an
|
||||
artifact preview sets its own CSP). `bytes` with a `size`: `Content-Length`; with `ranges`, one range gets 206 and an
|
||||
unsatisfiable one 416, a multi-range request gets 200 with the whole body; `HEAD` sends headers only; `download`
|
||||
sets `Content-Disposition: attachment` with an ASCII fallback and `filename*`. `bytes` without a `size` (R6): chunked
|
||||
encoding, no ranges, `read` called with growing offsets until it returns an empty chunk. Reads are 64 KiB with the
|
||||
socket's backpressure (`drain`), stopped when the client disconnects; a `read` returning fewer bytes than asked with a
|
||||
`size` set destroys the connection (reported). A throwing route is a 500 without its message (reported).
|
||||
- **`fileResponse(path, { download?, ranges?, mime? })`:** `stat`s at once, opens its file handle on the first `read`
|
||||
and closes it when the request's scope seals, so an unwritten response (`HEAD`, a 416, a disconnect, a wrap that
|
||||
replaced it) opens nothing or leaks nothing. Without `mime` it picks from a small fixed table by extension (text,
|
||||
images, audio, video), else `application/octet-stream`; active types (HTML, SVG) only when the route names them.
|
||||
- **Sockets** (D6, Q6): `sockets: { "/terminal": (socket, request, ctx) => … }`, patterns as routes. After the checks
|
||||
and `authenticate` (no principal: 403 before plugin code), the component upgrades and hands the handler a `ws`
|
||||
WebSocket with an `error` listener already attached (an oversized frame closes it, never the server); per
|
||||
contribution options `maxPayload` (1 MiB by default) and no `perMessageDeflate`. Wraps do not apply. When the
|
||||
contribution that handed it out leaves the table (a reload changing or removing it), the component closes its
|
||||
sockets (1012, "service restart"): old code does not keep serving them (design §17).
|
||||
- **The links endpoint:** `links.path` (bootstrap) upgrades to `host.accept(webSocketLink(socket), { principal,
|
||||
limits }, ctx)`, its `limits` and `maxPayload` from `links.options(request, principal)` per socket (a socket keeps
|
||||
what it got); a per-socket output cap (p2's `limitedSocket`: a client that stops reading past 4 MiB unsent is cut off).
|
||||
- **Allowed hosts change** (Q6): when the allowed set changes, links and sockets accepted through a `Host` no longer
|
||||
allowed close (1008, "policy").
|
||||
- **`close(ctx)`:** new requests and upgrades are refused (503), plugin sockets and links closed (1001), running
|
||||
requests waited for up to `closeTimeout`, then their scopes sealed. Run it before `server.close`, whose seal would
|
||||
otherwise cut running requests mid-write.
|
||||
- **The core** is `http.fetch(request, { localAddress?, localPort?, remoteAddress? })`, a Web `Request` to a Web
|
||||
`Response`; `http.upgrade` is Node's upgrade adapter; `nodeRequest(message, res)` converts a Node request with the
|
||||
listener's checks (`{ kind: "request", request, connection } | { kind: "refused", status }`, never a throw) for apps
|
||||
mounting on their own Node server. Chord's own listener (with `listen`) is a host-lifetime object of `chord.http`
|
||||
whose native is a Node server, on a fast path that skips the Web `Request` unless a route or authenticator reads
|
||||
it.
|
||||
- **Registries:** `Http` (`"chord.http"`, version 1: `routes`, `wraps`, `sockets`, `maxPayload`, `hosts`, `framers`,
|
||||
`relays`, `csp`; a value or a writable) and `Authenticators` (in contributor order, the first principal wins).
|
||||
Requests before the publication wait for it (503 past `readyTimeout`).
|
||||
- **Admission, foreign requests, origins, default headers, cookies, downloads:** the security baseline (design §23).
|
||||
Refusals before any plugin code: a non-loopback socket without an allowed host, forwarded headers without a
|
||||
verified relay, duplicate or missing `Host`, userinfo in `Host`, absolute-form targets (403), unparsable targets
|
||||
(400), `TRACE`/`TRACK`/`CONNECT` (501; a route for them refused at compose).
|
||||
- **A request's scope, `HttpRequest`, patterns, wraps, responses, `fileResponse`, sockets, links, revocation,
|
||||
`close`:** design §23. The spec's earlier text (an `httpServer` that listens first, `allowedHosts`, one
|
||||
`authenticate` and a `headers` option, 64 KiB reads) is superseded by those decisions.
|
||||
- **Errors** (`onError`, `HttpError`): `route_failed { pattern, plugin, error }`, `wrap_failed { name, plugin, error }`,
|
||||
`socket_failed { pattern, plugin, error }`, `response_failed { pattern, error }` (a short `read`, a write error),
|
||||
`hook_failed { hook, error }`.
|
||||
`socket_failed { pattern, plugin, error }`, `response_failed { pattern, error }`, `hook_failed { hook, plugin?,
|
||||
error }` (`authenticate`, `relay`, `links.options`).
|
||||
- `ws` is an optional peer dependency (D7), imported only here.
|
||||
|
||||
## 3. `chord/next/workers`
|
||||
|
|
@ -230,8 +159,11 @@ await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
|
|||
default; checked every second), never before `ready`. A stop (idle, `stop`, `close`): `shutdown`; the child aborts its
|
||||
`ctx`, runs the closers registered with `onClose` in reverse order and exits, forcing its own exit after 3 s; the
|
||||
server kills the tree after 5 s.
|
||||
- **`state`** (R5): per address key `{ status: "starting" | "running" | "stopping", pid, startedAt }` or `{ status:
|
||||
"failed", error: { message, code?, reason? }, at, stderrTail? }`; no entry when stopped or never started.
|
||||
- **`state`** (R5, decision "worker errors in public"): per address key `{ status: "starting" | "running" |
|
||||
"stopping", pid, startedAt }` or `{ status: "failed", error: { message, code?, reason? }, at }` with a public error
|
||||
(a fixed message, or the child's redacted `fatal`); no entry when stopped or never started. The full error and the
|
||||
stderr tail go only to `onError` (`worker_failed`); every app hook of a start (`start`, `principal`, `lock`, the
|
||||
fork) is inside that boundary.
|
||||
- **`stop(address, ctx)`** (R1, Q3): `RouteHandle.suspend(address)`, then the stop. The suspension lasts while the
|
||||
address has holders (they see `failed { waiting }`, "suspended"); with the last holder it goes, and a later hold
|
||||
starts the worker on demand; `retry(address)` lifts it at once. Whether an archived session may be opened is the
|
||||
|
|
@ -252,8 +184,9 @@ await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
|
|||
`reloaded { report } | { error }`. One `reload` is outstanding per child at a time: a reload whose caller aborted
|
||||
keeps the child's slot until its `reloaded` or the child's exit, and the next waits for it. Errors cross as `{
|
||||
message, code?, reason? }`.
|
||||
- **Errors** (`onError`, `WorkersError`, which includes `BatchError` for a `state` watch listener that throws): `worker_failed { address, error, stderrTail? }`, `worker_exited { address,
|
||||
code, signal }` (a clean exit after a stop is not reported).
|
||||
- **Errors** (`onError`, `WorkersError`, which includes `BatchError` for a `state` watch listener that throws): `worker_failed { address, error, stderrTail? }` (private),
|
||||
`authorize_failed { address, error }` (a throwing `authorize`: the hold is refused `forbidden`), `worker_exited {
|
||||
address, code, signal }` (only for an exit nobody asked for: not after a stop or a start the component failed).
|
||||
|
||||
### Plugins in workers (D11)
|
||||
|
||||
|
|
@ -310,6 +243,9 @@ channel component (D6); telemetry spans for requests (slice 9).
|
|||
the last one.
|
||||
- `host.root(fn, ctx, { principal: undefined })` runs anonymous (`ctx.principal` undefined); only an omitted
|
||||
`principal` takes the host's (a public-API change of `RootOptions`: `"principal" in options`).
|
||||
- Origins only from relays (decision, 2026-10-31, security audit): `origin` is read only on parent and route links;
|
||||
on a child link it is a protocol error; a per-origin record goes once it has no holds, calls or messages in flight.
|
||||
- An anonymous `host.root` scope gets no remote object served on its host (`forbidden`).
|
||||
- Design §3: the paragraph on a `resolve` waiting for the worker's `load` is updated (done), and "Chord never closes a
|
||||
route link itself except at `host.close`" becomes "except at `host.close`, the heartbeat and protocol errors".
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ slice spec's section citations are for orientation, not a reading limit (owner,
|
|||
(Durable's 35-37); benchmarks in `benchmarks.md`.
|
||||
- **Slice 7b** (built 2026-10-29, `slice-7b.md`): `host.reload`, staging (`host/staging.ts`), fates, adapted members
|
||||
(`host/adapted.ts`), references, jobs and registries across a reload; examples 33-34; `test/next/bench/reload.ts`.
|
||||
- **Slice 7c** (committed 2026-10-30, `slice-7c.md`): `src/next-node/plugins/` (discovery, builds, evaluation,
|
||||
`pluginFile`, `mapStack`, `installDependencies`, the watcher, `rollout`), per-plugin `load` and `reload`; examples 35-39.
|
||||
- **Slice 10** (built 2026-10-31, `slice-10.md`): `src/next-node/http/` (a `fetch` core with the security baseline,
|
||||
Chord's Node listener, `nodeRequest`, plugin sockets, links), `src/next-node/workers/`, `createPluginRuntime`, kinds,
|
||||
the kernel's `RouteHandle` and anonymous `host.root`, origins only from relays; examples 40-48.
|
||||
- **Chord's shared code** (upstream pi): `src/watch.ts` (`Watch`, `NativeWatch`, `WatchHandle`, `WatchEnd`,
|
||||
`NativeWatchable`), Delta (`track`, `diffRevisions`, `applyImmutable`, encoders, `PendingOps`, `appendText`),
|
||||
context, JSON, output, SQLite drivers.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue