slopkit 0.3.0 → 0.4.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DEPS.md CHANGED
@@ -28,9 +28,11 @@ seeded network's PRNG and scheduler.
28
28
 
29
29
  ## Budgets
30
30
 
31
- - **Kit client core**, gated by `pack:check` on the packed package: `schema`, `contract`, `sync`, `client`, `machine`,
32
- `money`, `digest`, `states` and `web/replica`, minified and gzipped together, ≤ 13,951 bytes (13,951 measured; the
33
- paged first copy added 1,470, refused pushes and the bounded outbox 1,755).
31
+ - **Kit client core**, gated by `pack:check` on the packed package: `schema`, `contract`, `api`, `sync`, `client`,
32
+ `machine`, `money`, `digest`, `states`, `web/replica` and `web/runtime`, minified and gzipped together, ≤ 17,286
33
+ bytes (17,286 measured; the paged first copy added 1,470, refused pushes and the bounded outbox 1,755, the sync
34
+ contract and the page's runtime 2,428, in place of each app's own, a working state's owed work 107, and an upgrade
35
+ keeping the offline copy 800).
34
36
  `cron` is browser-safe but outside the core; an app that ships it counts it in its own budget. The budget grows only
35
37
  with a new entry or guarantee, by its measured size.
36
38
  - **Hello app** (preact, signals, and `sync`, `client`, `web/replica`, `schema`, `contract`, `states`): ≤ 17 KB gzip
