@uniflowed/router 0.1.0 → 0.3.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.
@@ -55,16 +55,46 @@
55
55
  // header for what is excluded and why.
56
56
  //
57
57
  // A submitted form is inside that boundary and does not widen it: `<form
58
- // action={fn}>` reaches here as the same `application/json` body, with the
59
- // form's entries beside the values under a `form` key, bounded in count and in
60
- // field-name length and holding strings only. Nothing about it is multipart
61
- // and nothing about it is a content type a cross-origin `<form>` could
62
- // produce, so rule 4 is exactly as true of a form call as of any other. The
63
- // cost of keeping it that way is written down where it is paid — a form that
64
- // submits before its page has hydrated throws in the page rather than posting
65
- // anywhere, because React writes `action="javascript:throw …"` for a form
66
- // whose action carries no `$$FORM_ACTION`, and giving it one would mean
67
- // accepting a native form post here.
58
+ // action={fn}>` on a hydrated page reaches here as the same `application/json`
59
+ // body, with the form's entries beside the values under a `form` key, bounded
60
+ // in count and in field-name length and holding strings only. Nothing about it
61
+ // is multipart and nothing about it is a content type a cross-origin `<form>`
62
+ // could produce, so rule 4 is exactly as true of a form call as of any other.
63
+ //
64
+ // # The second door: a form posted before the page hydrated
65
+ //
66
+ // React's progressive enhancement is a *native* form post, and a native post is
67
+ // the one request rule 4 was written to keep out. So it is not let in through
68
+ // that door. It has its own, narrower than the first, and it is worth being
69
+ // exact about what it keeps of the six rules above:
70
+ //
71
+ // - **Rules 1, 2, 3, 5 and 6 hold unchanged.** The id selects a row and is
72
+ // compared the same way; a failed lookup is the same `404`; the bound
73
+ // arguments and the form cross through `decodeActionArguments`, so the
74
+ // grammar, the counts, the depth and the field limits are the JSON call's
75
+ // own; a throw is the same fixed `500`.
76
+ // - **Rule 4 keeps one of its three guards.** A native post is a simple
77
+ // request, so there is no custom header and no JSON content type to lean on.
78
+ // What is left is `Origin`, which every browser sends on a `POST` and which
79
+ // must equal `Host` exactly as before — and `Sec-Fetch-Site`, which must say
80
+ // `same-origin` when the browser sends it. A cross-site form, a sandboxed
81
+ // frame (`Origin: null`) and a request with no `Origin` are `403`s.
82
+ // - **It accepts one content type**, `application/x-www-form-urlencoded`, which
83
+ // is what `$$FORM_ACTION` asks React to write. Not multipart: a file cannot
84
+ // cross to an action, and a multipart parser is surface uf does not open.
85
+ // - **It is recognised by its fields, read from a copy of the body.** A post
86
+ // that carries no `$uf_ref_` field is somebody else's — an ordinary form to a
87
+ // route handler — and is declined with `null` and its body untouched. So is
88
+ // one larger than an action accepts or not UTF-8, because it cannot be known
89
+ // to be an action post without reading it; it goes on to the route handlers,
90
+ // and a page answers a `POST` with a `404`.
91
+ //
92
+ // The answer is a document rather than JSON, because a person is looking at it:
93
+ // a `303` back to the page for a plain form action, the page rendered again
94
+ // with the action's result as the submitting `useActionState`'s state (the
95
+ // host supplies that render as `postback`), or a `303` to wherever
96
+ // `redirect()` pointed. See `./form-action.js` for the fields and
97
+ // ubugeeei-prod/uf#1358.
68
98
  //
69
99
  // **6. Nothing about a failure goes back.** An action that throws is a `500`
70
100
  // with a fixed body; the exception goes to the host's error reporting. A
