slopkit 0.2.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.
Files changed (52) hide show
  1. package/DEPS.md +5 -3
  2. package/README.md +103 -1
  3. package/RULES.md +26 -6
  4. package/dist/api.d.ts +93 -0
  5. package/dist/api.js +111 -0
  6. package/dist/client.d.ts +21 -4
  7. package/dist/client.js +21 -2
  8. package/dist/conformance/agent-access.test.d.ts +1 -0
  9. package/dist/conformance/agent-access.test.js +74 -0
  10. package/dist/conformance/client.test.js +7 -7
  11. package/dist/conformance/fixtures.d.ts +1 -0
  12. package/dist/conformance/fixtures.js +5 -1
  13. package/dist/conformance/outbox-bound.test.d.ts +1 -0
  14. package/dist/conformance/outbox-bound.test.js +268 -0
  15. package/dist/conformance/paging-state.test.js +1 -0
  16. package/dist/conformance/push-bytes.test.d.ts +1 -0
  17. package/dist/conformance/push-bytes.test.js +174 -0
  18. package/dist/conformance/push-refusal.test.d.ts +1 -0
  19. package/dist/conformance/push-refusal.test.js +644 -0
  20. package/dist/conformance/states.test.js +19 -8
  21. package/dist/contract.d.ts +19 -6
  22. package/dist/contract.js +76 -9
  23. package/dist/server/auth.d.ts +4 -3
  24. package/dist/server/auth.js +14 -6
  25. package/dist/server/cli.d.ts +117 -0
  26. package/dist/server/cli.js +1534 -0
  27. package/dist/server/config.d.ts +4 -1
  28. package/dist/server/config.js +2 -2
  29. package/dist/server/keys.d.ts +17 -0
  30. package/dist/server/keys.js +127 -0
  31. package/dist/server/pull.js +158 -67
  32. package/dist/server/store.d.ts +101 -0
  33. package/dist/server/store.js +347 -0
  34. package/dist/server/sync.d.ts +158 -0
  35. package/dist/server/sync.js +293 -0
  36. package/dist/server/web.d.ts +62 -9
  37. package/dist/server/web.js +260 -35
  38. package/dist/states.d.ts +52 -4
  39. package/dist/states.js +20 -0
  40. package/dist/sync.d.ts +86 -2
  41. package/dist/sync.js +277 -59
  42. package/dist/test/agents.d.ts +19 -0
  43. package/dist/test/agents.js +62 -0
  44. package/dist/test/page.d.ts +91 -0
  45. package/dist/test/page.js +763 -0
  46. package/dist/test/states.d.ts +44 -7
  47. package/dist/test/states.js +152 -0
  48. package/dist/web/replica.d.ts +7 -0
  49. package/dist/web/replica.js +260 -47
  50. package/dist/web/runtime.d.ts +102 -0
  51. package/dist/web/runtime.js +335 -0
  52. package/package.json +39 -1
@@ -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
- * after, so neither side can drift from the contract. Failures answer `{ kind, message }` with the kind's status.
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
  */
@@ -32,19 +36,32 @@ const SECURITY = {
32
36
  "x-frame-options": "DENY",
33
37
  "content-security-policy": "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; manifest-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
34
38
  };
