@fabrika/app 0.8.2 → 0.9.0-cloudflare.2

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 (201) hide show
  1. package/package.json +40 -24
  2. package/skill/SKILL.md +29 -3
  3. package/skill/references/app-setup.md +29 -11
  4. package/skill/references/dev.md +2 -1
  5. package/skill/references/endpoint.md +12 -3
  6. package/skill/references/errors.md +39 -35
  7. package/skill/references/files.md +4 -2
  8. package/skill/references/jobs.md +9 -0
  9. package/skill/references/table-and-migration.md +19 -2
  10. package/skill/references/tests.md +33 -1
  11. package/skill/references/webhooks-links-connectors.md +1 -1
  12. package/src/build-recipe.ts +198 -0
  13. package/src/build.ts +60 -4
  14. package/src/bun.ts +33 -8
  15. package/src/cloudflare/application.ts +352 -0
  16. package/src/cloudflare/bindings.ts +159 -0
  17. package/src/cloudflare/names.ts +16 -0
  18. package/src/cloudflare/ops.ts +246 -0
  19. package/src/cloudflare/r2-files.ts +172 -0
  20. package/src/cloudflare.ts +99 -146
  21. package/src/contract/app-id.ts +5 -0
  22. package/src/contract/artifact-schema.ts +8 -1
  23. package/src/contract/build.ts +68 -11
  24. package/src/contract/compile.ts +1 -0
  25. package/src/contract/context.ts +9 -0
  26. package/src/contract/declaration.ts +2 -0
  27. package/src/contract/define.ts +28 -2
  28. package/src/contract/group.ts +10 -1
  29. package/src/contract/kit.ts +6 -2
  30. package/src/contract/platform.ts +3 -2
  31. package/src/contract/vocabulary.ts +26 -8
  32. package/src/dev/changes.ts +35 -0
  33. package/src/dev/contract-digest.ts +17 -0
  34. package/src/dev/index.ts +11 -0
  35. package/src/dev/launcher.ts +713 -0
  36. package/src/dev/output.ts +72 -0
  37. package/src/dev/wire.ts +136 -0
  38. package/src/disk-assets.ts +8 -3
  39. package/src/files/archive.ts +2 -2
  40. package/src/files/job-names.ts +12 -0
  41. package/src/files/kinds.ts +19 -7
  42. package/src/files/scan.ts +3 -4
  43. package/src/files/streams.ts +3 -1
  44. package/src/implement/anonymous.ts +11 -1
  45. package/src/implement/boot.ts +70 -7
  46. package/src/implement/bounded.ts +21 -2
  47. package/src/implement/capability.ts +18 -3
  48. package/src/implement/dispatch.ts +251 -147
  49. package/src/implement/egress.ts +341 -8
  50. package/src/implement/failures.ts +17 -1
  51. package/src/implement/faults.ts +1 -0
  52. package/src/implement/index.ts +1 -0
  53. package/src/implement/invocation.ts +18 -9
  54. package/src/implement/principal.ts +25 -2
  55. package/src/implement/terminals.ts +93 -0
  56. package/src/implement/workload.ts +24 -13
  57. package/src/jobs/report.ts +5 -1
  58. package/src/jobs/runner.ts +40 -19
  59. package/src/jobs/schedule.ts +7 -1
  60. package/src/jobs/scrub.ts +14 -1
  61. package/src/spa-fallback.ts +12 -0
  62. package/src/testing/endpoint-access.ts +307 -152
  63. package/src/testing/isolation.ts +8 -1
  64. package/src/testing/kit.ts +20 -2
  65. package/src/testing/proxy.ts +5 -5
  66. package/types/src/agent-skill.d.ts +29 -0
  67. package/types/src/build-recipe.d.ts +70 -0
  68. package/types/src/build.d.ts +29 -0
  69. package/types/src/bun.d.ts +66 -0
  70. package/types/src/client/caller.d.ts +19 -0
  71. package/types/src/client/contract.d.ts +131 -0
  72. package/types/src/client/error-reporting.d.ts +66 -0
  73. package/types/src/client/index.d.ts +4 -0
  74. package/types/src/client/rpc.d.ts +14 -0
  75. package/types/src/client/share.d.ts +2 -0
  76. package/types/src/client/transport.d.ts +42 -0
  77. package/types/src/cloudflare/application.d.ts +42 -0
  78. package/types/src/cloudflare/bindings.d.ts +33 -0
  79. package/types/src/cloudflare/names.d.ts +11 -0
  80. package/types/src/cloudflare/ops.d.ts +46 -0
  81. package/types/src/cloudflare/r2-files.d.ts +9 -0
  82. package/types/src/cloudflare.d.ts +42 -0
  83. package/types/src/contract/app-id.d.ts +2 -0
  84. package/types/src/contract/artifact-schema.d.ts +8 -0
  85. package/types/src/contract/attachment.d.ts +41 -0
  86. package/types/src/contract/build.d.ts +12 -0
  87. package/types/src/contract/compile.d.ts +11 -0
  88. package/types/src/contract/connect.d.ts +51 -0
  89. package/types/src/contract/context.d.ts +510 -0
  90. package/types/src/contract/declaration.d.ts +307 -0
  91. package/types/src/contract/defaults.d.ts +208 -0
  92. package/types/src/contract/define.d.ts +376 -0
  93. package/types/src/contract/file.d.ts +44 -0
  94. package/types/src/contract/group.d.ts +282 -0
  95. package/types/src/contract/handled.d.ts +18 -0
  96. package/types/src/contract/index.d.ts +19 -0
  97. package/types/src/contract/kit.d.ts +211 -0
  98. package/types/src/contract/platform.d.ts +9 -0
  99. package/types/src/contract/problems.d.ts +14 -0
  100. package/types/src/contract/redirect.d.ts +25 -0
  101. package/types/src/contract/schema-from-shape.d.ts +10 -0
  102. package/types/src/contract/schema-shape.d.ts +5 -0
  103. package/types/src/contract/snapshot-source.d.ts +15 -0
  104. package/types/src/contract/token.d.ts +58 -0
  105. package/types/src/contract/vocabulary.d.ts +32 -0
  106. package/types/src/contract/webhook-binding.d.ts +24 -0
  107. package/types/src/contract/webhook-scheme.d.ts +23 -0
  108. package/types/src/dev/changes.d.ts +7 -0
  109. package/types/src/dev/contract-digest.d.ts +1 -0
  110. package/types/src/dev/index.d.ts +2 -0
  111. package/types/src/dev/launcher.d.ts +32 -0
  112. package/types/src/dev/output.d.ts +28 -0
  113. package/types/src/dev/wire.d.ts +126 -0
  114. package/types/src/disk-assets.d.ts +6 -0
  115. package/types/src/errors.d.ts +46 -0
  116. package/types/src/fetch.d.ts +7 -0
  117. package/types/src/files/archive.d.ts +26 -0
  118. package/types/src/files/clamscan.d.ts +32 -0
  119. package/types/src/files/handle.d.ts +106 -0
  120. package/types/src/files/job-names.d.ts +9 -0
  121. package/types/src/files/kinds.d.ts +101 -0
  122. package/types/src/files/scan.d.ts +78 -0
  123. package/types/src/files/sniff.d.ts +7 -0
  124. package/types/src/files/streams.d.ts +17 -0
  125. package/types/src/files/treatment.d.ts +18 -0
  126. package/types/src/from-row.d.ts +20 -0
  127. package/types/src/honeypot.d.ts +2 -0
  128. package/types/src/implement/anonymous.d.ts +28 -0
  129. package/types/src/implement/attachment.d.ts +14 -0
  130. package/types/src/implement/boot.d.ts +120 -0
  131. package/types/src/implement/bounded.d.ts +32 -0
  132. package/types/src/implement/browser-config.d.ts +19 -0
  133. package/types/src/implement/capability.d.ts +144 -0
  134. package/types/src/implement/challenge.d.ts +75 -0
  135. package/types/src/implement/client-address.d.ts +11 -0
  136. package/types/src/implement/connect.d.ts +67 -0
  137. package/types/src/implement/crashes.d.ts +31 -0
  138. package/types/src/implement/dispatch.d.ts +221 -0
  139. package/types/src/implement/egress.d.ts +49 -0
  140. package/types/src/implement/environment.d.ts +14 -0
  141. package/types/src/implement/failures.d.ts +39 -0
  142. package/types/src/implement/faults.d.ts +45 -0
  143. package/types/src/implement/headers.d.ts +7 -0
  144. package/types/src/implement/implement.d.ts +58 -0
  145. package/types/src/implement/index.d.ts +25 -0
  146. package/types/src/implement/invocation.d.ts +125 -0
  147. package/types/src/implement/membership.d.ts +201 -0
  148. package/types/src/implement/multipart.d.ts +36 -0
  149. package/types/src/implement/params.d.ts +7 -0
  150. package/types/src/implement/principal.d.ts +57 -0
  151. package/types/src/implement/project.d.ts +11 -0
  152. package/types/src/implement/public.d.ts +61 -0
  153. package/types/src/implement/serve.d.ts +25 -0
  154. package/types/src/implement/terminals.d.ts +33 -0
  155. package/types/src/implement/tokens.d.ts +49 -0
  156. package/types/src/implement/types.d.ts +243 -0
  157. package/types/src/implement/urls.d.ts +15 -0
  158. package/types/src/implement/verifiers.d.ts +19 -0
  159. package/types/src/implement/webhook.d.ts +55 -0
  160. package/types/src/implement/workload.d.ts +56 -0
  161. package/types/src/implement/xlsx.d.ts +6 -0
  162. package/types/src/index.d.ts +12 -0
  163. package/types/src/jobs/exception.d.ts +40 -0
  164. package/types/src/jobs/mail.d.ts +67 -0
  165. package/types/src/jobs/report.d.ts +56 -0
  166. package/types/src/jobs/runner.d.ts +154 -0
  167. package/types/src/jobs/schedule.d.ts +17 -0
  168. package/types/src/jobs/scrub.d.ts +12 -0
  169. package/types/src/jobs/types.d.ts +98 -0
  170. package/types/src/react/caller.d.ts +30 -0
  171. package/types/src/react/errors.d.ts +40 -0
  172. package/types/src/react/hooks.d.ts +110 -0
  173. package/types/src/react/index.d.ts +5 -0
  174. package/types/src/react/provider.d.ts +58 -0
  175. package/types/src/react/store.d.ts +68 -0
  176. package/types/src/react/testing.d.ts +28 -0
  177. package/types/src/react/types.d.ts +33 -0
  178. package/types/src/rpc/builder.d.ts +52 -0
  179. package/types/src/rpc/contract.d.ts +23 -0
  180. package/types/src/rpc/dispatcher.d.ts +11 -0
  181. package/types/src/rpc/types.d.ts +62 -0
  182. package/types/src/service/define.d.ts +42 -0
  183. package/types/src/service/index.d.ts +3 -0
  184. package/types/src/service/serve.d.ts +28 -0
  185. package/types/src/spa-fallback.d.ts +2 -0
  186. package/types/src/standard-schema.d.ts +55 -0
  187. package/types/src/testing/caller.d.ts +95 -0
  188. package/types/src/testing/challenge.d.ts +11 -0
  189. package/types/src/testing/dev.d.ts +97 -0
  190. package/types/src/testing/endpoint-access.d.ts +131 -0
  191. package/types/src/testing/index.d.ts +12 -0
  192. package/types/src/testing/isolation.d.ts +3 -0
  193. package/types/src/testing/kit.d.ts +187 -0
  194. package/types/src/testing/membership.d.ts +132 -0
  195. package/types/src/testing/policy-isolation.d.ts +113 -0
  196. package/types/src/testing/preload.d.ts +1 -0
  197. package/types/src/testing/proxy.d.ts +40 -0
  198. package/types/src/testing/runner.d.ts +8 -0
  199. package/types/src/testing/sample-file.d.ts +5 -0
  200. package/types/src/testing/status.d.ts +4 -0
  201. package/types/src/undumpable.d.ts +5 -0
