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.
@@ -4,8 +4,12 @@
4
4
  * (an access proxy's JWT or the server's own OIDC login), the Host must be ours, and every POST and the socket must come
5
5
  * from the page's own Origin. Responses carry a strict CSP and no framing.
6
6
  *
7
+ * An agent's key (`Authorization: Bearer`) decides a request alone and needs no Origin: the Origin check guards
8
+ * credentials a browser sends on its own (cookies), and a bearer isn't one.
9
+ *
7
10
  * Routes are contract endpoints: the request is checked against its schema before the handler runs and the reply
8
11
  * after, so neither side can drift from the contract. Failures answer `{ kind, message, path? }` with the kind's status.
12
+ * Raw routes (`rawRoute`) take and answer bytes behind the same guards, with their own body limit.
9
13
  * The socket is a hint channel: the app sends small messages (pokes, pings); nothing correct depends on it.
10
14
  * Every API request is logged with an id, status, time and whether a person or a service asked.
11
15
  */
@@ -45,11 +49,19 @@ export class Refusal extends Error {
45
49
  this.path = path;
46
50
  }
47
51
  }
48
- export function route(endpoint, handle) {
52
+ /** A body limit as given: a positive whole number of bytes. */
53
+ function bodyBytes(bytes) {
54
+ if (!Number.isSafeInteger(bytes) || bytes < 1) {
55
+ throw new Error(`a body limit must be a positive integer, got ${bytes}`);
56
+ }
57
+ return bytes;
58
+ }
59
+ export function route(endpoint, handle, options = {}) {
49
60
  return {
50
61
  method: endpoint.method,
51
62
  path: endpoint.path,
52
63
  person: endpoint.person,
64
+ ...(options.bodyLimit === undefined ? {} : { bodyLimit: bodyBytes(options.bodyLimit) }),
53
65
  run: async (input, caller) => {
54
66
  let request;
55
67
  try {
@@ -66,6 +78,53 @@ export function route(endpoint, handle) {
66
78
  },
67
79
  };
68
80
  }
81
+ export function rawRoute(definition) {
82
+ if (!/^\/[^*]*(\/\*)?$/.test(definition.path)) {
83
+ throw new Error(`a raw route's path is exact or ends in /*: ${definition.path}`);
84
+ }
85
+ return {
86
+ raw: true,
87
+ method: definition.method,
88
+ path: definition.path,
89
+ person: definition.person ?? false,
90
+ bodyLimit: bodyBytes(definition.bodyLimit ?? 65_536),
91
+ handle: definition.handle,
92
+ };
93
+ }
94
+ /** Types worth gzipping; images never are, whatever their format. */
95
+ const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json))/i;
96
+ /** CSP directives that only take away: a handler may add these to the server's policy. */
97
+ const TIGHTENING = new Set([
98
+ "sandbox",
99
+ "upgrade-insecure-requests",
100
+ "block-all-mixed-content",
101
+ "require-trusted-types-for",
102
+ "trusted-types",
103
+ ]);
104
+ function directives(policy) {
105
+ return new Map(policy
106
+ .split(";")
107
+ .map((part) => part.trim().replaceAll(/\s+/g, " "))
108
+ .filter((part) => part !== "")
109
+ .map((part) => {
110
+ const space = part.indexOf(" ");
111
+ return space === -1
112
+ ? [part.toLowerCase(), ""]
113
+ : [part.slice(0, space).toLowerCase(), part.slice(space + 1)];
114
+ }));
115
+ }
116
+ /**
117
+ * The server's policy with the tightening directives a handler's policy names added (`sandbox` for bytes someone else
118
+ * made). Everything else in the handler's policy is ignored: the server's directives are never replaced or dropped.
119
+ */
120
+ function tightened(base, offered) {
121
+ if (offered === undefined) {
122
+ return base;
123
+ }
124
+ const own = directives(base);
125
+ const added = [...directives(offered)].flatMap(([name, value]) => TIGHTENING.has(name) && !own.has(name) ? [value === "" ? name : `${name} ${value}`] : []);
126
+ return [base, ...added].join("; ");
127
+ }
69
128
  function header(request, name) {
70
129
  const value = request.headers[name];
71
130
  return Array.isArray(value) ? value[0] : value;
@@ -77,11 +136,36 @@ function sessionToken(request, cookie) {
77
136
  .find((part) => part.startsWith(`${cookie}=`))
78
137
  ?.slice(cookie.length + 1);
79
138
  }
139
+ /** How long a sign-in keeps the page it interrupted. */
140
+ const BACK_SECONDS = 600;
141
+ function decodeCookie(value) {
142
+ try {
143
+ return value === undefined ? undefined : decodeURIComponent(value);
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ }
149
+ /**
150
+ * A path on this server to return to, or nothing: never another origin (`//host`, `/\\host`, a scheme), and only
151
+ * printable ASCII, which a Location header always takes (the redirect to the login percent-encodes the rest).
152
+ */
153
+ function onThisServer(path) {
154
+ return path !== null &&
155
+ path !== undefined &&
156
+ path.length <= 2048 &&
157
+ /^\/(?![/\\])[\u0021-\u005B\u005D-\u007E]*$/.test(path)
158
+ ? path
159
+ : undefined;
160
+ }
80
161
  /** Send a body, gzipped when the client takes it and it is worth it. */
81
162
  function send(request, response, status, body, headers = {}) {
82
163
  let bytes = typeof body === "string" ? Buffer.from(body) : body;
83
164
  const extra = {};
84
- if (bytes.length > 1024 && /\bgzip\b/.test(header(request, "accept-encoding") ?? "")) {
165
+ const type = headers["content-type"] ?? "text/plain";
166
+ if (bytes.length > 1024 &&
167
+ COMPRESSIBLE.test(type) &&
168
+ /\bgzip\b/.test(header(request, "accept-encoding") ?? "")) {
85
169
  bytes = gzipSync(bytes, { level: 6 });
86
170
  extra["content-encoding"] = "gzip";
87
171
  extra.vary = "accept-encoding";
@@ -98,11 +182,12 @@ function send(request, response, status, body, headers = {}) {
98
182
  function failure(request, response, kind, message, path) {
99
183
  send(request, response, STATUS[kind], JSON.stringify({ kind, message, ...(path === undefined ? {} : { path }) }), { "content-type": "application/json" });
100
184
  }
101
- /**
102
- * The body as JSON: its bytes collected and counted against `limit`, then decoded once, so a character split between
103
- * chunks arrives whole. Bytes that aren't UTF-8 are refused, never replaced.
104
- */
105
- async function readBody(request, limit) {
185
+ /** The body's bytes, counted against `limit` as they arrive: past it, refused before anything reads them. */
186
+ async function readBytes(request, limit) {
187
+ const declared = Number(header(request, "content-length"));
188
+ if (Number.isFinite(declared) && declared > limit) {
189
+ throw new Refusal("invalid", "the request is too large");
190
+ }
106
191
  const chunks = [];
107
192
  let size = 0;
108
193
  for await (const chunk of request) {
@@ -113,9 +198,17 @@ async function readBody(request, limit) {
113
198
  }
114
199
  chunks.push(bytes);
115
200
  }
201
+ return Buffer.concat(chunks);
202
+ }
203
+ /**
204
+ * The body as JSON: its bytes collected and counted against `limit`, then decoded once, so a character split between
205
+ * chunks arrives whole. Bytes that aren't UTF-8 are refused, never replaced.
206
+ */
207
+ async function readBody(request, limit) {
208
+ const bytes = await readBytes(request, limit);
116
209
  let text;
117
210
  try {
118
- text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
211
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
119
212
  }
120
213
  catch {
121
214
  throw new Refusal("invalid", "the request is not UTF-8");
@@ -133,22 +226,124 @@ export async function serveWeb(options) {
133
226
  const hosts = new Set();
134
227
  const origins = new Set();
135
228
  let plainHost;
136
- const routes = new Map(options.routes.map((each) => [`${each.method} ${each.path}`, each]));
229
+ const contract = options.routes.filter((each) => !("raw" in each));
230
+ const raws = options.routes.filter((each) => "raw" in each);
231
+ const routes = new Map(contract.map((each) => [`${each.method} ${each.path}`, each]));
137
232
  const manifest = JSON.stringify(options.manifest);
233
+ // A raw route can't take over the login, the socket or the manifest, or shadow another route.
234
+ for (const each of raws) {
235
+ const prefix = each.path.endsWith("/*") ? each.path.slice(0, -1) : undefined;
236
+ const covers = (path) => prefix === undefined
237
+ ? path === each.path
238
+ : path.startsWith(prefix) || path === prefix.slice(0, -1) || prefix === "/";
239
+ if (each.path.startsWith("/auth/") ||
240
+ covers("/auth/login") ||
241
+ covers("/manifest.webmanifest") ||
242
+ (options.socket !== undefined && covers(options.socket.path)) ||
243
+ contract.some((other) => covers(other.path))) {
244
+ throw new Error(`a raw route can't serve ${each.path}`);
245
+ }
246
+ }
247
+ if (new Set(raws.map((each) => `${each.method} ${each.path}`)).size !== raws.length) {
248
+ throw new Error("two raw routes have the same method and path");
249
+ }
250
+ /** The raw routes for a path, most specific first, with what a `/*` matched. */
251
+ function rawFor(path) {
252
+ // Matched on the decoded path, so a prefix's remainder is decoded exactly once.
253
+ return raws
254
+ .flatMap((each) => {
255
+ if (each.path === path) {
256
+ return [{ found: each, params: undefined, rank: Number.MAX_SAFE_INTEGER }];
257
+ }
258
+ const prefix = each.path.endsWith("/*") ? each.path.slice(0, -1) : undefined;
259
+ return prefix !== undefined && path.startsWith(prefix)
260
+ ? [{ found: each, params: path.slice(prefix.length), rank: prefix.length }]
261
+ : [];
262
+ })
263
+ .toSorted((a, b) => b.rank - a.rank);
264
+ }
138
265
  /** The caller, or undefined when not signed in, or false when signed in but not allowed. */
139
266
  async function identify(request) {
267
+ const authorization = header(request, "authorization");
268
+ // Any Authorization is a bearer's claim: one that isn't a well-formed bearer is refused, never ignored.
269
+ const bearer = authorization === undefined ? undefined : (/^Bearer (\S+)$/i.exec(authorization)?.[1] ?? "");
140
270
  const jwt = header(request, "cf-access-jwt-assertion");
141
- const identities = await auth.identify(jwt, sessionToken(request, cookie));
271
+ const identities = await auth.identify(jwt, sessionToken(request, cookie), bearer);
142
272
  if (identities === undefined || identities === false) {
143
273
  return identities;
144
274
  }
145
275
  return {
146
276
  identities,
147
- person: !identities.some((identity) => identity.startsWith("service:")),
148
- login: jwt === undefined ? "oidc" : "access",
277
+ person: !identities.some((identity) => identity.startsWith("service:") || identity.startsWith("key:")),
278
+ login: bearer === undefined ? (jwt === undefined ? "oidc" : "access") : "key",
149
279
  request: randomBytes(6).toString("base64url"),
150
280
  };
151
281
  }
282
+ /** Whether a request may act: a key always (it isn't sent on its own); a cookie or proxy only from the page. */
283
+ const fromPage = (request, caller) => caller.login === "key" || origins.has(header(request, "origin") ?? "");
284
+ /** Log one request's outcome, as every route does. */
285
+ function logged(caller, method, path, status, started) {
286
+ log(status < 400 ? "info" : "warn", "request", {
287
+ request: caller.request,
288
+ method,
289
+ path,
290
+ status,
291
+ ms: Math.round(performance.now() - started),
292
+ person: caller.person,
293
+ ...(caller.login === "key" ? { caller: caller.identities.join(" ") } : {}),
294
+ });
295
+ }
296
+ async function raw(request, response, url, caller, candidates) {
297
+ const started = performance.now();
298
+ const method = request.method === "HEAD" ? "GET" : (request.method ?? "GET");
299
+ const match = candidates.find((each) => each.found.method === method);
300
+ let status = 200;
301
+ try {
302
+ if (match === undefined) {
303
+ status = 405;
304
+ send(request, response, 405, "Method not allowed");
305
+ logged(caller, method, url.pathname, status, started);
306
+ return;
307
+ }
308
+ const { found, params } = match;
309
+ if (method === "POST" && !fromPage(request, caller)) {
310
+ throw new Refusal("forbidden", "bad origin");
311
+ }
312
+ if (found.person && !caller.person) {
313
+ throw new Refusal("forbidden", "only a person's login can do this");
314
+ }
315
+ const body = method === "POST" ? await readBytes(request, found.bodyLimit) : undefined;
316
+ const reply = await found.handle({
317
+ url,
318
+ params: params === undefined ? {} : { "*": params },
319
+ body,
320
+ headers: request.headers,
321
+ }, caller);
322
+ status = reply.status;
323
+ const offered = Object.entries(reply.headers ?? {}).find(([name]) => name.toLowerCase() === "content-security-policy")?.[1];
324
+ const own = Object.fromEntries(Object.entries(reply.headers ?? {}).filter(([name]) => !(name.toLowerCase() in SECURITY) &&
325
+ !["content-type", "content-encoding", "cache-control", "vary"].includes(name.toLowerCase())));
326
+ send(request, response, reply.status, reply.body, {
327
+ ...own,
328
+ "content-type": reply.type,
329
+ "cache-control": reply.cache === "immutable" ? "private, max-age=31536000, immutable" : "no-store",
330
+ "content-security-policy": tightened(SECURITY["content-security-policy"], offered),
331
+ });
332
+ }
333
+ catch (error) {
334
+ const kind = error instanceof Refusal ? error.kind : "unavailable";
335
+ status = STATUS[kind];
336
+ if (!(error instanceof Refusal)) {
337
+ log("error", "handler failed", {
338
+ request: caller.request,
339
+ path: url.pathname,
340
+ reason: error instanceof Error ? error.message : String(error),
341
+ });
342
+ }
343
+ failure(request, response, kind, error instanceof Refusal ? error.message : "the server failed; see its log");
344
+ }
345
+ logged(caller, method, url.pathname, status, started);
346
+ }
152
347
  async function asset(request, response, path) {
153
348
  const relative = normalize(decodeURIComponent(path)).replace(/^[/\\]+/, "");
154
349
  if (relative.split(sep).includes("..")) {
@@ -196,7 +391,7 @@ export async function serveWeb(options) {
196
391
  throw new Refusal("invalid", `no ${method} ${url.pathname}`);
197
392
  }
198
393
  // A page from anywhere else must never act with this browser's credentials.
199
- if (method === "POST" && !origins.has(header(request, "origin") ?? "")) {
394
+ if (method === "POST" && !fromPage(request, caller)) {
200
395
  throw new Refusal("forbidden", "bad origin");
201
396
  }
202
397
  if (found.person && !caller.person) {
@@ -204,7 +399,7 @@ export async function serveWeb(options) {
204
399
  }
205
400
  let input;
206
401
  if (method === "POST") {
207
- input = await readBody(request, options.bodyLimit ?? 65_536);
402
+ input = await readBody(request, found.bodyLimit ?? options.bodyLimit ?? 65_536);
208
403
  }
209
404
  else {
210
405
  const q = url.searchParams.get("q");
@@ -231,14 +426,7 @@ export async function serveWeb(options) {
231
426
  }
232
427
  failure(request, response, kind, error instanceof Refusal ? message : "the server failed; see its log", error instanceof Refusal ? error.path : undefined);
233
428
  }
234
- log(status < 400 ? "info" : "warn", "request", {
235
- request: caller.request,
236
- method,
237
- path: url.pathname,
238
- status,
239
- ms: Math.round(performance.now() - started),
240
- person: caller.person,
241
- });
429
+ logged(caller, method, url.pathname, status, started);
242
430
  }
243
431
  /** The sockets each sign-in session opened, so signing out closes them. */
244
432
  const signedIn = new Map();
@@ -255,14 +443,21 @@ export async function serveWeb(options) {
255
443
  if (!auth.oidc) {
256
444
  return send(request, response, 404, "No login here");
257
445
  }
258
- return send(request, response, 302, "", { location: await auth.loginUrl(callback) });
446
+ const back = onThisServer(url.searchParams.get("back"));
447
+ return send(request, response, 302, "", {
448
+ location: await auth.loginUrl(callback),
449
+ // Where the sign-in returns, for the callback; only a path on this server is kept.
450
+ "set-cookie": `${cookie}_back=${back === undefined ? "" : encodeURIComponent(back)}; Path=/auth; HttpOnly;${secure} SameSite=Lax; Max-Age=${back === undefined ? 0 : BACK_SECONDS}`,
451
+ });
259
452
  }
260
453
  if (url.pathname === "/auth/callback" && request.method === "GET") {
261
454
  try {
262
455
  const token = await auth.callback(url.searchParams.get("state") ?? "", url.searchParams.get("code") ?? "");
263
456
  log("info", "signed in");
457
+ // Every sign-in starts at /auth/login, which sets or clears this, so it is never an older one's.
458
+ const back = onThisServer(decodeCookie(sessionToken(request, `${cookie}_back`)));
264
459
  return send(request, response, 302, "", {
265
- location: "/",
460
+ location: back ?? "/",
266
461
  "set-cookie": `${cookie}=${token}; Path=/; HttpOnly;${secure} SameSite=Lax; Max-Age=${7 * 86_400}`,
267
462
  });
268
463
  }
@@ -292,10 +487,23 @@ export async function serveWeb(options) {
292
487
  log("warn", caller === false ? "refused" : "not signed in", { path: url.pathname });
293
488
  const navigation = request.method === "GET" && (header(request, "accept") ?? "").includes("text/html");
294
489
  if (caller === undefined && navigation && auth.oidc) {
295
- return send(request, response, 302, "", { location: "/auth/login" });
490
+ return send(request, response, 302, "", {
491
+ location: `/auth/login?back=${encodeURIComponent(url.pathname + url.search)}`,
492
+ });
296
493
  }
297
494
  return failure(request, response, caller === false ? "forbidden" : "signed-out", caller === false ? "not allowed" : "sign in first");
298
495
  }
496
+ let decoded;
497
+ try {
498
+ decoded = decodeURIComponent(url.pathname);
499
+ }
500
+ catch {
501
+ return send(request, response, 400, "Bad path");
502
+ }
503
+ const candidates = rawFor(decoded);
504
+ if (candidates.length > 0) {
505
+ return await raw(request, response, url, caller, candidates);
506
+ }
299
507
  if (url.pathname.startsWith("/api/")) {
300
508
  return await api(request, response, url, caller);
301
509
  }
@@ -328,13 +536,13 @@ export async function serveWeb(options) {
328
536
  new URL(request.url ?? "/", "http://localhost").pathname !== options.socket.path) {
329
537
  return refuse(404);
330
538
  }
331
- if (!origins.has(header(request, "origin") ?? "")) {
332
- return refuse(403);
333
- }
334
539
  const caller = await identify(request);
335
540
  if (caller === undefined || caller === false) {
336
541
  return refuse(caller === false ? 403 : 401);
337
542
  }
543
+ if (!fromPage(request, caller)) {
544
+ return refuse(403);
545
+ }
338
546
  const open = options.socket.open;
339
547
  sockets.handleUpgrade(request, stream, head, (socket) => {
340
548
  socket.on("error", () => {
package/dist/states.d.ts CHANGED
@@ -77,25 +77,59 @@ export declare const LOOKS: {
77
77
  * history. `log`: nothing to act on or misread.
78
78
  */
79
79
  export type Place = "main" | "badge" | "panel" | "log";
80
- type Placed = {
80
+ /**
81
+ * The work a `working` state waits on, so it can be checked to be under way (`slopkit/test/states` `viewProblems`): the
82
+ * server's job of a kind and key, a command in the page's outbox, or the page's own copy (loading, pulling, pushing,
83
+ * writing). A working state that owes nothing of the app's (the world is at work: a parcel in transit) says `owes: null`.
84
+ */
85
+ export type Owed = {
86
+ readonly job: {
87
+ readonly kind: string;
88
+ readonly key: string;
89
+ };
90
+ } | {
91
+ readonly command: {
92
+ readonly client: string;
93
+ readonly id: number;
94
+ };
95
+ } | {
96
+ readonly sync: true;
97
+ };
98
+ type Others = Exclude<Meaning, "ok" | "partial" | "stale" | "working">;
99
+ type Placed<S> = {
81
100
  readonly meaning: "ok";
82
101
  readonly where: Place;
83
102
  readonly claim: false;
103
+ readonly owes?: never;
84
104
  } | {
85
105
  readonly meaning: "partial" | "stale";
86
106
  readonly where: "main";
87
107
  readonly claim: true;
108
+ readonly owes?: never;
88
109
  } | {
89
- readonly meaning: Exclude<Meaning, "ok" | "partial" | "stale">;
110
+ readonly meaning: Others;
90
111
  readonly where: "main";
91
112
  readonly claim: boolean;
113
+ readonly owes?: never;
92
114
  } | {
93
- readonly meaning: Exclude<Meaning, "ok" | "partial" | "stale">;
115
+ readonly meaning: Others;
94
116
  readonly where: "badge";
95
117
  readonly claim: false;
118
+ readonly owes?: never;
119
+ } | {
120
+ readonly meaning: "working";
121
+ readonly where: "main";
122
+ readonly claim: boolean;
123
+ /** What is at work: checked to be under way whenever this is drawn. Required. */
124
+ readonly owes: ((state: S) => Owed) | null;
125
+ } | {
126
+ readonly meaning: "working";
127
+ readonly where: "badge";
128
+ readonly claim: false;
129
+ readonly owes: ((state: S) => Owed) | null;
96
130
  };
97
131
  /** One state's display: what it means, where and why it is drawn, its words, and what the person can do. */
98
- export type Display<S, A> = Placed & {
132
+ export type Display<S, A> = Placed<S> & {
99
133
  /** Why it is drawn there, or not at all. Required. */
100
134
  readonly why: string;
101
135
  readonly text: (state: S) => string;
@@ -109,9 +143,20 @@ export type Table<S extends {
109
143
  kind: K;
110
144
  }>, A>;
111
145
  };
146
+ /**
147
+ * Brands a Line and a Screen carry, so a check holding one copy of this module still knows another copy's (a fresh
148
+ * module graph for each page load in a test, two installed versions).
149
+ */
150
+ declare const LINE: unique symbol;
151
+ declare const SCREEN: unique symbol;
152
+ /** Whether a value is a Line, made by any copy of this module. */
153
+ export declare function isLine(value: unknown): value is Line<unknown>;
154
+ /** Whether a value is a Screen, made by any copy of this module. */
155
+ export declare function isScreen(value: unknown): value is Screen<unknown>;
112
156
  /** A drawn state: only a subject makes one, from its table. */
113
157
  export declare class Line<A> {
114
158
  #private;
159
+ readonly [LINE] = true;
115
160
  readonly subject: string;
116
161
  readonly kind: string;
117
162
  readonly meaning: Meaning;
@@ -121,11 +166,14 @@ export declare class Line<A> {
121
166
  readonly tone: Tone;
122
167
  readonly text: string;
123
168
  readonly action: A | null;
169
+ /** The work a working state waits on (its table's `owes`), or null. */
170
+ readonly owed: Owed | null;
124
171
  private constructor();
125
172
  }
126
173
  /** A screen's summary of all its parts: only `compose` makes one. */
127
174
  export declare class Screen<A> {
128
175
  #private;
176
+ readonly [SCREEN] = true;
129
177
  /** Every part is ok. */
130
178
  readonly ok: boolean;
131
179
  /** The status line: the first part that isn't ok (by placement, severity, order), or the ok text; `more` counts the rest. */
package/dist/states.js CHANGED
@@ -45,6 +45,20 @@ export const LOOKS = {
45
45
  ours: { glyph: "✕", tone: "warning" },
46
46
  gone: { glyph: "⊘", tone: "muted" },
47
47
  };
48
+ /**
49
+ * Brands a Line and a Screen carry, so a check holding one copy of this module still knows another copy's (a fresh
50
+ * module graph for each page load in a test, two installed versions).
51
+ */
52
+ const LINE = Symbol.for("slopkit.states.Line");
53
+ const SCREEN = Symbol.for("slopkit.states.Screen");
54
+ /** Whether a value is a Line, made by any copy of this module. */
55
+ export function isLine(value) {
56
+ return typeof value === "object" && value !== null && LINE in value;
57
+ }
58
+ /** Whether a value is a Screen, made by any copy of this module. */
59
+ export function isScreen(value) {
60
+ return typeof value === "object" && value !== null && SCREEN in value;
61
+ }
48
62
  // Set by the classes' static blocks: nothing outside this module can make a Line or a Screen.
49
63
  let makeLine;
50
64
  let makeScreen;
@@ -53,6 +67,7 @@ export class Line {
53
67
  // A private field makes the type nominal: no object literal or spread of a Line is one.
54
68
  // oxlint-disable-next-line no-unused-private-class-members -- its presence is its use
55
69
  #made = true;
70
+ [LINE] = true;
56
71
  subject;
57
72
  kind;
58
73
  meaning;
@@ -62,6 +77,8 @@ export class Line {
62
77
  tone;
63
78
  text;
64
79
  action;
80
+ /** The work a working state waits on (its table's `owes`), or null. */
81
+ owed;
65
82
  constructor(fields) {
66
83
  this.subject = fields.subject;
67
84
  this.kind = fields.kind;
@@ -72,6 +89,7 @@ export class Line {
72
89
  this.tone = fields.tone;
73
90
  this.text = fields.text;
74
91
  this.action = fields.action;
92
+ this.owed = fields.owed;
75
93
  }
76
94
  static {
77
95
  makeLine = (fields) => new Line(fields);
@@ -81,6 +99,7 @@ export class Line {
81
99
  export class Screen {
82
100
  // oxlint-disable-next-line no-unused-private-class-members -- nominal, as Line's
83
101
  #made = true;
102
+ [SCREEN] = true;
84
103
  /** Every part is ok. */
85
104
  ok;
86
105
  /** The status line: the first part that isn't ok (by placement, severity, order), or the ok text; `more` counts the rest. */
@@ -185,6 +204,7 @@ export function states(options) {
185
204
  tone: look.tone,
186
205
  text,
187
206
  action: display.action,
207
+ owed: display.meaning === "working" && display.owes !== null ? display.owes(state) : null,
188
208
  });
189
209
  },
190
210
  };
package/dist/sync.d.ts CHANGED
@@ -317,6 +317,8 @@ export type Msg<T, P, S, C, O> = {
317
317
  replica: Replica<T, S>;
318
318
  commands: Pending<C, O>[];
319
319
  unreadable: number;
320
+ /** The copy owes a real reset before it is trusted (lifted from an earlier release's row shapes). */
321
+ restart?: "paged";
320
322
  now: number;
321
323
  } | {
322
324
  type: "loadFailed";
package/dist/sync.js CHANGED
@@ -635,6 +635,7 @@ export function createSync(spec) {
635
635
  replica: msg.replica,
636
636
  saved: { ...at.saved, dataset: msg.replica.dataset, revision: msg.replica.revision },
637
637
  target: Math.max(at.target, msg.replica.revision),
638
+ restart: msg.restart ?? at.restart,
638
639
  commands: [...resumed, ...at.commands],
639
640
  unreadable: msg.unreadable,
640
641
  },
@@ -0,0 +1,19 @@
1
+ import type { Endpoint } from "../contract.ts";
2
+ export type AgentCase = {
3
+ name: string;
4
+ check: (base: string, token: string) => Promise<string[]>;
5
+ };
6
+ /** A call an agent's key makes and gets answered. */
7
+ export declare function reads<Req, Res>(endpoint: Endpoint<Req, Res>, input: Req, name?: string): AgentCase;
8
+ /** A call only the person may make: the key gets `forbidden`, or an answer `refusal` judges a refusal. */
9
+ export declare function refused<Req, Res>(endpoint: Endpoint<Req, Res>, input: Req, options?: {
10
+ name?: string;
11
+ refusal?: (reply: Res) => boolean;
12
+ }): AgentCase;
13
+ /** Run every case against the app at `url` with an allowed key's `token`; throws listing every failure. */
14
+ export declare function agentAccess(options: {
15
+ url: string;
16
+ token: string;
17
+ reads: readonly AgentCase[];
18
+ refused: readonly AgentCase[];
19
+ }): Promise<void>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Agent access, checked on an app's real server: the app lists what its agents read with a key and what only the
3
+ * person may do, and `agentAccess` proves over HTTP, as an agent calls it (a bearer key, no Origin, no login), that:
4
+ *
5
+ * - the key reads each `reads` case, and the reply is the contract's;
6
+ * - none of those reads is public: without credentials it's `signed-out`, with an unknown key `forbidden`;
7
+ * - the key is refused each `refused` case: `forbidden`, or a reply the case judges a refusal (a sync push answers
8
+ * each command it refuses with a receipt).
9
+ *
10
+ * Every failure is collected and thrown together, so one run shows the whole gap.
11
+ */
12
+ import { call } from "../contract.js";
13
+ const bearer = (token) => ({ authorization: `Bearer ${token}` });
14
+ const said = (result) => result.ok ? "an answer" : `${result.failure.kind}: ${result.failure.message}`;
15
+ /** A call an agent's key makes and gets answered. */
16
+ export function reads(endpoint, input, name) {
17
+ const named = name ?? `${endpoint.method} ${endpoint.path}`;
18
+ return {
19
+ name: named,
20
+ check: async (base, token) => {
21
+ const failures = [];
22
+ const read = await call(endpoint, input, { base, headers: bearer(token) });
23
+ if (!read.ok) {
24
+ failures.push(`${named}: a key should read it, got ${said(read)}`);
25
+ }
26
+ const anonymous = await call(endpoint, input, { base });
27
+ if (anonymous.ok || anonymous.failure.kind !== "signed-out") {
28
+ failures.push(`${named}: without credentials it should be signed-out, got ${said(anonymous)}`);
29
+ }
30
+ const unknown = await call(endpoint, input, { base, headers: bearer("nobody.not-a-key") });
31
+ if (unknown.ok || unknown.failure.kind !== "forbidden") {
32
+ failures.push(`${named}: an unknown key should be forbidden, got ${said(unknown)}`);
33
+ }
34
+ return failures;
35
+ },
36
+ };
37
+ }
38
+ /** A call only the person may make: the key gets `forbidden`, or an answer `refusal` judges a refusal. */
39
+ export function refused(endpoint, input, options = {}) {
40
+ const named = options.name ?? `${endpoint.method} ${endpoint.path}`;
41
+ return {
42
+ name: named,
43
+ check: async (base, token) => {
44
+ const result = await call(endpoint, input, { base, headers: bearer(token) });
45
+ const refusal = result.ok
46
+ ? options.refusal?.(result.value) === true
47
+ : result.failure.kind === "forbidden";
48
+ return refusal ? [] : [`${named}: a key should be refused, got ${said(result)}`];
49
+ },
50
+ };
51
+ }
52
+ /** Run every case against the app at `url` with an allowed key's `token`; throws listing every failure. */
53
+ export async function agentAccess(options) {
54
+ if (options.reads.length === 0 || options.refused.length === 0) {
55
+ throw new Error("agentAccess: name at least one read and one refused call, or it proves nothing");
56
+ }
57
+ const base = options.url.replace(/\/$/, "");
58
+ const failures = (await Promise.all([...options.reads, ...options.refused].map((each) => each.check(base, options.token)))).flat();
59
+ if (failures.length > 0) {
60
+ throw new Error(`agent access:\n${failures.map((line) => `- ${line}`).join("\n")}`);
61
+ }
62
+ }