35
- /** A handler's refusal, answered as a typed failure. */
39
+ /**
40
+ * A handler's refusal, answered as a typed failure: the kit's error body, so the page can tell it from a proxy's.
41
+ * `path` names where a request broke its schema (`request.commands[1].command.value`).
42
+ */
36
43
  export class Refusal extends Error {
37
44
  kind;
38
- constructor(kind, message) {
45
+ path;
46
+ constructor(kind, message, path) {
39
47
  super(message);
40
48
  this.kind = kind;
49
+ this.path = path;
41
50
  }
42
51
  }
43
- 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 = {}) {
44
60
  return {
45
61
  method: endpoint.method,
46
62
  path: endpoint.path,
47
63
  person: endpoint.person,
64
+ ...(options.bodyLimit === undefined ? {} : { bodyLimit: bodyBytes(options.bodyLimit) }),
48
65
  run: async (input, caller) => {
49
66
  let request;
50
67
  try {
@@ -52,7 +69,7 @@ export function route(endpoint, handle) {
52
69
  }
53
70
  catch (error) {
54
71
  if (error instanceof Invalid) {
55
- throw new Refusal("invalid", error.message);
72
+ throw new Refusal("invalid", error.message, error.path);
56
73
  }
57
74
  throw error;
58
75
  }
@@ -61,6 +78,53 @@ export function route(endpoint, handle) {
61
78
  },
62
79
  };
63
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
+ }
64
128
  function header(request, name) {
65
129
  const value = request.headers[name];
66
130
  return Array.isArray(value) ? value[0] : value;
@@ -72,11 +136,36 @@ function sessionToken(request, cookie) {
72
136
  .find((part) => part.startsWith(`${cookie}=`))
73
137
  ?.slice(cookie.length + 1);
74
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
+ }
75
161
  /** Send a body, gzipped when the client takes it and it is worth it. */
76
162
  function send(request, response, status, body, headers = {}) {
77
163
  let bytes = typeof body === "string" ? Buffer.from(body) : body;
78
164
  const extra = {};
79
- 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") ?? "")) {
80
169
  bytes = gzipSync(bytes, { level: 6 });
81
170
  extra["content-encoding"] = "gzip";
82
171
  extra.vary = "accept-encoding";
@@ -90,18 +179,39 @@ function send(request, response, status, body, headers = {}) {
90
179
  });
91
180
  response.end(request.method === "HEAD" ? undefined : bytes);
92
181
  }
93
- function failure(request, response, kind, message) {
94
- send(request, response, STATUS[kind], JSON.stringify({ kind, message }), {
95
- "content-type": "application/json",
96
- });
182
+ function failure(request, response, kind, message, path) {
183
+ send(request, response, STATUS[kind], JSON.stringify({ kind, message, ...(path === undefined ? {} : { path }) }), { "content-type": "application/json" });
97
184
  }
98
- async function readBody(request, limit) {
99
- let text = "";
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
+ }
191
+ const chunks = [];
192
+ let size = 0;
100
193
  for await (const chunk of request) {
101
- text += String(chunk);
102
- if (text.length > limit) {
194
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
195
+ size += bytes.length;
196
+ if (size > limit) {
103
197
  throw new Refusal("invalid", "the request is too large");
104
198
  }
199
+ chunks.push(bytes);
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);
209
+ let text;
210
+ try {
211
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
212
+ }
213
+ catch {
214
+ throw new Refusal("invalid", "the request is not UTF-8");
105
215
  }
106
216
  try {
107
217
  return text ? JSON.parse(text) : {};
@@ -116,22 +226,124 @@ export async function serveWeb(options) {
116
226
  const hosts = new Set();
117
227
  const origins = new Set();
118
228
  let plainHost;
119
- 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]));
120
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
+ }
121
265
  /** The caller, or undefined when not signed in, or false when signed in but not allowed. */
122
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] ?? "");
123
270
  const jwt = header(request, "cf-access-jwt-assertion");
124
- const identities = await auth.identify(jwt, sessionToken(request, cookie));
271
+ const identities = await auth.identify(jwt, sessionToken(request, cookie), bearer);
125
272
  if (identities === undefined || identities === false) {
126
273
  return identities;
127
274
  }
128
275
  return {
129
276
  identities,
130
- person: !identities.some((identity) => identity.startsWith("service:")),
131
- 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",
132
279
  request: randomBytes(6).toString("base64url"),
133
280
  };