@@ -20,6 +20,9 @@ import type { Tables } from '@fabrika/db'
20
20
  import { tablePolicies } from '@fabrika/db/tables'
21
21
  import { type IsolationWorlds, type SampleValue, SEEDED_OBJECT_VERSION, type TestDatabase, type WorldRow } from '@fabrika/db/testing'
22
22
  import { MemoryFileObjects } from '@fabrika/platform/testing'
23
+ import { readFile } from 'node:fs/promises'
24
+ import { basename } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
23
26
  import { z } from 'zod/v4'
24
27
  import type { RpcFetch } from '../client/transport'
25
28
  import { compileContract } from '../contract/compile'
@@ -33,6 +36,7 @@ import {
33
36
  } from '../contract/defaults'
34
37
  import type { AnyContract } from '../contract/define'
35
38
  import type { FileScanner, ScanVerdict } from '../files/scan'
39
+ import { bytesAreOfType, SNIFF_BYTES } from '../files/sniff'
36
40
  import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
37
41
  import type { TestCaller, TestGrant } from './caller'
38
42
  import { standInIamKit } from './kit'
@@ -58,6 +62,13 @@ export type ReachOutcome = { readonly answered: true } | { readonly answered: fa
58
62
  /** Column → value of the row of the table that owns the endpoint's dimension, in both scopes: the author's seed. */
59
63
  export type Seed = Readonly<Record<string, SampleValue>>
60
64
 
65
+ /**
66
+ * Table → column → value: one row of each application table the handler reads for the caller's own scope, seeded
67
+ * before a leak or an own case asks and removed once it is over, with every row that refers to it, so no other case
68
+ * finds it. Both scopes may see it, so its values are never counted as the other scope's.
69
+ */
70
+ export type AppRows = Readonly<Record<string, Seed>>
71
+
61
72
  export interface EndpointAccessKit {
62
73
  /**
63
74
  * A caller holding the endpoint's action in one scope names an object (`on`) or a scope value (`in`) of another;
@@ -66,16 +77,18 @@ export interface EndpointAccessKit {
66
77
  denied(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
67
78
  /**
68
79
  * A caller of the other scope asks first, then a caller of one scope asks for its own object (`on`), its list
69
- * (`over`) or, for every table that lies in the dimension, its scope value (`in`).
80
+ * (`over`) or, for every table that lies in the dimension, its scope value (`in`). `appRows` are seeded first, as
81
+ * for `own`.
70
82
  */
71
- leaked(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<LeakOutcome>
83
+ leaked(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed, appRows?: AppRows): Promise<LeakOutcome>
72
84
  /**
73
85
  * A caller that holds only the endpoint's action asks for its own object: a person or a script in its scope, or, on
74
86
  * a link mount, the holder of a link bound to the object. A write action reads its own target. Where IAM requires more
75
87
  * of the caller, it holds that too: a member manager an assigning role and the assigned role's actions, a link's
76
- * issuer the actions the link grants.
88
+ * issuer the actions the link grants. `appRows` are the rows of application tables its handler reads (the guide a link
89
+ * holder downloads), each seeded first.
77
90
  */
78
- own(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
91
+ own(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed, appRows?: AppRows): Promise<DeniedOutcome>
79
92
  /**
80
93
  * An `app` or a `creates` endpoint asked by a caller who holds its action in one value of a dimension only, never
81
94
  * globally: refused (403), by the proxy for `app` and by IAM for `creates`, since both need a global grant.
@@ -101,6 +114,54 @@ export interface EndpointAccessKit {
101
114
  anyone(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>
102
115
  }
103
116
 
117
+ export interface EndpointAccessOptions {
118
+ /**
119
+ * The file every case of an endpoint sends in a file field instead of the kit's sample, by endpoint id and input
120
+ * field: a file of the application's repository that a handler inspecting the content accepts (a valid package). Its
121
+ * type is the first of the field's kind that its bytes are; a file of none of them throws. `files` of the fixtures.
122
+ */
123
+ readonly files?: Readonly<Record<string, Readonly<Record<string, URL>>>>
124
+ /**
125
+ * The error types of the refusals (400) an endpoint's handler answers its own object with when the case's input meets
126
+ * no domain rule the kit can satisfy, by endpoint id: such a 400 is the handler's own answer, so the request passed
127
+ * the access form, and the endpoint's own case gets it as `{ status: 400 }` instead of failing; so does the other
128
+ * scope's request a leak case makes first. The own scope's answer of a leak case, and a global or capability case,
129
+ * still throw on it (an answer that holds nothing proves no isolation), so does any other 400, and an input the schema
130
+ * refuses (`validation`) always does. `refusals` of the fixtures.
131
+ */
132
+ readonly refusals?: Readonly<Record<string, readonly string[]>>
133
+ /**
134
+ * Known values for text columns of the row the endpoint addresses (the row of the table that owns its dimension), by
135
+ * endpoint id: the value in the own scope's row, and `<value>-other` in the other scope's, which counts among the
136
+ * other scope's values a leak shows. For a handler whose rule ties a column to the input or a file fixture (a package
137
+ * that names the skill's slug). `pins` of the fixtures.
138
+ */
139
+ readonly pins?: Readonly<Record<string, Readonly<Record<string, string>>>>
140
+ }
141
+
142
+ /**
143
+ * Whether a request takes a refusal the fixtures declare for its endpoint as the handler's answer: an own case's, and the
144
+ * other scope's request a leak case makes first, whose answer is not the one checked.
145
+ */
146
+ type Refusals = 'declared' | 'none'
147
+
148
+ /**
149
+ * The pins of a case's endpoint (column → the own scope's value of a text column), and the removals of the case: rows
150
+ * seeded with pins are removed once the case is over, so the next case can pin the same values under a unique index.
151
+ */
152
+ interface Pinning {
153
+ readonly pins: Readonly<Record<string, string>>
154
+ readonly removals: (() => Promise<void>)[]
155
+ }
156
+
157
+ const NO_PINNING: Pinning = { pins: {}, removals: [] }
158
+
159
+ /** Seeded worlds, their removal taken by the case when they hold pinned values. */
160
+ const pinned = <W extends { readonly remove: () => Promise<void> }>(worlds: W, pinning: Pinning): W => {
161
+ if (Object.keys(pinning.pins).length > 0) pinning.removals.push(worlds.remove)
162
+ return worlds
163
+ }
164
+
104
165
  interface ActionEndpoint {
105
166
  readonly id: string
106
167
  /** The link mount of a link procedure; `null` for every other endpoint. */
@@ -276,7 +337,12 @@ export const managerGrants = (
276
337
  * The endpoint access kit of `app`, whose data layer is `scoped(tables)`, over `postgres`. It boots `app` once, so the
277
338
  * application's layer is bound here and nowhere else in the process.
278
339
  */
279
- export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase): EndpointAccessKit {
340
+ export function endpointAccessKit<DB, P>(
341
+ app: ContractImplementation<AnyContract>,
342
+ tables: Tables<DB, P>,
343
+ postgres: TestDatabase,
344
+ options: EndpointAccessOptions = {},
345
+ ): EndpointAccessKit {
280
346
  // The objects of the files the cases seed live in memory; their rows are the application's own tables.
281
347
  const objects = new MemoryFileObjects()
282
348
  const { kit, standIn } = standInIamKit(app, {
@@ -395,16 +461,20 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
395
461
  throw new Error(`${row.id} admits neither a human nor a machine caller`)
396
462
  }
397
463
 
398
- /** A case's request by a new caller holding `grants`, its generated fields filled for `chain` and that caller. */
464
+ /**
465
+ * A case's request by a new caller holding `grants`, its generated fields filled for `chain` and that caller. Only an
466
+ * own case passes `refusals: 'declared'`.
467
+ */
399
468
  const ask = async (
400
469
  row: ActionEndpoint,
401
470
  grants: readonly Grant[],
402
471
  input: Readonly<Record<string, unknown>>,
403
472
  chain: Chain,
473
+ refusals: Refusals = 'none',
404
474
  ): Promise<{ readonly status: number; readonly text: string; readonly marks: readonly string[] }> => {
405
475
  const asker = callerFor(row, grants)
406
476
  const asked = withGenerated(row, input, chain, asker.caller)
407
- return { ...(await askAs(row, asker.fetch, asked.input)), marks: asked.marks }
477
+ return { ...(await askAs(row, asker.fetch, asked.input, refusals)), marks: asked.marks }
408
478
  }
409
479
 
410
480
  // A seeded file column references an empty object, which a handler that serves or opens the file reads here.
@@ -418,7 +488,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
418
488
  }
419
489
 
420
490
  /** The request of a case: a GET's input in its URL, a file field's marker as a file of its kind in a multipart body. */
421
- const requestOf = (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): readonly [string, RequestInit] => {
491
+ const requestOf = async (row: ActionEndpoint, body: Readonly<Record<string, unknown>>): Promise<readonly [string, RequestInit]> => {
422
492
  if (row.method === 'GET') {
423
493
  const params = new URLSearchParams(Object.entries(body).map(([name, value]) => [name, value === null ? 'null' : String(value)]))
424
494
  return [params.size === 0 ? row.path : `${row.path}?${params}`, { method: 'GET' }]
@@ -437,7 +507,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
437
507
  for (const item of Array.isArray(value) ? value : [value]) {
438
508
  const kind = fileKindOf(item)
439
509
  if (kind === undefined) throw new Error(`${row.id}: the file field ${name} holds ${JSON.stringify(item)}, not a generated file`)
440
- files.push([name, generatedFile(kind)])
510
+ files.push([name, await generatedFile(row.id, name, kind)])
441
511
  }
442
512
  }
443
513
  const form = new FormData()
@@ -446,30 +516,69 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
446
516
  return [row.path, { method: 'POST', body: form }]
447
517
  }
448
518
 
449
- const generatedFile = (kind: string): File => {
450
- const sample = sampleFile(kind, kit.artifact.fileKinds?.[kind]?.types ?? [])
451
- return new File([sample.bytes], `generated-${kind}`, { type: sample.type })
519
+ /** The file a case sends in `field`: the author's file fixture of the endpoint's field, else a sample of `kind`. */
520
+ const generatedFile = async (endpoint: string, field: string, kind: string): Promise<File> => {
521
+ const types = kit.artifact.fileKinds?.[kind]?.types ?? []
522
+ const fixture = options.files?.[endpoint]?.[field]
523
+ if (fixture === undefined) {
524
+ const sample = sampleFile(kind, types)
525
+ return new File([sample.bytes], `generated-${kind}`, { type: sample.type })
526
+ }
527
+ const path = fileURLToPath(fixture)
528
+ const bytes = await fixtureBytes(endpoint, field, path)
529
+ const type = types.find((candidate) => bytesAreOfType(candidate, bytes.subarray(0, SNIFF_BYTES)))
530
+ if (type === undefined) {
531
+ throw new Error(`${endpoint}: the file of ${field}, ${path}, is none of the types of files.${kind} (${types.join(', ')})`)
532
+ }
533
+ return new File([bytes], basename(path), { type })
534
+ }
535
+
536
+ // A file fixture is read once; every case sends the same bytes.
537
+ const fixtureFiles = new Map<string, Uint8Array<ArrayBuffer>>()
538
+ const fixtureBytes = async (endpoint: string, field: string, path: string): Promise<Uint8Array<ArrayBuffer>> => {
539
+ const known = fixtureFiles.get(path)
540
+ if (known !== undefined) return known
541
+ let bytes: Uint8Array<ArrayBuffer>
542
+ try {
543
+ bytes = new Uint8Array(await readFile(path))
544
+ } catch {
545
+ throw new Error(`${endpoint}: the file of ${field} in the fixtures, ${path}, cannot be read; add it to the repository or correct its path`)
546
+ }
547
+ fixtureFiles.set(path, bytes)
548
+ return bytes
452
549
  }
453
550
 
454
551
  const askAs = async (
455
552
  row: ActionEndpoint,
456
553
  fetch: RpcFetch,
457
554
  body: Readonly<Record<string, unknown>>,
555
+ refusals: Refusals = 'none',
458
556
  ): Promise<{ readonly status: number; readonly text: string }> => {
459
557
  stockSeededObjects()
460
- const [url, init] = requestOf(row, body)
558
+ const [url, init] = await requestOf(row, body)
461
559
  const response = await fetch(url, init)
462
560
  const text = await response.text()
463
561
  // A 400 hides what the case is about: input is parsed before the access form runs, and a handler's refusal of
464
562
  // the input it was given is no answer to another scope. Either is what the generator could not build.
465
563
  if (response.status === 400) {
466
564
  const type = errorType(text)
565
+ const declared = type !== undefined && type !== 'validation' && options.refusals?.[row.id]?.includes(type) === true
566
+ // The own case may take it: it shows the access form let the caller through. The answer a leak case checks, or a
567
+ // global case's, would pass holding nothing, which proves nothing.
568
+ if (declared && refusals === 'declared') {
569
+ return { status: response.status, text }
570
+ }
571
+ if (declared) {
572
+ throw new Error(
573
+ `${row.id}: the handler answered its declared refusal (400 ${type}), which only the own case takes; this case proves nothing on it until the fixtures give what the handler needs (pins, files, fixtures) (${text})`,
574
+ )
575
+ }
467
576
  throw new Error(
468
577
  type === 'validation'
469
578
  ? `${row.id}: the case's input does not match the endpoint's input schema; correct its fixture (${text})`
470
579
  : `${row.id}: the handler refused the case's input (400 ${
471
580
  type ?? 'without a type'
472
- }); the case cannot reach what it tests until its fixtures give what the handler needs: a picked person in members, a row of its object in rows, the state in seeds or the whole input in fixtures (${text})`,
581
+ }); the case cannot reach what it tests until its fixtures give what the handler needs: a picked person in members, a row of its object in rows, the state in seeds, a file it accepts in files, the whole input in fixtures, or the refusal it answers in refusals (${text})`,
473
582
  )
474
583
  }
475
584
  return { status: response.status, text }
@@ -485,6 +594,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
485
594
  field: SchemaShape | undefined,
486
595
  seed: Seed,
487
596
  input: Readonly<Record<string, unknown>>,
597
+ pinning: Pinning = NO_PINNING,
488
598
  ): Promise<Scopes> => {
489
599
  const named = rowFields(input)
490
600
  const owner = ownerOf(dimension)
@@ -505,7 +615,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
505
615
  },
506
616
  }
507
617
  }
508
- const worlds = await postgres.seedWorlds(tables, owner, seed)
618
+ const worlds = pinned(await postgres.seedWorlds(tables, owner, seed, pinning.pins), pinning)
509
619
  const own = await rowsIn(worlds.own, named)
510
620
  const other = await rowsIn(worlds.other, named)
511
621
  return {
@@ -560,14 +670,17 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
560
670
  * For a report `in` `dimension`: one row in each scope of every table that lies in it, the table that owns it with
561
671
  * `seed`. Each side's targets are its rows' values of `dimension`, and each caller holds all of them.
562
672
  */
563
- const inScopesOf = async (action: string, dimension: string, field: SchemaShape | undefined, seed: Seed) => {
673
+ const inScopesOf = async (action: string, dimension: string, field: SchemaShape | undefined, seed: Seed, pinning: Pinning = NO_PINNING) => {
564
674
  const own: InTarget[] = []
565
675
  const other: InTarget[] = []
566
676
  const values: string[] = []
567
677
  for (const name of tableNames) {
568
678
  const mode = policies[name]?.scope.mode
569
679
  if (mode !== 'dimension' && mode !== 'child') continue
570
- const worlds = await postgres.seedWorlds(tables, name, name === ownerOf(dimension) ? seed : {})
680
+ const owned = name === ownerOf(dimension)
681
+ const worlds = owned
682
+ ? pinned(await postgres.seedWorlds(tables, name, seed, pinning.pins), pinning)
683
+ : await postgres.seedWorlds(tables, name)
571
684
  const ownAt = worlds.own.chain.findIndex((link) => link.type === dimension)
572
685
  const otherAt = worlds.other.chain.findIndex((link) => link.type === dimension)
573
686
  const ownValue = worlds.own.chain[ownAt]?.value
@@ -579,7 +692,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
579
692
  }
580
693
  }
581
694
  if (own.length === 0) {
582
- const scopes = await scopesOf(action, dimension, field, seed, {})
695
+ const scopes = await scopesOf(action, dimension, field, seed, {}, pinning)
583
696
  return { own: [scopes.own], other: [scopes.other], values: scopes.other.values }
584
697
  }
585
698
  return { own, other, values }
@@ -702,6 +815,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
702
815
  mount: string,
703
816
  seed: Seed,
704
817
  input: Readonly<Record<string, unknown>>,
818
+ pinning: Pinning,
705
819
  ): Promise<{ readonly fetch: RpcFetch; readonly rows: Readonly<Record<string, string>> }> => {
706
820
  const access = row.access
707
821
  if (access.form !== 'on') {
@@ -711,7 +825,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
711
825
  if (owner === undefined) {
712
826
  throw new Error(`${row.id}: no table owns ${access.dimension}, so no link can be bound to one`)
713
827
  }
714
- const { own } = await postgres.seedWorlds(tables, owner, seed)
828
+ const { own } = pinned(await postgres.seedWorlds(tables, owner, seed, pinning.pins), pinning)
715
829
  const holder = kit.testCaller([{ action: row.action, scope: { type: access.dimension, value: own.key } }], {
716
830
  kind: 'link',
717
831
  link: { mount, chain: own.chain },
@@ -724,147 +838,188 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
724
838
  return { fetch, rows: (await rowsIn(own, rowFields(input))).rows }
725
839
  }
726
840
 
727
- const accessKit: EndpointAccessKit = {
728
- async denied(endpoint, input, seed = {}) {
729
- const row = endpointOf(endpoint)
730
- const { action, access } = row
731
- if (access.form !== 'on' && access.form !== 'in') {
732
- throw new Error(`${endpoint} has the access form ${access.form}; only on and in name an object or a scope value`)
733
- }
734
- const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
735
- const asked = addressing(input, access.from, scopes.other)
736
- const { status } = await ask(row, holding(row, input, scopes.own.grants, scopes.own.chain), asked, scopes.own.chain)
737
- return { status }
738
- },
739
- async leaked(endpoint, input, seed = {}) {
740
- const row = endpointOf(endpoint)
741
- const { action, access } = row
742
- if (access.form === 'in') {
743
- const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed)
744
- const grants = (targets: readonly InTarget[]): Grant[] =>
745
- targets.flatMap(({ target, chain }) => holding(row, input, [{ action, scope: { type: access.dimension, value: target } }], chain))
746
- const marks: string[] = []
747
- for (const side of scopes.other) {
748
- const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain)
749
- marks.push(...answer.marks)
841
+ // The cases of one call: `removals` takes the removal of the application rows it seeds, which `drained` runs once
842
+ // the call and the runs it kicked are over. A list per call, so a concurrent case never removes the rows another
843
+ // was given; the generated cases run one after another, since a row a required foreign key needed is shared.
844
+ const accessKitOf = (removals: (() => Promise<void>)[]): EndpointAccessKit => {
845
+ /** The pins of `endpoint`, whose seeded rows this call removes once it is over. */
846
+ const pinningOf = (endpoint: string): Pinning => ({ pins: options.pins?.[endpoint] ?? {}, removals })
847
+
848
+ /** One row of each application table `appRows` names, for this call only. */
849
+ const seedAppRows = async (appRows: AppRows): Promise<void> => {
850
+ if (Object.keys(appRows).length === 0) return
851
+ removals.push((await postgres.seedApplicationRows(tables, appRows)).remove)
852
+ }
853
+
854
+ return {
855
+ async denied(endpoint, input, seed = {}) {
856
+ const row = endpointOf(endpoint)
857
+ const { action, access } = row
858
+ if (access.form !== 'on' && access.form !== 'in') {
859
+ throw new Error(`${endpoint} has the access form ${access.form}; only on and in name an object or a scope value`)
750
860
  }
751
- const answers = []
752
- for (const side of scopes.own) {
753
- answers.push(await ask(row, grants(scopes.own), addressing(input, access.from, side), side.chain))
861
+ const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
862
+ const asked = addressing(input, access.from, scopes.other)
863
+ const { status } = await ask(row, holding(row, input, scopes.own.grants, scopes.own.chain), asked, scopes.own.chain)
864
+ return { status }
865
+ },
866
+ async leaked(endpoint, input, seed = {}, appRows = {}) {
867
+ const row = endpointOf(endpoint)
868
+ const { action, access } = row
869
+ if (access.form === 'in') {
870
+ await seedAppRows(appRows)
871
+ const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, pinningOf(endpoint))
872
+ const grants = (targets: readonly InTarget[]): Grant[] =>
873
+ targets.flatMap(({ target, chain }) => holding(row, input, [{ action, scope: { type: access.dimension, value: target } }], chain))
874
+ const marks: string[] = []
875
+ for (const side of scopes.other) {
876
+ const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain, 'declared')
877
+ marks.push(...answer.marks)
878
+ }
879
+ const answers = []
880
+ for (const side of scopes.own) {
881
+ answers.push(await ask(row, grants(scopes.own), addressing(input, access.from, side), side.chain))
882
+ }
883
+ return leakOutcome(answers, [...scopes.values, ...marks])
754
884
  }
755
- return leakOutcome(answers, [...scopes.values, ...marks])
756
- }
757
- if (access.form !== 'on' && access.form !== 'over') {
758
- throw new Error(`${endpoint} has the access form ${access.form}; only on, in and over answer with rows of a scope`)
759
- }
760
- const scopes = access.form === 'on'
761
- ? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
762
- : await listScopesOf(action, access.dimension)
763
- const from = access.form === 'on' ? access.from : null
764
- const other = await ask(
765
- row,
766
- holding(row, input, scopes.other.grants, scopes.other.chain),
767
- addressing(input, from, scopes.other),
768
- scopes.other.chain,
769
- )
770
- const answer = await ask(
771
- row,
772
- holding(row, input, scopes.own.grants, scopes.own.chain),
773
- addressing(input, from, scopes.own),
774
- scopes.own.chain,
775
- )
776
- return leakOutcome([answer], [...scopes.other.values, ...other.marks])
777
- },
778
- async own(endpoint, input, seed = {}) {
779
- const row = endpointOf(endpoint)
780
- const { action, access } = row
781
- if (access.form !== 'on') {
782
- throw new Error(`${endpoint} has the access form ${access.form}; only on addresses an object of its own`)
783
- }
784
- if (row.link !== null) {
785
- const holder = await linkHolder(row, row.link, seed, input)
786
- const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows })
885
+ if (access.form !== 'on' && access.form !== 'over') {
886
+ throw new Error(`${endpoint} has the access form ${access.form}; only on, in and over answer with rows of a scope`)
887
+ }
888
+ await seedAppRows(appRows)
889
+ const scopes = access.form === 'on'
890
+ ? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
891
+ : await listScopesOf(action, access.dimension)
892
+ const from = access.form === 'on' ? access.from : null
893
+ // The other scope asks first, to fill whatever a handler keeps between requests; its pins differ from the own
894
+ // scope's, so a refusal the fixtures declare is its answer too. The own scope's answer is the one checked.
895
+ const other = await ask(
896
+ row,
897
+ holding(row, input, scopes.other.grants, scopes.other.chain),
898
+ addressing(input, from, scopes.other),
899
+ scopes.other.chain,
900
+ 'declared',
901
+ )
902
+ const answer = await ask(
903
+ row,
904
+ holding(row, input, scopes.own.grants, scopes.own.chain),
905
+ addressing(input, from, scopes.own),
906
+ scopes.own.chain,
907
+ )
908
+ return leakOutcome([answer], [...scopes.other.values, ...other.marks])
909
+ },
910
+ async own(endpoint, input, seed = {}, appRows = {}) {
911
+ const row = endpointOf(endpoint)
912
+ const { action, access } = row
913
+ if (access.form !== 'on') {
914
+ throw new Error(`${endpoint} has the access form ${access.form}; only on addresses an object of its own`)
915
+ }
916
+ await seedAppRows(appRows)
917
+ if (row.link !== null) {
918
+ const holder = await linkHolder(row, row.link, seed, input, pinningOf(endpoint))
919
+ const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows }, 'declared')
920
+ return { status }
921
+ }
922
+ const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
923
+ const { status } = await ask(
924
+ row,
925
+ holding(row, input, scopes.own.grants, scopes.own.chain),
926
+ addressing(input, access.from, scopes.own),
927
+ scopes.own.chain,
928
+ 'declared',
929
+ )
787
930
  return { status }
931
+ },
932
+ async scoped(endpoint, input) {
933
+ const row = globalEndpointOf(endpoint)
934
+ const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
935
+ const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
936
+ return { status }
937
+ },
938
+ async anyone(endpoint, input) {
939
+ const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
940
+ if (row?.kind !== 'public') {
941
+ throw new Error(`${endpoint} is not a public procedure of the contract`)
942
+ }
943
+ const response = await kit.fetch(row.path, {
944
+ method: row.method,
945
+ headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
946
+ body: JSON.stringify(input),
947
+ })
948
+ const status = response.status
949
+ const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
950
+ return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
951
+ },
952
+ async global(endpoint, input) {
953
+ const row = globalEndpointOf(endpoint)
954
+ const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
955
+ // Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
956
+ return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
957
+ },
958
+ async capability(endpoint, capability, input, seed = {}) {
959
+ const capabilities = capabilitiesEndpointOf(endpoint)
960
+ const named = capabilities.get(capability)
961
+ if (named === undefined) {
962
+ throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
963
+ }
964
+ const grants: Grant[] = []
965
+ const global: { readonly action: string }[] = []
966
+ let asked = input
967
+ for (const [name, other] of capabilities) {
968
+ if (name === capability) continue
969
+ if (other.access.form === 'over') {
970
+ global.push({ action: other.action })
971
+ } else if (other.access.form === 'on' || other.access.form === 'in') {
972
+ const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
973
+ asked = addressing(asked, other.access.from, own)
974
+ grants.push(...own.grants)
975
+ }
976
+ }
977
+ const { access } = named
978
+ if (access.form === 'on' || access.form === 'in') {
979
+ const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input, pinningOf(endpoint))
980
+ asked = addressing(asked, access.from, scopes.other)
981
+ grants.push(...scopes.own.grants)
982
+ }
983
+ const { status } = await askAs(named, callerFor(named, grants, global).fetch, asked)
984
+ return { status }
985
+ },
986
+ }
987
+ }
988
+ // Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one, and
989
+ // only then removes its application rows, which those runs may still read. Every step runs whatever failed before
990
+ // it, and the case's own error is thrown first.
991
+ const drained =
992
+ <A extends readonly unknown[], R>(method: (kit: EndpointAccessKit) => (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
993
+ const removals: (() => Promise<void>)[] = []
994
+ let outcome: { readonly ok: true; readonly value: R } | { readonly ok: false; readonly error: unknown }
995
+ try {
996
+ outcome = { ok: true, value: await method(accessKitOf(removals))(...args) }
997
+ } catch (error) {
998
+ outcome = { ok: false, error }
788
999
  }
789
- const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
790
- const { status } = await ask(
791
- row,
792
- holding(row, input, scopes.own.grants, scopes.own.chain),
793
- addressing(input, access.from, scopes.own),
794
- scopes.own.chain,
795
- )
796
- return { status }
797
- },
798
- async scoped(endpoint, input) {
799
- const row = globalEndpointOf(endpoint)
800
- const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
801
- const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
802
- return { status }
803
- },
804
- async anyone(endpoint, input) {
805
- const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
806
- if (row?.kind !== 'public') {
807
- throw new Error(`${endpoint} is not a public procedure of the contract`)
808
- }
809
- const response = await kit.fetch(row.path, {
810
- method: row.method,
811
- headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
812
- body: JSON.stringify(input),
813
- })
814
- const status = response.status
815
- const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
816
- return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
817
- },
818
- async global(endpoint, input) {
819
- const row = globalEndpointOf(endpoint)
820
- const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
821
- // Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
822
- return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
823
- },
824
- async capability(endpoint, capability, input, seed = {}) {
825
- const capabilities = capabilitiesEndpointOf(endpoint)
826
- const named = capabilities.get(capability)
827
- if (named === undefined) {
828
- throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
829
- }
830
- const grants: Grant[] = []
831
- const global: { readonly action: string }[] = []
832
- let asked = input
833
- for (const [name, other] of capabilities) {
834
- if (name === capability) continue
835
- if (other.access.form === 'over') {
836
- global.push({ action: other.action })
837
- } else if (other.access.form === 'on' || other.access.form === 'in') {
838
- const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
839
- asked = addressing(asked, other.access.from, own)
840
- grants.push(...own.grants)
1000
+ const failures: unknown[] = []
1001
+ for (const step of [() => kit.drain(), ...removals]) {
1002
+ try {
1003
+ await step()
1004
+ } catch (error) {
1005
+ failures.push(error)
841
1006
  }
842
1007
  }
843
- const { access } = named
844
- if (access.form === 'on' || access.form === 'in') {
845
- const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input)
846
- asked = addressing(asked, access.from, scopes.other)
847
- grants.push(...scopes.own.grants)
1008
+ if (!outcome.ok) {
1009
+ if (failures.length === 0) throw outcome.error
1010
+ throw new AggregateError([outcome.error, ...failures], 'the case failed, and so did its cleanup')
848
1011
  }
849
- const { status } = await askAs(named, callerFor(named, grants, global).fetch, asked)
850
- return { status }
851
- },
852
- }
853
- // Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one.
854
- const drained = <A extends readonly unknown[], R>(method: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
855
- try {
856
- return await method(...args)
857
- } finally {
858
- await kit.drain()
1012
+ if (failures.length === 1) throw failures[0]
1013
+ if (failures.length > 1) throw new AggregateError(failures, 'the cleanup of the case failed')
1014
+ return outcome.value
859
1015
  }
860
- }
861
1016
  return Object.freeze({
862
- denied: drained(accessKit.denied),
863
- leaked: drained(accessKit.leaked),
864
- own: drained(accessKit.own),
865
- scoped: drained(accessKit.scoped),
866
- global: drained(accessKit.global),
867
- anyone: drained(accessKit.anyone),
868
- capability: drained(accessKit.capability),
1017
+ denied: drained((cases) => cases.denied),
1018
+ leaked: drained((cases) => cases.leaked),
1019
+ own: drained((cases) => cases.own),
1020
+ scoped: drained((cases) => cases.scoped),
1021
+ global: drained((cases) => cases.global),
1022
+ anyone: drained((cases) => cases.anyone),
1023
+ capability: drained((cases) => cases.capability),
869
1024
  })
870
1025
  }
@@ -6,7 +6,14 @@ import { refuseInProduction } from '@fabrika/db/production'
6
6
 
7
7
  refuseInProduction('@fabrika/app/testing/isolation')
8
8
 
9
- export { type DeniedOutcome, type EndpointAccessKit, endpointAccessKit, type LeakOutcome, type ReachOutcome } from './endpoint-access'
9
+ export {
10
+ type DeniedOutcome,
11
+ type EndpointAccessKit,
12
+ endpointAccessKit,
13
+ type EndpointAccessOptions,
14
+ type LeakOutcome,
15
+ type ReachOutcome,
16
+ } from './endpoint-access'
10
17
  export {
11
18
  type AppCaller,
12
19
  type AppCountOutcome,