@@ -95,6 +125,7 @@ import { asResponder, nativeActionAllowed } from "@uniflowed/server/host";
95
125
  import {
96
126
  ACTION_CONTENT_TYPE,
97
127
  ACTION_HEADER,
128
+ ACTION_OUTCOME_HEADER,
98
129
  type ActionArgument,
99
130
  ActionValueError,
100
131
  MAX_ACTION_BODY_BYTES,
@@ -102,7 +133,30 @@ import {
102
133
  encodeActionResult,
103
134
  isActionId,
104
135
  } from "./action-wire.js";
136
+ import { addressOf } from "./base-path.js";
137
+ import {
138
+ FORM_ACTION_CONTENT_TYPE,
139
+ type FormPost,
140
+ type FormState,
141
+ readFormPost,
142
+ } from "./form-action.js";
105
143
  import { requireRequest } from "./request.js";
144
+ import { ForbiddenError, NotFoundError, RedirectError, UnauthorizedError } from "./routing.js";
145
+
146
+ export type { FormState } from "./form-action.js";
147
+
148
+ /**
149
+ * What a host gives the dispatcher beyond the request.
150
+ *
151
+ * `postback` renders the page the request is for, with a `useActionState`'s
152
+ * result as React's `formState`, and is how a form posted before hydration
153
+ * gets the page back with its answer in it. A host that supplies none still
154
+ * serves native posts: the answer is a `303` back to the page, which runs the
155
+ * action and loses only the state.
156
+ */
157
+ export type ActionDispatchOptions = {|
158
+ readonly postback?: (formState: FormState) => Promise<Response>,
159
+ |};
106
160
 
107
161
  /** A module holding server actions, as the generated table loads it. */
108
162
  export type ActionModule = { readonly [name: string]: mixed };
@@ -146,10 +200,13 @@ const ANSWER_HEADERS: { readonly [string]: string } = {
146
200
  */
147
201
  export function createActionDispatcher(options: {|
148
202
  readonly actions: $ReadOnlyArray<ActionRecord>,
149
- |}): (request: Request) => Promise<Response | null> {
203
+ |}): (request: Request, settings?: ActionDispatchOptions) => Promise<Response | null> {
150
204
  const table = options.actions;
151
205
 
152
- return async function callAction(request: Request): Promise<Response | null> {
206
+ return async function callAction(
207
+ request: Request,
208
+ settings?: ActionDispatchOptions,
209
+ ): Promise<Response | null> {
153
210
  // The host's half of the contract, checked rather than assumed, exactly as
154
211
  // `dispatch` and `runMiddleware` check it: an action that calls `cookies()`
155
212
  // has to answer about the request it is inside.
@@ -157,7 +214,9 @@ export function createActionDispatcher(options: {|
157
214
 
158
215
  const id = request.headers.get(ACTION_HEADER);
159
216
  if (id == null) {
160
- return null;
217
+ // Not a JSON call. It may still be a form posted before its page
218
+ // hydrated, which is the second door in the header.
219
+ return nativeFormPost(table, request, settings?.postback);
161
220
  }
162
221
 
163
222
  // Cheapest and most protective first, and all of it before a byte of the
@@ -236,6 +295,13 @@ export function createActionDispatcher(options: {|
236
295
  const call = action as $FlowFixMe;
237
296
  result = await traceRequestPhase("action", () => call(...args));
238
297
  } catch (error) {
298
+ // `redirect()` and its three siblings are where the visitor goes next,
299
+ // not something that went wrong: answered as themselves, and never
300
+ // reported. See `ACTION_OUTCOME_HEADER`.
301
+ const outcome = routingOutcome(error);
302
+ if (outcome != null) {
303
+ return outcome;
304
+ }
239
305
  report(record, error);
240
306
  return refusal(500);
241
307
  }
@@ -256,6 +322,243 @@ export function createActionDispatcher(options: {|
256
322
  };
257
323
  }
258
324
 
325
+ /**
326
+ * Answer a form the browser posted natively, or decline with `null`.
327
+ *
328
+ * The header's second door. Declines everything that is not a urlencoded
329
+ * `POST` carrying a `$uf_ref_` field, and answers everything that is,
330
+ * refusals included — the same promise the JSON door makes, for the same
331
+ * reason.
332
+ */
333
+ async function nativeFormPost(
334
+ table: $ReadOnlyArray<ActionRecord>,
335
+ request: Request,
336
+ postback: ?(formState: FormState) => Promise<Response>,
337
+ ): Promise<Response | null> {
338
+ if (request.method.toUpperCase() !== "POST") {
339
+ return null;
340
+ }
341
+ if (!isMedia(request.headers.get("content-type"), FORM_ACTION_CONTENT_TYPE)) {
342
+ return null;
343
+ }
344
+ // A copy, so that a form which turns out not to be an action post reaches the
345
+ // route handler with its body unread.
346
+ const text = await readBoundedText(request.clone(), MAX_ACTION_BODY_BYTES);
347
+ if (text == null) {
348
+ return null;
349
+ }
350
+ const post = readFormPost(new URLSearchParams(text));
351
+ if (post == null) {
352
+ return null;
353
+ }
354
+
355
+ // An action post from here on, and every outcome is an answer.
356
+ if (!sameOrigin(request) || !sameSiteFetch(request)) {
357
+ return refusal(403);
358
+ }
359
+
360
+ let args: Array<ActionArgument>;
361
+ let bound: number;
362
+ try {
363
+ ({ args, bound } = formArguments(post));
364
+ } catch (error) {
365
+ if (!(error instanceof ActionValueError)) {
366
+ throw error;
367
+ }
368
+ return refusal(400);
369
+ }
370
+
371
+ const id = post.id;
372
+ const record = id != null && isActionId(id) ? select(table, id) : null;
373
+ if (record == null) {
374
+ return refusal(404);
375
+ }
376
+
377
+ let action: mixed;
378
+ try {
379
+ const module = await record.load();
380
+ action = module[record.export];
381
+ } catch (error) {
382
+ report(record, error);
383
+ return refusal(500);
384
+ }
385
+ if (typeof action !== "function") {
386
+ report(record, new Error(`export \`${record.export}\` is not a function`));
387
+ return refusal(500);
388
+ }
389
+
390
+ const url = new URL(request.url);
391
+ const stateKey = post.stateKey;
392
+ return asResponder("a server action", async () => {
393
+ let result: mixed;
394
+ try {
395
+ const call = action as $FlowFixMe;
396
+ result = await traceRequestPhase("action", () => call(...args));
397
+ } catch (error) {
398
+ if (error instanceof RedirectError) {
399
+ return seeOther(addressOf(error.to));
400
+ }
401
+ // A person is looking at this answer and there is no page to render the
402
+ // boundary into, so it is the status with a fixed line of text — the same
403
+ // three statuses the JSON door answers, and nothing of the exception.
404
+ const status = routingStatus(error);
405
+ if (status != null) {
406
+ return new Response(`${status.text}\n`, {
407
+ status: status.code,
408
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
409
+ });
410
+ }
411
+ report(record, error);
412
+ return refusal(500);
413
+ }
414
+ if (stateKey == null || postback == null) {
415
+ // Post/redirect/get: the page again, by a `GET`, so a reload does not
416
+ // submit the form a second time.
417
+ return seeOther(addressOf(withOneLeadingSlash(url.pathname) + url.search));
418
+ }
419
+ try {
420
+ // Held to the grammar for the reason the JSON door holds a result to it:
421
+ // this value is written into a document a browser parses.
422
+ encodeActionResult(result);
423
+ } catch (error) {
424
+ report(record, error);
425
+ return refusal(500);
426
+ }
427
+ // `bound - 1`: `useActionState` bound the previous state itself, and
428
+ // React compares the count of the bindings the *action* had.
429
+ return postback([result, stateKey, record.id, bound - 1]);
430
+ });
431
+ }
432
+
433
+ /**
434
+ * The arguments a native post calls its action with: the bound ones, then
435
+ * the form.
436
+ *
437
+ * Built into the JSON call's own envelope and decoded by the JSON call's own
438
+ * decoder, so there is one grammar and one set of limits, not a second one for
439
+ * forms that could drift from the first.
440
+ */
441
+ function formArguments(post: FormPost): {|
442
+ readonly args: Array<ActionArgument>,
443
+ readonly bound: number,
444
+ |} {
445
+ let bound: Array<ActionArgument> = [];
446
+ if (post.bound != null) {
447
+ bound = decodeActionArguments(post.bound);
448
+ if (bound.some((value) => value instanceof FormData)) {
449
+ throw new ActionValueError("the bound arguments", "carry a form");
450
+ }
451
+ }
452
+ const envelope = JSON.stringify({
453
+ args: [...bound, null],
454
+ form: { at: bound.length, entries: post.entries },
455
+ });
456
+ return { args: decodeActionArguments(envelope), bound: bound.length };
457
+ }
458
+
459
+ /**
460
+ * Whether the browser says the request came from this origin, when it says.
461
+ *
462
+ * `Sec-Fetch-Site` is a forbidden header name, so a page cannot set it; a
463
+ * browser that sends it and says anything but `same-origin` is describing a
464
+ * cross-site form. Absent is not a refusal on its own — older browsers do not
465
+ * send it, and `Origin` has already been required.
466
+ */
467
+ function sameSiteFetch(request: Request): boolean {
468
+ const site = request.headers.get("sec-fetch-site");
469
+ return site == null || site === "same-origin";
470
+ }
471
+
472
+ /** The kind, status and text of a routing error other than a redirect. */
473
+ type RoutingStatus = {|
474
+ readonly kind: "not-found" | "unauthorized" | "forbidden",
475
+ readonly code: 404 | 401 | 403,
476
+ readonly text: string,
477
+ |};
478
+
479
+ /**
480
+ * What `notFound()`, `unauthorized()` or `forbidden()` answers with, or `null`
481
+ * for anything else.
482
+ *
483
+ * The statuses are the ones a page gets for the same call (`routeErrorStatus`
484
+ * and the not-found page), so a client that reads only the status reads the
485
+ * same thing a document request would have told it.
486
+ */
487
+ function routingStatus(error: mixed): RoutingStatus | null {
488
+ if (error instanceof NotFoundError) {
489
+ return { kind: "not-found", code: 404, text: "not found" };
490
+ }
491
+ if (error instanceof UnauthorizedError) {
492
+ return { kind: "unauthorized", code: 401, text: "unauthorized" };
493
+ }
494
+ if (error instanceof ForbiddenError) {
495
+ return { kind: "forbidden", code: 403, text: "forbidden" };
496
+ }
497
+ return null;
498
+ }
499
+
500
+ /**
501
+ * The JSON door's answer for a routing error, or `null` for any other throw.
502
+ *
503
+ * A redirect is a `204` carrying `Location`, the address `addressOf` makes of
504
+ * it (under the base path for a path on this application). It is not a `3xx`,
505
+ * because `fetch` would follow that itself and fetch the page's HTML for
506
+ * nothing, and the reference could not read where it pointed. The other three
507
+ * are their page statuses. Each one carries `ACTION_OUTCOME_HEADER`, so a `404`
508
+ * from `notFound()` is not mistaken for the `404` of an unknown id. The body
509
+ * names the kind and nothing else.
510
+ */
511
+ function routingOutcome(error: mixed): Response | null {
512
+ if (error instanceof RedirectError) {
513
+ const headers: { [string]: string } = {
514
+ "cache-control": "no-store",
515
+ location: addressOf(error.to),
516
+ };
517
+ headers[ACTION_OUTCOME_HEADER] = "redirect";
518
+ return new Response(null, { status: 204, headers });
519
+ }
520
+ const status = routingStatus(error);
521
+ if (status == null) {
522
+ return null;
523
+ }
524
+ const headers: { [string]: string } = { ...ANSWER_HEADERS };
525
+ headers[ACTION_OUTCOME_HEADER] = status.kind;
526
+ return new Response(JSON.stringify({ outcome: status.kind }), {
527
+ status: status.code,
528
+ headers,
529
+ });
530
+ }
531
+
532
+ /**
533
+ * `path` with a leading run of `/` counted down to one.
534
+ *
535
+ * The post/redirect/get above answers with the path the form was posted to,
536
+ * and that path is the request's: `https://app.example//evil.example/notes` is
537
+ * a link anybody can write, its page posts its forms back to the same address,
538
+ * and a URL parser reads `/\evil.example` as the same two slashes. Written
539
+ * into `Location` as it stands, `//evil.example/notes` is a network-path
540
+ * reference and the browser follows it to another host — after the person
541
+ * submitted a form on this one. `@uniflowed/server` makes the same repair to
542
+ * the trailing-slash redirect (`withOneLeadingSlash` in its `routing.js`); the
543
+ * router cannot import it, so it is spelled here as a counted loop, never a
544
+ * pattern (`docs/security.md`, rule 5).
545
+ */
546
+ function withOneLeadingSlash(path: string): string {
547
+ let start = 0;
548
+ while (start + 1 < path.length && path.charCodeAt(start + 1) === 47) {
549
+ start += 1;
550
+ }
551
+ return path.charCodeAt(0) === 47 ? path.slice(start) : path;
552
+ }
553
+
554
+ /** A `303 See Other`, which a browser follows with a `GET`. */
555
+ function seeOther(location: string): Response {
556
+ return new Response(null, {
557
+ status: 303,
558
+ headers: { location, "cache-control": "no-store" },
559
+ });
560
+ }
561
+
259
562
  /**
260
563
  * The row with this id, or `null`, without saying which by taking longer.
261
564
  *
@@ -331,12 +634,17 @@ function sameOrigin(request: Request): boolean {
331
634
  * can.
332
635
  */
333
636
  function isJson(declared: string | null): boolean {
637
+ return isMedia(declared, ACTION_CONTENT_TYPE);
638
+ }
639
+
640
+ /** Whether `declared` is exactly the media type `expected`, parameters aside. */
641
+ function isMedia(declared: string | null, expected: string): boolean {
334
642
  if (declared == null) {
335
643
  return false;
336
644
  }
337
645
  const semicolon = declared.indexOf(";");
338
646
  const media = (semicolon === -1 ? declared : declared.slice(0, semicolon)).trim().toLowerCase();
339
- return media === ACTION_CONTENT_TYPE;
647
+ return media === expected;
340
648
  }
341
649
 
342
650
  /**
@@ -93,6 +93,24 @@
93
93
  // Pure: no imports, no platform APIs beyond `JSON`, so the browser half of
94
94
  // `@uniflowed/router` can reach it without reaching anything server-only.
95
95
 
96
+ /**
97
+ * The response header that says a server action ended in a routing outcome
98
+ * rather than a result: `redirect`, `not-found`, `unauthorized` or
99
+ * `forbidden`.
100
+ *
101
+ * `redirect()`, `notFound()`, `unauthorized()` and `forbidden()` are how a
102
+ * page's code says where the visitor goes next, and an action is page code:
103
+ * `redirect("/notes")` at the end of a mutation is the common way to finish
104
+ * one. They are control flow, not failures, so the endpoint does not report
105
+ * them or answer `500`. It answers with this header, the matching status and,
106
+ * for a redirect, `Location`. The reference in the browser reads the header
107
+ * and navigates or throws the same error for the route's boundary. Only the
108
+ * kind crosses, plus the address for a redirect. The exception's message and
109
+ * stack never do, which is the fixed-`500` rule applied to the one failure
110
+ * that is not a failure.
111
+ */
112
+ export const ACTION_OUTCOME_HEADER: string = "uf-action-outcome";
113
+
96
114
  /** The request header carrying the action id. */
97
115
  export const ACTION_HEADER: string = "uf-action";
98
116
 
@@ -248,6 +266,21 @@ function asFormData(value: mixed): FormData | null {
248
266
  /** One entry of the explicit walk stack. */
249
267
  type Pending = {| readonly value: mixed, readonly path: string, readonly depth: number |};
250
268
 
269
+ /**
270
+ * How many more values a walk may visit before `MAX_ACTION_VALUES` is spent.
271
+ *
272
+ * An object rather than a number so that one budget can be handed to the walk
273
+ * of every argument of a call: the ceiling `docs/security.md` states is on the
274
+ * *call* — "at most 10,000 values in total" — and a budget per argument would
275
+ * be `MAX_ACTION_ARGUMENTS` times that.
276
+ */
277
+ export type ValueBudget = { remaining: number };
278
+
279
+ /** A fresh budget of `MAX_ACTION_VALUES`, for one call or one result. */
280
+ export function valueBudget(): ValueBudget {
281
+ return { remaining: MAX_ACTION_VALUES };
282
+ }
283
+
251
284
  /**
252
285
  * Refuse a value that cannot cross the wire, naming where it was.
253
286
  *
@@ -257,11 +290,27 @@ type Pending = {| readonly value: mixed, readonly path: string, readonly depth:
257
290
  * hand on the depth. Every bound is checked as the walk runs rather than
258
291
  * afterwards, so a payload that busts one is refused before the rest of it is
259
292
  * visited.
293
+ *
294
+ * That includes the count, which is checked *before* an array's or an
295
+ * object's members are queued rather than as each is visited. One array of
296
+ * half a million zeros fits in a 1 MiB body, and queueing every element — each
297
+ * with a path string of its own — before the counter reached any of them
298
+ * spent tens of megabytes of heap on a payload that was always going to be
299
+ * refused.
300
+ *
301
+ * `budget` is spent by this walk and may be shared: `decodeActionArguments`
302
+ * and `encodeActionArguments` hand every argument the same one, so the ceiling
303
+ * is on the call. Without one, the value has a budget of its own.
260
304
  */
261
- export function checkActionValue(root: mixed, label: string): void {
305
+ export function checkActionValue(
306
+ root: mixed,
307
+ label: string,
308
+ budget?: ValueBudget = valueBudget(),
309
+ ): void {
262
310
  const stack: Array<Pending> = [{ value: root, path: label, depth: 0 }];
263
311
  const seen = new Set<mixed>();
264
- let remaining = MAX_ACTION_VALUES;
312
+ const overBudget = () =>
313
+ new ActionValueError(label, `holds more than ${String(MAX_ACTION_VALUES)} values`);
265
314
 
266
315
  while (stack.length > 0) {
267
316
  const pending = stack.pop();
@@ -270,9 +319,9 @@ export function checkActionValue(root: mixed, label: string): void {
270
319
  }
271
320
  const { value, path, depth } = pending;
272
321
 
273
- remaining -= 1;
274
- if (remaining < 0) {
275
- throw new ActionValueError(label, `holds more than ${String(MAX_ACTION_VALUES)} values`);
322
+ budget.remaining -= 1;
323
+ if (budget.remaining < 0) {
324
+ throw overBudget();
276
325
  }
277
326
  if (depth > MAX_ACTION_DEPTH) {
278
327
  throw new ActionValueError(path, `is nested deeper than ${String(MAX_ACTION_DEPTH)}`);
@@ -309,6 +358,11 @@ export function checkActionValue(root: mixed, label: string): void {
309
358
 
310
359
  if (Array.isArray(object)) {
311
360
  const items: $ReadOnlyArray<mixed> = object as $FlowFixMe;
361
+ // Every queued value will be visited and spent, so a queue longer than
362
+ // what is left is a refusal now rather than after the push.
363
+ if (stack.length + items.length > budget.remaining) {
364
+ throw overBudget();
365
+ }
312
366
  for (let index = 0; index < items.length; index += 1) {
313
367
  stack.push({ value: items[index], path: `${path}[${String(index)}]`, depth: depth + 1 });
314
368
  }
@@ -342,7 +396,11 @@ export function checkActionValue(root: mixed, label: string): void {
342
396
  }
343
397
 
344
398
  const record: { readonly [string]: mixed } = object as $FlowFixMe;
345
- for (const key of Object.getOwnPropertyNames(object)) {
399
+ const keys = Object.getOwnPropertyNames(object);
400
+ if (stack.length + keys.length > budget.remaining) {
401
+ throw overBudget();
402
+ }
403
+ for (const key of keys) {
346
404
  if (FORBIDDEN_KEYS.includes(key)) {
347
405
  throw new ActionValueError(`${path}.${key}`, "is a key this grammar never carries");
348
406
  }
@@ -407,6 +465,8 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
407
465
  );
408
466
  }
409
467
  const values: Array<mixed> = [];
468
+ // One budget for the whole call; see `ValueBudget`.
469
+ const budget = valueBudget();
410
470
  let form: {| readonly at: number, readonly entries: Array<Array<string>> |} | null = null;
411
471
  for (let index = 0; index < args.length; index += 1) {
412
472
  const argument = args[index];
@@ -420,7 +480,7 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
420
480
  values.push(null);
421
481
  continue;
422
482
  }
423
- checkActionValue(argument, label);
483
+ checkActionValue(argument, label, budget);
424
484
  values.push(argument);
425
485
  }
426
486
  return form == null ? JSON.stringify({ args: values }) : JSON.stringify({ args: values, form });
@@ -460,8 +520,10 @@ export function decodeActionArguments(text: string): Array<ActionArgument> {
460
520
  `passes more than ${String(MAX_ACTION_ARGUMENTS)} arguments`,
461
521
  );
462
522
  }
523
+ // One budget for the whole call; see `ValueBudget`.
524
+ const budget = valueBudget();
463
525
  for (let index = 0; index < args.length; index += 1) {
464
- checkActionValue(args[index], `argument ${String(index + 1)}`);
526
+ checkActionValue(args[index], `argument ${String(index + 1)}`, budget);
465
527
  }
466
528
  const decoded: Array<ActionArgument> = args as $FlowFixMe;
467
529
  if (keys.length === 2) {
@@ -58,7 +58,7 @@ export type ComposeOptions = {|
58
58
  |};
59
59
 
60
60
  /** The route a slot renders inside, as much of it as a slot reads. */
61
- type SlotRoute = { +pathname: string, +searchParams: SearchParams, ... };
61
+ type SlotRoute = { readonly pathname: string, readonly searchParams: SearchParams, ... };
62
62
 
63
63
  /**
64
64
  * Whether this bundle marks the boundaries it renders.
@@ -356,11 +356,11 @@ export function slotsAt(
356
356
  slots: $ReadOnlyArray<ResolvedSlot>,
357
357
  depth: number,
358
358
  route: SlotRoute,
359
- ): { readonly [string]: React.Node } {
359
+ ): SlotProps {
360
360
  if (slots.length === 0) {
361
361
  return EMPTY_SLOTS;
362
362
  }
363
- const props: { [string]: React.Node } = {};
363
+ const props: { key?: empty, [string]: React.Node } = {};
364
364
  for (const slot of slots) {
365
365
  if (slot.above === depth) {
366
366
  // `null` rather than an element that renders nothing, and the difference
@@ -376,8 +376,18 @@ export function slotsAt(
376
376
  return props;
377
377
  }
378
378
 
379
+ /**
380
+ * A layout's slots, as the props they are spread into.
381
+ *
382
+ * `key` is named out of the indexer, the way `@uniflowed/ui`'s `Rest` names it:
383
+ * the object is spread onto an element, and a `React.Node` is not a key. No
384
+ * slot is called `key` — a slot is a prop the layout declares, and React
385
+ * never hands a component its key.
386
+ */
387
+ export type SlotProps = { readonly key?: empty, readonly [string]: React.Node };
388
+
379
389
  /** One object for every layout on a project that declares no slot. */
380
- const EMPTY_SLOTS: { readonly [string]: React.Node } = Object.freeze({});
390
+ const EMPTY_SLOTS: SlotProps = Object.freeze({});
381
391
 
382
392
  /**
383
393
  * One slot's tree: its page, inside the layouts declared under the slot, with
@@ -52,8 +52,9 @@ let remembered: string | null | void;
52
52
 
53
53
  /** The parts of a `Document` read here. */
54
54
  type HeadLike = interface {
55
- readonly querySelector: (selector: string) => ?interface {
56
- readonly getAttribute: (name: string) => ?string,
55
+ // Methods, as a `Document`'s are: a method cannot be read off as a property.
56
+ querySelector(selector: string): ?interface {
57
+ getAttribute(name: string): ?string,
57
58
  },
58
59
  };
59
60
 
@@ -92,7 +92,7 @@ export function devtoolsProblem(win: HookWindow): {|
92
92
  // handed back. Anything else there is a hook uf did not install and DevTools
93
93
  // did not either, and guessing at its shape would report a problem that is
94
94
  // really this module not recognising one.
95
- const renderers = (hook: $FlowFixMe).renderers;
95
+ const renderers = (hook as $FlowFixMe).renderers;
96
96
  const count = renderers instanceof Map ? renderers.size : null;
97
97
  if (count != null && count > 1) {
98
98
  return {
@@ -34,7 +34,7 @@ component DefaultRouteError(error: RouteError, reset: () => void) {
34
34
  const detail = match (error) {
35
35
  {kind: "unauthorized"} => "This page needs you to be signed in.",
36
36
  {kind: "forbidden"} => "You do not have access to this page.",
37
- {kind: "thrown"} => "This page could not be rendered.",
37
+ {kind: "thrown", ...} => "This page could not be rendered.",
38
38
  };
39
39
  return (
40
40
  <main>