package/README.md CHANGED
@@ -59,7 +59,8 @@ const update: Update<Note, Msg, Effect> = (note, msg) => {
59
59
  const ui = states<"check">({ order: MEANINGS });
60
60
  const view = ui.subject<Note>("note", {
61
61
  draft: { meaning: "none", where: "main", claim: false, why: "unsent", text: () => "draft", action: null },
62
- saving: { meaning: "working", where: "main", claim: false, why: "sent", text: () => "saving", action: null },
62
+ saving: { meaning: "working", where: "main", claim: false, why: "sent", text: () => "saving", action: null,
63
+ owes: () => ({ sync: true }) }, // a working state names its work, checked to be under way
63
64
  saved: { meaning: "ok", where: "main", claim: false, why: "kept", text: () => "saved", action: null },
64
65
  unsure: { meaning: "you", where: "main", claim: true, why: "may have saved", text: () => "saved?", action: "check" },
65
66
  });
@@ -72,6 +73,14 @@ const { model, effects } = replay(update, { kind: "draft" }, [
72
73
  view.line(model).text; // "saved?", drawn ⚠ with a check action
73
74
  effects; // [{ kind: "send", text: "hi" }], one send
74
75
 
76
+ // the page's own state (route, filters, applied forms) lives in the model too, so every view a replay reaches is checked
77
+ const { problems } = replayProblems(update, { kind: "draft" }, messages, { view: (note) => view.line(note), order: MEANINGS });
78
+
79
+ // the app's real page, headless, over its own server in this process, signed in by the test login
80
+ const page = await openPage({ server, page: () => import("../src/web/main.tsx"), at: "2026-01-01T12:00:00Z", path: "/?note=a" });
81
+ await page.press(/save/); // by accessible name; settles, then checks the view
82
+ await page.reload(); // same device: IndexedDB, localStorage, sessionStorage and the address kept
83
+
75
84
  // the page calls it, and the server answers after checking the login, the request and the reply
76
85
  const sent = await call(save, { text: "hi" }); // { ok: true, value: { id } } or a typed failure
77
86
  await serveWeb({
@@ -94,6 +103,20 @@ export default defineConfig({
94
103
  });
95
104
  ```
96
105
 
106
+ agents call your app with a key (`addKey(home, name)` from `slopkit/server/keys`, allowed as `key:<name>` in
107
+ `web.allow`), never as the person. prove it in your own tests against your real server:
108
+
109
+ ```ts
110
+ import { agentAccess, reads, refused } from "slopkit/test/agents";
111
+
112
+ await agentAccess({
113
+ url: server.url,
114
+ token: await addKey(home, "agent"),
115
+ reads: [reads(list, {})],
116
+ refused: [refused(push, request, { refusal: (reply) => reply.results.every(forbidden) })],
117
+ });
118
+ ```
119
+
97
120
  push handlers return per-command outcomes as ledger receipts. once any command in a request has acted, never throw
98
121
  an `invalid`, `forbidden` or `conflict` refusal for the whole request: a refusal proves only that attempt did not act,
99
122
  not that an earlier delivery did not. request schemas are checked before the handler runs.
@@ -145,6 +168,75 @@ import { pullConformance } from "slopkit/conformance/pull";
145
168
  pullConformance({ name: "my app", create: async (limits) => fixtureOverMyRealStore(limits) });
146
169
  ```
147
170
 
171
+ ### the app shell
172
+
173
+ the page's runtime, the server's sync, the store and the command line are the same in every app, so they live here.
174
+ an app declares its rows, commands and outcomes once:
175
+
176
+ ```ts
177
+ import { syncApi } from "slopkit/api";
178
+
179
+ // pull, push, telemetry and the socket's poke, plus the outbox record. rows need an `id`
180
+ export const api = syncApi({ rows: { notes: note, tags: tag }, server, command, outcome });
181
+ ```
182
+
183
+ the store is the kit's core (meta, revisions, the command ledger, jobs) plus your tables:
184
+
185
+ ```ts
186
+ import { schemaVersion, SyncStore } from "slopkit/server/store";
187
+
188
+ const SCHEMA = { base: "create table notes(...) strict; ...", replicated: ["notes", "tags"], baseVersion: 1,
189
+ migrations: [], indexes: "create index if not exists notes_order on notes(at, id, deleted);" };
190
+
191
+ class Store extends SyncStore {
192
+ readonly ledger = this.createLedger<Kept>({ encode: JSON.stringify, decode: readKept });
193
+ constructor(path: string) { super(path, { name: "notes", schema: SCHEMA }); }
194
+ }
195
+ ```
196
+
197
+ the server answers pull and push over it, and serves them with the poke socket and telemetry:
198
+
199
+ ```ts
200
+ import { serveSync, syncHandlers } from "slopkit/server/sync";
201
+
202
+ const sync = syncHandlers({ api, store, ledger: store.ledger, log, server: (caller) => facts(caller),
203
+ history: { row: "notes", table: "notes", order: ["at", "id"], version: 1, live: "deleted = 0", read: toNote,
204
+ through: ([at]) => String(at) },
205
+ unpaged: (since) => ({ tags: store.tags(since) }), everything: () => store.everything(),
206
+ names: (kept) => [kept.note], limits, act: (command) => ({ local: () => store.apply(command) }),
207
+ outcome: (kept) => kept });
208
+
209
+ await serveSync({ name: "notes", api, sync, home, config, env, log, page, assets: "dist/web" });
210
+ ```
211
+
212
+ your outcome union must admit the kit's own answers (`failed` with `conflict` or `forbidden`, and `retired`): if it
213
+ can't, `serveSync` doesn't compile. the page runs the same engine headless or in a browser:
214
+
215
+ ```ts
216
+ import { createRuntime } from "slopkit/web/runtime";
217
+
218
+ const runtime = createRuntime({ name: "notes", sync, api, replica: { version: 1, tables, empty } });
219
+ runtime.subscribe((model) => draw(model));
220
+ runtime.start();
221
+ ```
222
+
223
+ and `main.ts` is the command line: `serve | update | rollback | probe | version`, one server per home, releases that
224
+ boot on a copy of the store before they replace the running one, automatic rollback of a crashing update, and `probe`,
225
+ the live server checked from its host with no identity (it serves, the version that passed, no test login, requests
226
+ without identity refused, the public door matching `web.json`):
227
+
228
+ ```ts
229
+ import { runCli } from "slopkit/server/cli";
230
+
231
+ process.exitCode = await runCli(process.argv.slice(2), {
232
+ name: "notes", root: new URL("../", import.meta.url), schema: schemaVersion(SCHEMA), serves: "your notes.",
233
+ serve: async ({ home, version, log, web, env }) => {
234
+ /* open the store, serveSync when web isn't off */
235
+ return { url, close };
236
+ },
237
+ });
238
+ ```
239
+
148
240
  the shared lint rules go on top of ultracite's presets (`oxlint.config.ts`):
149
241
 
150
242
  ```ts
package/RULES.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Rules for apps on slopkit
2
2
 
3
- Rules version: 1
3
+ Rules version: 2
4
4
 
5
5
  The standard every app built on `slopkit` meets, and the kit itself. The aim is speed and correctness,
6
6
  solved once in the kit so every app built on it starts with these problems solved. Each rule is a property of the
@@ -56,9 +56,21 @@ What the screen can say is decided for every state, as a pure function of the ap
56
56
  with composition rules, not one giant enum. Prevents a concurrent fact being lost to another.
57
57
  - **Every state's display is decided, with its reason.** A typed table `satisfies Record<State["kind"], Display>` maps
58
58
  each state to where it is drawn (`main`: it changes what the person should believe about the numbers or needs them
59
- to act; `badge`; `panel`; `log`), why, tone, glyph, text and action. A new state doesn't compile until it is placed.
59
+ to act; `badge`; `panel`; `log`), why, tone, glyph, text and action, and for a working state what it owes. A new
60
+ state doesn't compile until it is placed.
60
61
  Two states share a display only when the statement is true for both and implies the same safe action. A hidden state
61
62
  has a reason, and hiding it changes no claim or action.
63
+ - **Working names what it waits on.** A working state's table entry names the work it waits on (a job, a command,
64
+ the page's own sync), and the state is drawn only while that work is under way. One whose work is the world's (a
65
+ parcel in transit) says so in its entry, with why. Prevents "checking…" on a check nobody runs.
66
+ - **The page's own state is model state.** What changes what the screen says or what a tap does (the route and open
67
+ item, modes, open panels, filters and searches, selections, forms with drafts and applied values held apart, and
68
+ persisted view settings) is part of the model. It changes only through the model's pure `update` (a message in; the
69
+ model and effects out), so a replay of messages reaches it like the rest of the model. The address and storage are
70
+ read once at load and written as effects, so a deep link opens the screen navigating there does, back returns, and
71
+ a reload keeps what was set on that device. The numbers never read a form's draft. Focus, hover, scroll and
72
+ animation stay in the browser unless the view changes with them. Prevents a selection that moves the numbers with
73
+ no state of its own.
62
74
  - **Never claim more than is true.** Order by placement, then severity, with each app setting its own severity order
63
75
  by consequence. Facts that change what the numbers mean (incomplete, possibly wrong, not saved, stale) sit next to
64
76
  the numbers and are never folded into a count behind another headline; a headline may lead with an action (sign in
@@ -78,8 +90,10 @@ What the screen can say is decided for every state, as a pure function of the ap
78
90
  and accessibility violations fail the build. No new colours, boxes or spinners.
79
91
  - **Verified over the whole catalog.** Tests enumerate every state and bounded combinations through the real engine
80
92
  and `view`: placement holds, states needing different actions never render the same text, and no number is shown
81
- without its claim-critical facts. Every state can also be drawn on its own at phone size (a gallery of the catalog),
82
- so each display can be seen as rendered.
93
+ without its claim-critical facts. Every view an app's tests reach (replayed messages, the app's real page driven
94
+ headless) passes the kit's `viewProblems`: no broken value, "ok" only when every part is, claims kept whole, and
95
+ every working line's owed work under way. Every state can also be drawn on its own at phone size (a gallery of the
96
+ catalog), so each display can be seen as rendered.
83
97
 
84
98
  ## 4. Outside services
85
99
 
@@ -154,7 +168,9 @@ work up.
154
168
  overlap, interrupted commits, shared storage, identity changes, reconnects. More happy paths or seeds can't cover a missing failure dimension.
155
169
  - Headless correctness doesn't prove the UI binding: a renderer can drop a recovery button or swap actions while
156
170
  `view()` passes. Thin rendering and binding checks prove each action in `view` reaches the right command and each
157
- warning is drawn, in each target browser (Safari and iOS PWA storage differ). A packed-consumer check proves the app
171
+ warning is drawn, on the app's real page driven headless, and in each target browser for what a DOM shim can't
172
+ show: layout and overflow, and platform bindings (a popup inside the tap, storage persistence, the service worker;
173
+ Safari and iOS PWA storage differ). A packed-consumer check proves the app
158
174
  runs the kit as shipped.
159
175
  - Tiers: pure model tests in milliseconds; a seeded headless end-to-end gate in seconds; adapter and binding checks; a
160
176
  read-only check of the deployed app on synthetic data.
@@ -202,7 +218,11 @@ Performance is a requirement, aimed at the limits of every service and system in
202
218
  - **Dependencies.** Few, from `DEPS.md`, so apps stay small. Few never means hand-rolled crypto, JWT, protocol parsers
203
219
  or weaker storage semantics: those use a vetted library (for example `jose`).
204
220
  - **Defaults:** IndexedDB in the browser (SQLite in the browser only where demonstrably better); SQLite on the server;
205
- OIDC login, or Cloudflare Access's signed header where deployed; logins added in the UI.
221
+ logins to outside accounts added in the UI.
222
+ - **Its own login.** An app signs people in itself, with OpenID Connect, and serves on a local binding. An
223
+ identity-aware proxy in front (Cloudflare Access's signed header) is an optional outer door, never the app's login.
224
+ A test identity (the kit's OIDC stand-in) signs in on a test home, so the app is proven end to end without a real
225
+ login.
206
226
  - **Look.** Brutalist, mobile-first PWAs: dense, monospace, black and white, no shadows or rounded corners.
207
227
  - Shipped source is generic: no hostnames, accounts or private data in code or docs; private data lives in the app's
208
228
  data directory and configuration.
package/dist/api.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { Endpoint } from "./contract.ts";
2
+ import type { Schema } from "./schema.ts";
3
+ import type { Page, Pending, Receipt } from "./sync.ts";
4
+ /** The answers the kit's server gives a command itself (`slopkit/server/sync`): an app's outcomes must admit them. */
5
+ export type KitOutcome = {
6
+ kind: "failed";
7
+ reason: "conflict" | "forbidden";
8
+ message: string;
9
+ } | {
10
+ kind: "retired";
11
+ };
12
+ /** Each row key's list, as a pull reply carries them. */
13
+ export type Rows<R> = {
14
+ [K in keyof R]: R[K][];
15
+ };
16
+ export type PullRequest = {
17
+ client: string;
18
+ dataset: string | null;
19
+ since: number;
20
+ verify?: boolean;
21
+ page?: string | null;
22
+ commands?: {
23
+ client: string;
24
+ id: number;
25
+ }[];
26
+ };
27
+ export type PullReply<R, S, O> = Rows<R> & {
28
+ dataset: string;
29
+ revision: number;
30
+ reset: boolean;
31
+ commands: Receipt<O>[];
32
+ server: S;
33
+ checksum?: string;
34
+ page?: Page;
35
+ };
36
+ export type PushRequest<C> = {
37
+ client: string;
38
+ dataset: string | null;
39
+ since: number;
40
+ commands: {
41
+ id: number;
42
+ command: C;
43
+ }[];
44
+ };
45
+ export type PushReply<O> = {
46
+ results: Receipt<O>[];
47
+ revision: number;
48
+ };
49
+ export type TelemetryEvent = {
50
+ name: string;
51
+ at: string;
52
+ attributes?: Record<string, string | number | boolean | null>;
53
+ };
54
+ export type TelemetryRequest = {
55
+ client: string;
56
+ events: TelemetryEvent[];
57
+ };
58
+ /** What the socket sends: the server's revision, when it moves and as a heartbeat. */
59
+ export type Poke = {
60
+ type: "poke";
61
+ revision: number;
62
+ };
63
+ export type SyncApi<R, S, C, O> = {
64
+ /** The row keys, in the order the fingerprint and the checksum digest them. */
65
+ rows: readonly (keyof R & string)[];
66
+ /** What the server says of itself, read with every pull. */
67
+ server: Schema<S>;
68
+ pull: Endpoint<PullRequest, PullReply<R, S, O>>;
69
+ push: Endpoint<PushRequest<C>, PushReply<O>>;
70
+ telemetry: Endpoint<TelemetryRequest, {
71
+ ok: true;
72
+ }>;
73
+ socket: {
74
+ path: string;
75
+ message: Schema<Poke>;
76
+ };
77
+ /** A command as a device's outbox keeps it, checked when read back. */
78
+ pending: Schema<Pending<C, O>>;
79
+ };
80
+ export declare function syncApi<R extends Record<string, {
81
+ id: string | number;
82
+ }>, S, C, O>(definition: {
83
+ rows: {
84
+ [K in keyof R]: Schema<R[K]>;
85
+ };
86
+ server: Schema<S>;
87
+ command: Schema<C>;
88
+ outcome: Schema<O>;
89
+ /** A store's dataset id; default text of 1 to 200 characters. */
90
+ dataset?: Schema<string>;
91
+ /** A page's `through`, as the app formats an order group; default text of at most 64 characters. */
92
+ through?: Schema<string>;
93
+ }): SyncApi<R, S, C, O>;
package/dist/api.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The sync contract's endpoints, built from an app's own schemas: pull (what changed since a revision, paged), push
3
+ * (commands, each acting once), telemetry (the page's counts and timings, for the server's log) and the socket's poke.
4
+ * The page, the server and the engine share these shapes, so an app declares only its rows, what its server says of
5
+ * itself, its commands and their outcomes.
6
+ */
7
+ import { endpoint } from "./contract.js";
8
+ import { array, boolean, literal, nullable, number, object, optional, record, scalar, string, variant, } from "./schema.js";
9
+ import { PUSH_MAX } from "./sync.js";
10
+ /** A reply's own fields: no row list may take one of their names. */
11
+ const RESERVED = new Set([
12
+ "dataset",
13
+ "revision",
14
+ "reset",
15
+ "commands",
16
+ "server",
17
+ "checksum",
18
+ "page",
19
+ ]);
20
+ const revision = number({ integer: true, min: 0 });
21
+ const client = string({ pattern: /^[A-Za-z0-9_-]{8,64}$/ });
22
+ const commandId = number({ integer: true, min: 1 });
23
+ const cursor = string({ min: 1, max: 1000 });
24
+ const iso = string({
25
+ pattern: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/,
26
+ });
27
+ export function syncApi(definition) {
28
+ const keys = Object.keys(definition.rows);
29
+ const taken = keys.find((key) => RESERVED.has(key));
30
+ if (taken !== undefined) {
31
+ throw new Error(`syncApi: a row key can't be a reply's own field: ${taken}`);
32
+ }
33
+ const dataset = definition.dataset ?? string({ min: 1, max: 200 });
34
+ const receipt = object({ id: commandId, outcome: definition.outcome, revision, client: optional(client) }, { strict: true });
35
+ const page = object({
36
+ from: nullable(cursor),
37
+ next: nullable(cursor),
38
+ through: nullable(definition.through ?? string({ max: 64 })),
39
+ held: number({ integer: true, min: 0 }),
40
+ total: number({ integer: true, min: 0 }),
41
+ }, { strict: true });
42
+ const lists = Object.fromEntries(Object.entries(definition.rows).map(([key, row]) => [key, array(row)]));
43
+ const reply = object({
44
+ dataset,
45
+ revision,
46
+ reset: boolean(),
47
+ ...lists,
48
+ commands: array(receipt),
49
+ server: definition.server,
50
+ checksum: optional(string({ pattern: /^[0-9a-f]{64}$/ })),
51
+ page: optional(page),
52
+ }, { strict: true });
53
+ const pull = endpoint("GET", "/api/pull", object({
54
+ client,
55
+ dataset: nullable(dataset),
56
+ since: revision,
57
+ verify: optional(boolean()),
58
+ page: optional(nullable(cursor)),
59
+ commands: optional(array(object({ client, id: commandId }, { strict: true }), { max: PUSH_MAX })),
60
+ }, { strict: true }), {
61
+ expected: reply.expected,
62
+ shape: reply.shape,
63
+ // SAFETY: `reply` is the strict object of exactly PullReply's fields: the fixed ones above and one array of
64
+ // each row schema under its key; the spread of `lists` is what TypeScript can't follow.
65
+ // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion
66
+ parse: (value, path) => reply.parse(value, path),
67
+ });
68
+ const push = endpoint("POST", "/api/push", object({
69
+ client,
70
+ dataset: nullable(dataset),
71
+ since: revision,
72
+ commands: array(object({ id: commandId, command: definition.command }, { strict: true }), {
73
+ max: PUSH_MAX,
74
+ }),
75
+ }, { strict: true }), object({ results: array(receipt), revision }, { strict: true }));
76
+ const telemetry = endpoint("POST", "/api/telemetry", object({
77
+ client,
78
+ events: array(object({
79
+ name: string({ pattern: /^[a-z][a-z0-9.-]{0,63}$/ }),
80
+ at: iso,
81
+ attributes: optional(record(scalar())),
82
+ }, { strict: true }), { max: 100 }),
83
+ }, { strict: true }), object({ ok: literal(true) }, { strict: true }));
84
+ const message = variant("type", {
85
+ poke: object({ type: literal("poke"), revision }, { strict: true }),
86
+ });
87
+ const pending = object({
88
+ client,
89
+ id: commandId,
90
+ dataset: nullable(dataset),
91
+ since: optional(revision),
92
+ command: definition.command,
93
+ at: number(),
94
+ state: variant("kind", {
95
+ saving: object({ kind: literal("saving") }, { strict: true }),
96
+ queued: object({ kind: literal("queued") }, { strict: true }),
97
+ sending: object({ kind: literal("sending") }, { strict: true }),
98
+ unknown: object({ kind: literal("unknown"), failure: string(), parked: optional(literal(true)) }, { strict: true }),
99
+ done: object({ kind: literal("done"), outcome: definition.outcome, revision }, { strict: true }),
100
+ }),
101
+ }, { strict: true });
102
+ return {
103
+ rows: keys,
104
+ server: definition.server,
105
+ pull,
106
+ push,
107
+ telemetry,
108
+ socket: { path: "/api/socket", message },
109
+ pending,
110
+ };
111
+ }
package/dist/client.d.ts CHANGED
@@ -13,11 +13,15 @@ export declare class Replaced extends Error {
13
13
  constructor();
14
14
  }
15
15
  export type StoragePort<T, S, C, O> = {
16
- /** The replica and the outbox; `unreadable` counts kept commands this version can't read (left on disk). */
16
+ /**
17
+ * The replica and the outbox; `unreadable` counts kept commands this version can't read (left on disk); `restart`
18
+ * when the copy owes a real reset before it is trusted (one lifted from an earlier release's row shapes).
19
+ */
17
20
  load: () => Promise<{
18
21
  replica: Replica<T, S>;
19
22
  commands: Pending<C, O>[];
20
23
  unreadable: number;
24
+ restart?: "paged";
21
25
  }>;
22
26
  /**
23
27
  * Commit a patch, decided against the disk inside its transaction; returns what the disk now holds, and `whole:
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, expect, test } from "vitest";
5
+ import { endpoint } from "../contract.js";
6
+ import { array, literal, object, string } from "../schema.js";
7
+ import { parseWebConfig } from "../server/config.js";
8
+ import { addKey } from "../server/keys.js";
9
+ import { Log, scoped } from "../server/log.js";
10
+ import { route, serveWeb } from "../server/web.js";
11
+ import { agentAccess, reads, refused } from "../test/agents.js";
12
+ import { identityProvider } from "../test/oidc.js";
13
+ const cleanups = [];
14
+ afterEach(async () => {
15
+ for (const close of cleanups.splice(0).toReversed()) {
16
+ await close();
17
+ }
18
+ });
19
+ const list = endpoint("GET", "/api/list", object({}), object({ items: array(string()) }));
20
+ const grade = endpoint("POST", "/api/grade", object({ item: string() }), object({ ok: literal(true) }));
21
+ const push = endpoint("POST", "/api/push", object({ item: string() }), object({ outcome: literal("done", "forbidden") }));
22
+ /** An app on loopback with one key: `person` says which of its routes only a person's login may call. */
23
+ async function app(person) {
24
+ const home = await mkdtemp(join(tmpdir(), "agent-access-"));
25
+ const provider = await identityProvider("owner");
26
+ const token = await addKey(home, "agent");
27
+ const server = await serveWeb({
28
+ home,
29
+ config: parseWebConfig({
30
+ port: 0,
31
+ oidc: { issuer: provider.issuer, clientId: "test" },
32
+ allow: ["sub:owner", "key:agent"],
33
+ }, []),
34
+ env: {},
35
+ log: scoped(new Log(home, "test", { echo: false }), "web"),
36
+ assets: home,
37
+ manifest: {},
38
+ cookie: "app_session",
39
+ routes: [
40
+ route({ ...list, person: person.list }, () => ({ items: ["a"] })),
41
+ route({ ...grade, person: person.grade }, () => ({ ok: true })),
42
+ route(push, (_request, caller) => ({ outcome: caller.person ? "done" : "forbidden" })),
43
+ ],
44
+ });
45
+ cleanups.push(async () => {
46
+ await server.close();
47
+ await provider.close();
48
+ await rm(home, { recursive: true, force: true });
49
+ });
50
+ return { url: server.url, token };
51
+ }
52
+ const cases = {
53
+ reads: [reads(list, {})],
54
+ refused: [
55
+ refused(grade, { item: "a" }),
56
+ refused(push, { item: "a" }, { refusal: (reply) => reply.outcome === "forbidden" }),
57
+ ],
58
+ };
59
+ test("an app whose key reads its agents' calls and is refused the person's passes", async () => {
60
+ const running = await app({ list: false, grade: true });
61
+ await expect(agentAccess({ ...running, ...cases })).resolves.toBeUndefined();
62
+ });
63
+ test("a person's call a key can make fails, naming it", async () => {
64
+ const running = await app({ list: false, grade: false });
65
+ await expect(agentAccess({ ...running, ...cases })).rejects.toThrow("POST /api/grade: a key should be refused, got an answer");
66
+ });
67
+ test("an agent's read a key can't make fails, naming it", async () => {
68
+ const running = await app({ list: true, grade: true });
69
+ await expect(agentAccess({ ...running, ...cases })).rejects.toThrow("GET /api/list: a key should read it, got forbidden");
70
+ });
71
+ test("a check with nothing to read or nothing refused proves nothing and fails", async () => {
72
+ const running = await app({ list: false, grade: true });
73
+ await expect(agentAccess({ ...running, reads: [], refused: cases.refused })).rejects.toThrow("proves nothing");
74
+ });
@@ -11,6 +11,7 @@ const working = {
11
11
  why: "the copy is loading",
12
12
  text: () => "loading copy",
13
13
  action: null,
14
+ owes: () => ({ sync: true }),
14
15
  };
15
16
  const warning = {
16
17
  meaning: "ours",
@@ -45,6 +45,7 @@ const table = {
45
45
  why: "Tap waits, numbers unchanged",
46
46
  text: () => "keeping the tap",
47
47
  action: null,
48
+ owes: null,
48
49
  },
49
50
  };
50
51
  const kit = () => states({ order });
@@ -288,14 +289,24 @@ describe("states: independent conformance", () => {
288
289
  text: () => meaning,
289
290
  action: null,
290
291
  }
291
- : {
292
- meaning,
293
- where: "main",
294
- claim: false,
295
- why: "An obligation",
296
- text: () => meaning,
297
- action: null,
298
- },
292
+ : meaning === "working"
293
+ ? {
294
+ meaning,
295
+ where: "main",
296
+ claim: false,
297
+ why: "An obligation",
298
+ text: () => meaning,
299
+ action: null,
300
+ owes: null,
301
+ }
302
+ : {
303
+ meaning,
304
+ where: "main",
305
+ claim: false,
306
+ why: "An obligation",
307
+ text: () => meaning,
308
+ action: null,
309
+ },
299
310
  })
300
311
  .line({ kind: "x" }));
301
312
  for (let i = 0; i < subjects.length; i++) {
@@ -12,15 +12,16 @@ export declare class WebAuth {
12
12
  private writing;
13
13
  private readonly pending;
14
14
  private readonly env;
15
+ private readonly keys;
15
16
  constructor(config: WebConfig, home: string, env: Readonly<Record<string, string | undefined>>);
16
17
  get oidc(): boolean;
17
18
  private allowed;
18
19
  /**
19
- * The allowed identities of a request: from the proxy's JWT when it sends one (a bad one is refused, never ignored),
20
- * else from the session token (the session cookie's value). `undefined` means not signed in; `false`, signed in but
20
+ * The allowed identities of a request: from an agent's bearer key when it sends one, else the proxy's JWT when it
21
+ * sends one (a bad one of either is refused, never ignored), else from the session token (the session cookie's value). `undefined` means not signed in; `false`, signed in but
21
22
  * not allowed.
22
23
  */
23
- identify(proxyJwt: string | undefined, token: string | undefined): Promise<Identities | false | undefined>;
24
+ identify(proxyJwt: string | undefined, token: string | undefined, bearer?: string): Promise<Identities | false | undefined>;
24
25
  private discover;
25
26
  /** Where to send a browser to sign in, with PKCE; `redirect` is this server's own callback URL. */
26
27
  loginUrl(redirect: string): Promise<string>;
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Who a web request is: Cloudflare Access's signed JWT (`Cf-Access-Jwt-Assertion`), or a session from this server's
3
- * own OpenID Connect login (authorization code with PKCE). The issuer, audience and claims come from `web.json`.
4
- * Either way the identity must be on `web.allow`.
2
+ * Who a web request is: an agent's key (`Authorization: Bearer`, see `keys.ts`), Cloudflare Access's signed JWT
3
+ * (`Cf-Access-Jwt-Assertion`), or a session from this server's own OpenID Connect login (authorization code with
4
+ * PKCE). The issuer, audience and claims come from `web.json`. Either way the identity must be on `web.allow`.
5
5
  */
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { readFile, rename, writeFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { createRemoteJWKSet, jwtVerify } from "jose";
10
+ import { Keys } from "./keys.js";
10
11
  const SESSION_DAYS = 7;
11
12
  const PENDING_MS = 10 * 60_000;
12
13
  /** A claim by dotted path, as a string, or `undefined`. */
@@ -42,6 +43,7 @@ export class WebAuth {
42
43
  writing = Promise.resolve();
43
44
  pending = new Map();
44
45
  env;
46
+ keys;
45
47
  constructor(config, home, env) {
46
48
  if (config.access === undefined && config.oidc === undefined) {
47
49
  throw new Error("web needs a login: set web.access (an identity-aware proxy) or web.oidc in web.json");
@@ -52,6 +54,7 @@ export class WebAuth {
52
54
  this.config = config;
53
55
  this.env = env;
54
56
  this.sessionsPath = join(home, "web-sessions.json");
57
+ this.keys = new Keys(home);
55
58
  this.accessKeys =
56
59
  config.access === undefined
57
60
  ? undefined
@@ -66,11 +69,16 @@ export class WebAuth {
66
69
  : undefined;
67
70
  }
68
71
  /**
69
- * The allowed identities of a request: from the proxy's JWT when it sends one (a bad one is refused, never ignored),
70
- * else from the session token (the session cookie's value). `undefined` means not signed in; `false`, signed in but
72
+ * The allowed identities of a request: from an agent's bearer key when it sends one, else the proxy's JWT when it
73
+ * sends one (a bad one of either is refused, never ignored), else from the session token (the session cookie's value). `undefined` means not signed in; `false`, signed in but
71
74
  * not allowed.
72
75
  */
73
- async identify(proxyJwt, token) {
76
+ async identify(proxyJwt, token, bearer) {
77
+ // A bearer decides alone: one that doesn't verify is refused, never passed over for a cookie or a proxy's header.
78
+ if (bearer !== undefined) {
79
+ const name = await this.keys.verify(bearer);
80
+ return name === undefined ? false : (this.allowed([`key:${name}`]) ?? false);
81
+ }
74
82
  if (proxyJwt !== undefined) {
75
83
  const access = this.config.access;
76
84
  if (access === undefined || this.accessKeys === undefined) {