134
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
+ }
135
347
  async function asset(request, response, path) {
136
348
  const relative = normalize(decodeURIComponent(path)).replace(/^[/\\]+/, "");
137
349
  if (relative.split(sep).includes("..")) {
@@ -179,7 +391,7 @@ export async function serveWeb(options) {
179
391
  throw new Refusal("invalid", `no ${method} ${url.pathname}`);
180
392
  }
181
393
  // A page from anywhere else must never act with this browser's credentials.
182
- if (method === "POST" && !origins.has(header(request, "origin") ?? "")) {
394
+ if (method === "POST" && !fromPage(request, caller)) {
183
395
  throw new Refusal("forbidden", "bad origin");
184
396
  }
185
397
  if (found.person && !caller.person) {
@@ -187,7 +399,7 @@ export async function serveWeb(options) {
187
399
  }
188
400
  let input;
189
401
  if (method === "POST") {
190
- input = await readBody(request, options.bodyLimit ?? 65_536);
402
+ input = await readBody(request, found.bodyLimit ?? options.bodyLimit ?? 65_536);
191
403
  }
192
404
  else {
193
405
  const q = url.searchParams.get("q");
@@ -212,16 +424,9 @@ export async function serveWeb(options) {
212
424
  reason: message,
213
425
  });
214
426
  }
215
- failure(request, response, kind, error instanceof Refusal ? message : "the server failed; see its log");
427
+ failure(request, response, kind, error instanceof Refusal ? message : "the server failed; see its log", error instanceof Refusal ? error.path : undefined);
216
428
  }
217
- log(status < 400 ? "info" : "warn", "request", {
218
- request: caller.request,
219
- method,
220
- path: url.pathname,
221
- status,
222
- ms: Math.round(performance.now() - started),
223
- person: caller.person,
224
- });
429
+ logged(caller, method, url.pathname, status, started);
225
430
  }
226
431
  /** The sockets each sign-in session opened, so signing out closes them. */
227
432
  const signedIn = new Map();
@@ -238,14 +443,21 @@ export async function serveWeb(options) {
238
443
  if (!auth.oidc) {
239
444
  return send(request, response, 404, "No login here");
240
445
  }
241
- 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
+ });
242
452
  }
243
453
  if (url.pathname === "/auth/callback" && request.method === "GET") {
244
454
  try {
245
455
  const token = await auth.callback(url.searchParams.get("state") ?? "", url.searchParams.get("code") ?? "");
246
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`)));
247
459
  return send(request, response, 302, "", {
248
- location: "/",
460
+ location: back ?? "/",
249
461
  "set-cookie": `${cookie}=${token}; Path=/; HttpOnly;${secure} SameSite=Lax; Max-Age=${7 * 86_400}`,
250
462
  });
251
463
  }
@@ -275,10 +487,23 @@ export async function serveWeb(options) {
275
487
  log("warn", caller === false ? "refused" : "not signed in", { path: url.pathname });
276
488
  const navigation = request.method === "GET" && (header(request, "accept") ?? "").includes("text/html");
277
489
  if (caller === undefined && navigation && auth.oidc) {
278
- 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
+ });
279
493
  }
280
494
  return failure(request, response, caller === false ? "forbidden" : "signed-out", caller === false ? "not allowed" : "sign in first");
281
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
+ }
282
507
  if (url.pathname.startsWith("/api/")) {
283
508
  return await api(request, response, url, caller);
284
509
  }
@@ -311,13 +536,13 @@ export async function serveWeb(options) {
311
536
  new URL(request.url ?? "/", "http://localhost").pathname !== options.socket.path) {
312
537
  return refuse(404);
313
538
  }
314
- if (!origins.has(header(request, "origin") ?? "")) {
315
- return refuse(403);
316
- }
317
539
  const caller = await identify(request);
318
540
  if (caller === undefined || caller === false) {
319
541
  return refuse(caller === false ? 403 : 401);
320
542
  }
543
+ if (!fromPage(request, caller)) {
544
+ return refuse(403);
545
+ }
321
546
  const open = options.socket.open;
322
547
  sockets.handleUpgrade(request, stream, head, (socket) => {
323
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
  };