@fabrika/app 0.5.5 → 0.7.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 (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +387 -236
  10. package/skill/references/spa.md +5 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
@@ -9,9 +9,12 @@ import {
9
9
  CLIENT_ADDRESS_HEADER,
10
10
  type ContractArtifact,
11
11
  contractDigest,
12
+ contractWorkloads,
13
+ FABRIKA_APP_ORIGIN,
12
14
  FABRIKA_CONTRACT_DIGEST,
13
15
  FABRIKA_IAM_APP_ID,
14
16
  FABRIKA_IAM_ISSUER,
17
+ FABRIKA_TOKEN_KEYS,
15
18
  type IamMembershipRpc,
16
19
  isNormalizedMailbox,
17
20
  type Jwks,
@@ -32,18 +35,21 @@ import { type IamGateway, REQUEST_ID_HEADER, stripFabrikaCookies } from '@fabrik
32
35
  import { exportJWK, generateKeyPair, SignJWT } from 'jose'
33
36
  import { existsSync } from 'node:fs'
34
37
  import { resolve } from 'node:path'
38
+ import type { z } from 'zod/v4'
35
39
  import type { RpcFetch } from '../client/transport'
36
40
  import { compileContract } from '../contract/compile'
37
41
  import type { AnyContract, DimensionOf } from '../contract/define'
42
+ import type { TokenPurpose } from '../contract/token'
38
43
  import { diskAssets } from '../disk-assets'
39
44
  import type { RequestExecutionContext } from '../fetch'
40
- import type { FILE_SWEEP_JOB } from '../files/collections'
45
+ import type { FILE_SWEEP_JOB } from '../files/kinds'
41
46
  import type { FILE_SCAN_JOB, FileScanner } from '../files/scan'
42
47
  import { boot, type BootOptions, type ContractApp } from '../implement/boot'
43
48
  import type { ChallengeFetch } from '../implement/challenge'
44
49
  import { type ConnectFetch, connectVariables } from '../implement/connect'
45
50
  import type { SpaAssets } from '../implement/dispatch'
46
51
  import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
52
+ import { readTokenKeys, SignedTokens } from '../implement/tokens'
47
53
  import type { WorkloadKindOf, WorkloadTokenSource } from '../implement/workload'
48
54
  import type { MailKeyScope } from '../jobs/mail'
49
55
  import type { HandlerErrorSink } from '../jobs/runner'
@@ -80,6 +86,11 @@ type MintRefusal = { readonly ok: false; readonly reason: 'disabled' | 'token_to
80
86
  export interface TestKitOptions {
81
87
  /** The application's own variables, as `deps` receives them. */
82
88
  readonly env?: ApplicationEnvironment
89
+ /**
90
+ * The address the kit's proxy reports for every request, as the platform's proxy reports the visitor's
91
+ * (`X-Fabrika-Client-Ip`): what `ctx.client.ip` reads and a per-client bucket counts. `192.0.2.1` by default.
92
+ */
93
+ readonly clientIp?: string
83
94
  /**
84
95
  * The application root an SPA mount's `dir` is relative to; the working directory when omitted. A mount whose built
85
96
  * directory exists there is served from it as the deploy serves it: a file by its path, every other path by the
@@ -90,9 +101,9 @@ export interface TestKitOptions {
90
101
  readonly assets?: SpaAssets
91
102
  /** The connection a `scoped(tables)` database is bound to, as the runtime adapter supplies it to `boot`. */
92
103
  readonly database?: Database
93
- /** The object storage of the file collections, as the runtime adapter supplies it to `boot`. */
104
+ /** The object storage of the stored files, as the runtime adapter supplies it to `boot`. */
94
105
  readonly files?: FileObjectStore
95
- /** The scanner of the scan job of `scan: true` collections; without it, the job leaves every upload quarantined. */
106
+ /** The scanner of the scan job of `scan: true` file kinds; without it, the job leaves every such file quarantined. */
96
107
  readonly scanner?: FileScanner
97
108
  /**
98
109
  * IAM's membership operations in place of the kit's own stand-in: `boot` asks it as the adapter's transport, and the
@@ -104,7 +115,7 @@ export interface TestKitOptions {
104
115
  * framework variables, and how a request reaches it — usually the owner kit's `fetch`.
105
116
  */
106
117
  readonly connect?: Readonly<Record<string, TestConnection>>
107
- /** What answers a form's challenge verification in place of the provider: a stand-in for its siteverify. */
118
+ /** What answers a public procedure's challenge verification in place of the provider: a stand-in for its siteverify. */
108
119
  readonly challengeFetch?: ChallengeFetch
109
120
  /** What a job's handler threw, shown on the developer's own machine: `fabrika app dev` sets it, a test need not. */
110
121
  readonly revealHandlerErrors?: HandlerErrorSink
@@ -116,7 +127,7 @@ export interface TestKitOptions {
116
127
  */
117
128
  readonly mail?: MemoryEmailSender | 'none'
118
129
  /**
119
- * The origins the environment lists for the contract's `crossOrigin` endpoints, as an operator sets them in
130
+ * The origins the environment lists for the contract's endpoints that admit `origins: 'listed'`, as an operator sets them in
120
131
  * Control; a page on one of them may call such an endpoint with a key. None when omitted.
121
132
  */
122
133
  readonly crossOrigins?: readonly string[]
@@ -161,12 +172,12 @@ export interface TestKit<C extends AnyContract> {
161
172
  acceptInvitation(invitationId: string, person?: LabelledTestCaller): LabelledTestCaller
162
173
  /**
163
174
  * Runs the cron job `name` once, as `fabrika job run <name>` does: under its lease, with the actions IAM signs for
164
- * its key, its writes fenced. It first waits for the runs a form's or a mutation's `then` kicked, so it never races them.
165
- * `FILE_SCAN_JOB` runs the scan of `scan: true` collections with the kit's `scanner`, `FILE_SWEEP_JOB` the sweep of
166
- * removed and orphaned files. Needs the application's data layer on Postgres.
175
+ * its key, its writes fenced. It first waits for the runs a site's `ctx.kick` sent, so it never races them.
176
+ * `FILE_SCAN_JOB` runs the scan of `scan: true` file kinds with the kit's `scanner`, `FILE_SWEEP_JOB` the sweep of
177
+ * unreferenced files. Needs the application's data layer on Postgres.
167
178
  */
168
179
  runJob(name: JobName<C> | typeof FILE_SCAN_JOB | typeof FILE_SWEEP_JOB, options?: JobRunOptions): Promise<JobRunResult>
169
- /** Waits for the work requests left running: the runs a form's or a mutation's `then` kicked, a failure's report. */
180
+ /** Waits for the work requests left running: the runs a site's `ctx.kick` sent, a failure's report. */
170
181
  drain(): Promise<void>
171
182
  /**
172
183
  * The stand-in for every job's transport: what reached it (`accepted`, `handoffs`) and its faults (`refuse`,
@@ -176,11 +187,24 @@ export interface TestKit<C extends AnyContract> {
176
187
  readonly mail: MemoryEmailSender
177
188
  /** Whose keys this kit's prefix separates: `mailKeyPrefix(kit.mailKeyScope)` is what a job's key gets in front. */
178
189
  readonly mailKeyScope: MailKeyScope
190
+ /**
191
+ * A token of `purpose`, signed with the key the kit handed the application, as a site's `ctx.tokens.sign` signs it:
192
+ * what a test sends where an input carries `signedToken('<purpose>')`.
193
+ */
194
+ signToken<const P extends TokenPurposeName<C>>(purpose: P, claims: TokenClaimsInput<C, P>, options: { readonly days: number }): string
179
195
  }
180
196
 
181
197
  /** The names of the contract's cron jobs. */
182
198
  export type JobName<C> = C extends { readonly surface: { readonly jobs: infer J } } ? keyof J & string : never
183
199
 
200
+ type TokensOf<C> = C extends { readonly surface: { readonly tokens: infer T } } ? T : Record<never, never>
201
+
202
+ /** The token purposes of the contract's kit. */
203
+ export type TokenPurposeName<C> = keyof TokensOf<C> & string
204
+
205
+ /** What a token of purpose `P` carries, as a site passes it to `sign`. */
206
+ export type TokenClaimsInput<C, P extends string> = TokensOf<C> extends { readonly [K in P]: TokenPurpose<infer Claims> } ? z.input<Claims> : never
207
+
184
208
  /** Each SPA mount from its built directory under `root` when one exists, as the deploy serves it; an empty page otherwise. */
185
209
  function builtAssets(root: string): SpaAssets {
186
210
  const disk = diskAssets(root)
@@ -190,13 +214,27 @@ function builtAssets(root: string): SpaAssets {
190
214
  }
191
215
  }
192
216
 
193
- function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Request {
217
+ function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Promise<Request> {
194
218
  if (!(input instanceof Request)) {
195
- return new Request(new URL(input, origin), init)
219
+ return withLength(new Request(new URL(input, origin), init), init?.body)
196
220
  }
197
221
  // Never `new Request(request, …)`: Bun would buffer the body the application must meet as a stream.
198
222
  const base = { method: input.method, headers: input.headers, body: input.body, signal: input.signal, redirect: input.redirect }
199
- return init === undefined ? input : new Request(input.url, { ...base, ...init })
223
+ return init === undefined ? Promise.resolve(input) : withLength(new Request(input.url, { ...base, ...init }), init.body)
224
+ }
225
+
226
+ /**
227
+ * `request` as a browser sends a body it knows the length of (a string, a form, bytes): with `Content-Length`, which a
228
+ * `bytesPerHour` budget charges. A stream goes without one, as `fetch` sends it chunked, and is charged the body limit.
229
+ */
230
+ async function withLength(request: Request, body: BodyInit | null | undefined): Promise<Request> {
231
+ if (body === undefined || body === null || body instanceof ReadableStream || request.headers.has('Content-Length')) {
232
+ return request
233
+ }
234
+ const headers = new Headers(request.headers)
235
+ const bytes = await request.arrayBuffer()
236
+ headers.set('Content-Length', String(bytes.byteLength))
237
+ return new Request(request.url, { method: request.method, headers, body: bytes, signal: request.signal, redirect: request.redirect })
200
238
  }
201
239
 
202
240
  /**
@@ -207,6 +245,9 @@ function withHeaders(request: Request, headers: Headers): Request {
207
245
  return new Request(request.url, { method: request.method, headers, body: request.body, signal: request.signal, redirect: request.redirect })
208
246
  }
209
247
 
248
+ /** The client every request of a kit comes from, unless `clientIp` names another: TEST-NET-1 (RFC 5737). */
249
+ const TEST_CLIENT_IP = '192.0.2.1'
250
+
210
251
  /** Every header only the proxy may assert, deleted before the decision as Caddy and the proxy Worker delete them. */
211
252
  function stripClientAsserted(headers: Headers): void {
212
253
  for (const header of [...headers.keys()]) {
@@ -283,7 +324,7 @@ export function bootTestKit<C extends AnyContract>(
283
324
  connectVariablesOf[variables.key] = connection.key
284
325
  connectBindings[variables.binding] = connection.fetch
285
326
  }
286
- // The kit stands in for Control too: every job and webhook gets a key, which its stand-in IAM signs the declared actions for.
327
+ // The kit stands in for Control too: every workload gets a key, which its stand-in IAM signs the declared actions for.
287
328
  const workloadVariables: Record<string, string> = {}
288
329
  const workloadsByKey = new Map<string, { readonly kind: WorkloadKindOf; readonly name: string; readonly actions: readonly string[] }>()
289
330
  // A job's key and the tokens signed for it are its credential at the stand-in IAM's link operations, as at IAM's.
@@ -292,13 +333,7 @@ export function bootTestKit<C extends AnyContract>(
292
333
  const job = Object.hasOwn(artifact.jobs, name) ? artifact.jobs[name] : undefined
293
334
  return job === undefined ? undefined : { id: `job-${name}`, actions: job.actions, issues: artifact.declared?.[`job:${name}`]?.issues ?? null }
294
335
  }
295
- const declaredWorkloads = [
296
- ...Object.entries(artifact.jobs).map(([workload, job]) => ({ kind: 'job' as const, name: workload, actions: job.actions })),
297
- ...Object.entries(artifact.mounts).flatMap(([workload, mount]) =>
298
- mount.kind === 'webhook' ? [{ kind: 'webhook' as const, name: workload, actions: mount.actions }] : []
299
- ),
300
- ]
301
- for (const workload of declaredWorkloads) {
336
+ for (const workload of contractWorkloads(artifact)) {
302
337
  const key = `${API_KEY_PREFIX}test_${crypto.randomUUID().replaceAll('-', '')}`
303
338
  workloadVariables[workloadKeyVariable(workload.kind, workload.name)] = key
304
339
  workloadsByKey.set(key, workload)
@@ -347,6 +382,19 @@ export function bootTestKit<C extends AnyContract>(
347
382
  const membership = options.membership ?? standIn?.rpc
348
383
  const mail = options.mail === undefined || options.mail === 'none' ? new MemoryEmailSender() : options.mail
349
384
  const mailKeyScope: MailKeyScope = Object.freeze({ issuer: TEST_ISSUER, appId })
385
+ // The kit stands in for Control's token key too: one random key, which signs and verifies.
386
+ const tokenKey = `${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}:${
387
+ Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
388
+ }`
389
+ const tokenVariables: Record<string, string> = artifact.tokens === undefined ? {} : { [FABRIKA_TOKEN_KEYS]: tokenKey }
390
+ const kitTokens = new SignedTokens(
391
+ new Map(
392
+ Object.entries(app.contract.surface.tokens ?? {}).map(([purpose, declared]) => [purpose, { claims: declared.claims, maxDays: declared.maxDays }]),
393
+ ),
394
+ readTokenKeys({ [FABRIKA_TOKEN_KEYS]: tokenKey }, { ...artifact, tokens: artifact.tokens ?? {} }, []),
395
+ )
396
+ const signToken = (purpose: string, claims: unknown, signOptions: { readonly days: number }): string =>
397
+ kitTokens.context('the test kit', [purpose]).sign(purpose, claims, signOptions)
350
398
  const application: ContractApp = boot(app, {
351
399
  environment: {
352
400
  // The deploy writes the approved contract's digest; the kit's contract is the approved one by definition.
@@ -354,8 +402,11 @@ export function bootTestKit<C extends AnyContract>(
354
402
  [FABRIKA_IAM_APP_ID]: appId,
355
403
  [FABRIKA_IAM_ISSUER]: TEST_ISSUER,
356
404
  [FABRIKA_CONTRACT_DIGEST]: contractDigest(artifact),
405
+ // The deploy writes the environment's public origin; the kit's is its own.
406
+ [FABRIKA_APP_ORIGIN]: origin,
357
407
  ...connectVariablesOf,
358
408
  ...workloadVariables,
409
+ ...tokenVariables,
359
410
  },
360
411
  application: options.env ?? {},
361
412
  },
@@ -371,6 +422,8 @@ export function bootTestKit<C extends AnyContract>(
371
422
  connectBindings,
372
423
  workloadTokens,
373
424
  mailStandIn: options.mail === 'none' ? 'none' : mail,
425
+ // The kit's proxy sets `X-Fabrika-Client-Ip` from the request it was handed, as the Worker sets it from Cloudflare's.
426
+ clientAddress: 'vouched',
374
427
  })
375
428
 
376
429
  let issued = 0
@@ -543,7 +596,7 @@ export function bootTestKit<C extends AnyContract>(
543
596
  ...(options.crossOrigins === undefined ? {} : { crossOrigins: options.crossOrigins }),
544
597
  })
545
598
 
546
- // What requests leave running: a job a form's or a mutation's `then` kicked, or a failure's report.
599
+ // What requests leave running: a job a site's `ctx.kick` sent, or a failure's report.
547
600
  const background = new Set<Promise<void>>()
548
601
  const exec: RequestExecutionContext = {
549
602
  waitUntil(promise) {
@@ -554,7 +607,7 @@ export function bootTestKit<C extends AnyContract>(
554
607
  }
555
608
 
556
609
  const kitFetch: RpcFetch = async (input, init) => {
557
- const request = toRequest(origin, input, init)
610
+ const request = await toRequest(origin, input, init)
558
611
  if (new URL(request.url).origin !== origin) {
559
612
  throw new TypeError(`the test kit serves only ${origin}, not ${new URL(request.url).origin}`)
560
613
  }
@@ -572,6 +625,7 @@ export function bootTestKit<C extends AnyContract>(
572
625
  for (const [header, value] of outcome.asserted) {
573
626
  headers.set(header, value)
574
627
  }
628
+ headers.set(CLIENT_ADDRESS_HEADER, options.clientIp ?? TEST_CLIENT_IP)
575
629
  const answer = await application.fetch(withHeaders(request, headers), exec)
576
630
  const decorated = new Headers(answer.headers)
577
631
  outcome.decorate(decorated)
@@ -582,8 +636,8 @@ export function bootTestKit<C extends AnyContract>(
582
636
  if (caller !== undefined && caller.cookie === null) {
583
637
  throw new Error(`a browser carries a session; pass the ${caller.kind}'s key to createClient instead`)
584
638
  }
585
- return (input, init) => {
586
- const request = toRequest(origin, input, init)
639
+ return async (input, init) => {
640
+ const request = await toRequest(origin, input, init)
587
641
  // Bun's Request does not keep its credentials mode, so `init` is the only reliable reading of it.
588
642
  const credentials = init?.credentials ?? (input instanceof Request ? input.credentials : undefined)
589
643
  const headers = new Headers(request.headers)
@@ -647,6 +701,7 @@ export function bootTestKit<C extends AnyContract>(
647
701
  acceptInvitation,
648
702
  mail,
649
703
  mailKeyScope,
704
+ signToken,
650
705
  }),
651
706
  standIn,
652
707
  callerOf: (value) => [...memberships.keys()].find((caller) => caller === value),
@@ -5,12 +5,14 @@
5
5
  // application's handlers and stores never run. Each attempt seeds two fresh rows that share no scope value and asks as
6
6
  // a caller holding every action of the table in the scope of one of them.
7
7
 
8
+ import { tableActions, type TablePolicyArtifact } from '@fabrika/auth-core'
8
9
  import { scoped, type Tables } from '@fabrika/db'
9
10
  import { tablePolicies } from '@fabrika/db/tables'
10
- import { type IsolationWorlds, type SampleValue, tableProbe, type TestDatabase, withoutInboxes } from '@fabrika/db/testing'
11
+ import { type IsolationWorlds, type SampleValue, tableProbe, type TestDatabase } from '@fabrika/db/testing'
12
+ import { MemoryFileObjects } from '@fabrika/platform/testing'
11
13
  import { z } from 'zod/v4'
12
14
  import { createClient } from '../client/contract'
13
- import { type AnyContract, defineContract, type Vocabulary } from '../contract/define'
15
+ import { type AnyContract, defineContract, type FileKind } from '../contract/define'
14
16
  import { implement } from '../implement/implement'
15
17
  import type { TestGrant } from './caller'
16
18
  import { testKit } from './kit'
@@ -141,6 +143,20 @@ interface ProbeActions {
141
143
  readonly insert: string | null
142
144
  readonly update: string | null
143
145
  readonly delete: string | null
146
+ /** Every read action of the table: an action besides them is another action. */
147
+ readonly reads: readonly string[]
148
+ }
149
+
150
+ /** The actions a probe holds: the first of each operation, as the generated cases name them. */
151
+ function probeActions(actions: TablePolicyArtifact['actions']): ProbeActions {
152
+ const reads = tableActions(actions.read)
153
+ return {
154
+ read: reads[0] ?? '',
155
+ insert: tableActions(actions.insert)[0] ?? null,
156
+ update: tableActions(actions.update)[0] ?? null,
157
+ delete: tableActions(actions.delete)[0] ?? null,
158
+ reads,
159
+ }
144
160
  }
145
161
 
146
162
  function isCapabilityError(error: unknown): boolean {
@@ -155,14 +171,20 @@ async function attempt<T extends object>(run: () => Promise<T>, failed: (status:
155
171
  }
156
172
  }
157
173
 
174
+ /** The file kinds `contract` declares: each probe declares them too, so the file columns of the tables name declared kinds. */
175
+ function fileKindsOf(contract: AnyContract): Readonly<Record<string, FileKind>> {
176
+ return Object.fromEntries(Object.entries(contract.surface.files ?? {}).filter((entry): entry is [string, FileKind] => entry[1].kind === 'file-kind'))
177
+ }
178
+
158
179
  /** A write's outcome: 200 when it ran, the status otherwise. */
159
180
  async function wrote(run: () => Promise<unknown>): Promise<IsolationOutcome> {
160
181
  return { status: await statusOf(run()) }
161
182
  }
162
183
 
163
- function dimensionProbe(vocabulary: Vocabulary<string, string, string>, dimension: string, list: string, actions: ProbeActions) {
184
+ function dimensionProbe(contract: AnyContract, dimension: string, list: string, actions: ProbeActions) {
164
185
  const { read } = actions
165
- return defineContract(vocabulary, (c) => ({
186
+ return defineContract(contract.vocabulary, (c) => ({
187
+ files: fileKindsOf(contract),
166
188
  probe: c.rpc('/probe', { callers: ['human'] }, {
167
189
  get: c.query({ input: ById, output: Columns, access: { action: read, on: dimension, from: 'id' } }),
168
190
  list: c.query({ output: Keys, access: { action: read, over: list } }),
@@ -176,9 +198,10 @@ function dimensionProbe(vocabulary: Vocabulary<string, string, string>, dimensio
176
198
  }))
177
199
  }
178
200
 
179
- function childProbe(vocabulary: Vocabulary<string, string, string>, parent: string, list: string, actions: ProbeActions) {
201
+ function childProbe(contract: AnyContract, parent: string, list: string, actions: ProbeActions) {
180
202
  const { read } = actions
181
- return defineContract(vocabulary, (c) => ({
203
+ return defineContract(contract.vocabulary, (c) => ({
204
+ files: fileKindsOf(contract),
182
205
  probe: c.rpc('/probe', { callers: ['human'] }, {
183
206
  get: c.query({ input: ChildByKey, output: Columns, access: { action: read, on: parent, from: 'parent' } }),
184
207
  list: c.query({ output: Keys, access: { action: read, over: list } }),
@@ -195,12 +218,13 @@ function childProbe(vocabulary: Vocabulary<string, string, string>, parent: stri
195
218
 
196
219
  /** An action the table names besides its read action, or `null` when it names none. */
197
220
  function otherAction(actions: ProbeActions): string | null {
198
- return [actions.insert, actions.update, actions.delete].find((action) => action !== null && action !== actions.read) ?? null
221
+ return [actions.insert, actions.update, actions.delete].find((action) => action !== null && !actions.reads.includes(action)) ?? null
199
222
  }
200
223
 
201
- function appProbe(vocabulary: Vocabulary<string, string, string>, actions: ProbeActions) {
224
+ function appProbe(contract: AnyContract, actions: ProbeActions) {
202
225
  const { read } = actions
203
- return defineContract(vocabulary, (c) => ({
226
+ return defineContract(contract.vocabulary, (c) => ({
227
+ files: fileKindsOf(contract),
204
228
  probe: c.rpc('/probe', { callers: ['human'] }, {
205
229
  get: c.query({ input: ById, output: Columns, access: { action: read, app: true } }),
206
230
  list: c.query({ output: Keys, access: { action: read, app: true } }),
@@ -214,8 +238,9 @@ function appProbe(vocabulary: Vocabulary<string, string, string>, actions: Probe
214
238
  }
215
239
 
216
240
  /** Scoped sites over an application table: `over` the vocabulary's first dimension, of its read action and of another. */
217
- function scopedAppProbe(vocabulary: Vocabulary<string, string, string>, actions: ProbeActions, dimension: string) {
218
- return defineContract(vocabulary, (c) => ({
241
+ function scopedAppProbe(contract: AnyContract, actions: ProbeActions, dimension: string) {
242
+ return defineContract(contract.vocabulary, (c) => ({
243
+ files: fileKindsOf(contract),
219
244
  probe: c.rpc('/probe', { callers: ['human'] }, {
220
245
  list: c.query({ output: Keys, access: { action: actions.read, over: dimension } }),
221
246
  listWithOther: c.query({ output: Refused, access: { action: otherAction(actions) ?? actions.read, over: dimension } }),
@@ -241,8 +266,8 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
241
266
  if (scope.mode !== 'app') {
242
267
  throw new Error(`${table} is a ${scope.mode} table; policyIsolationKit probes a dimension or child table`)
243
268
  }
244
- const actions: ProbeActions = policy.actions
245
- const layer = scoped(withoutInboxes(tables))
269
+ const actions = probeActions(policy.actions)
270
+ const layer = scoped(tables)
246
271
  const probe = tableProbe(layer, table)
247
272
  const expectVersion = policy.version === null ? undefined : 1
248
273
  const staged = new Map<string, Readonly<Record<string, SampleValue>>>()
@@ -275,7 +300,7 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
275
300
  throw error
276
301
  }
277
302
  }
278
- const probeContract = appProbe(contract.vocabulary, actions)
303
+ const probeContract = appProbe(contract, actions)
279
304
  const kit = testKit(
280
305
  implement(probeContract, {
281
306
  database: layer,
@@ -301,7 +326,7 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
301
326
  },
302
327
  }),
303
328
  }),
304
- { database: postgres.database },
329
+ { database: postgres.database, files: new MemoryFileObjects() },
305
330
  )
306
331
  const held = [...new Set([actions.read, actions.insert, actions.update, actions.delete].filter((action) => action !== null))]
307
332
  const dimension = scopeTypeOf(contract.vocabulary.dimensions)
@@ -310,7 +335,7 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
310
335
  if (!Object.hasOwn(contract.vocabulary.dimensions, dimension)) {
311
336
  throw new Error(`the vocabulary declares no dimension, so no scoped site reads ${table}`)
312
337
  }
313
- scopedSites ??= scopedAppKit(scopedAppProbe(contract.vocabulary, actions, dimension), tables, table, scope.key, postgres)
338
+ scopedSites ??= scopedAppKit(scopedAppProbe(contract, actions, dimension), tables, table, scope.key, postgres)
314
339
  return scopedSites.as(action, dimension)
315
340
  }
316
341
  const grantsOf = (caller: AppCaller): TestGrant<AnyContract>[] =>
@@ -389,8 +414,9 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
389
414
  }
390
415
 
391
416
  /** `app` endpoints of one action over a dimension or child table: what its `appRead` and `appCount` reach. */
392
- function appReachProbe(vocabulary: Vocabulary<string, string, string>, action: string) {
393
- return defineContract(vocabulary, (c) => ({
417
+ function appReachProbe(contract: AnyContract, action: string) {
418
+ return defineContract(contract.vocabulary, (c) => ({
419
+ files: fileKindsOf(contract),
394
420
  probe: c.rpc('/probe', { callers: ['human'] }, {
395
421
  list: c.query({ output: Keys, access: { action, app: true } }),
396
422
  count: c.query({ output: Count, access: { action, app: true } }),
@@ -430,8 +456,8 @@ function appReachKit<DB, P>(
430
456
  }
431
457
  const kits = new Map<string, ReturnType<typeof appReachClient>>()
432
458
  const appReachClient = (action: string) => {
433
- const probeContract = appReachProbe(contract.vocabulary, action)
434
- const layer = scoped(withoutInboxes(tables))
459
+ const probeContract = appReachProbe(contract, action)
460
+ const layer = scoped(tables)
435
461
  const probe = tableProbe(layer, table)
436
462
  const kit = testKit(
437
463
  implement(probeContract, {
@@ -447,7 +473,7 @@ function appReachKit<DB, P>(
447
473
  },
448
474
  }),
449
475
  }),
450
- { database: postgres.database },
476
+ { database: postgres.database, files: new MemoryFileObjects() },
451
477
  )
452
478
  return createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller([{ action }])) })
453
479
  }
@@ -505,7 +531,7 @@ function scopedAppKit<DB, P>(
505
531
  key: string,
506
532
  postgres: TestDatabase,
507
533
  ) {
508
- const layer = scoped(withoutInboxes(tables))
534
+ const layer = scoped(tables)
509
535
  const probe = tableProbe(layer, table)
510
536
  const kit = testKit(
511
537
  implement(probeContract, {
@@ -528,7 +554,7 @@ function scopedAppKit<DB, P>(
528
554
  },
529
555
  }),
530
556
  }),
531
- { database: postgres.database },
557
+ { database: postgres.database, files: new MemoryFileObjects() },
532
558
  )
533
559
  return {
534
560
  as: (action: string, dimension: string) =>
@@ -557,9 +583,9 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
557
583
  if (scope.mode !== 'dimension' && scope.mode !== 'child') {
558
584
  throw new Error(`${table} is a ${scope.mode} table; no store reaches it, so it has no isolation tests`)
559
585
  }
560
- const actions: ProbeActions = policy.actions
586
+ const actions = probeActions(policy.actions)
561
587
  // The probe serves no form, so it compiles against the policies without the forms' inboxes.
562
- const layer = scoped(withoutInboxes(tables))
588
+ const layer = scoped(tables)
563
589
  const probe = tableProbe(layer, table)
564
590
  const expectVersion = policy.version === null ? undefined : 1
565
591
  const pending = new Map<string, Readonly<Record<string, SampleValue>>>()
@@ -603,7 +629,7 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
603
629
  if (scope.mode === 'dimension') {
604
630
  const dimension = scope.dimension
605
631
  const list = scope.container?.dimension ?? dimension
606
- const probeContract = dimensionProbe(contract.vocabulary, dimension, list, actions)
632
+ const probeContract = dimensionProbe(contract, dimension, list, actions)
607
633
  const kit = testKit(
608
634
  implement(probeContract, {
609
635
  database: layer,
@@ -630,7 +656,7 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
630
656
  },
631
657
  }),
632
658
  }),
633
- { database: postgres.database },
659
+ { database: postgres.database, files: new MemoryFileObjects() },
634
660
  )
635
661
  const session = (worlds: IsolationWorlds) => createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller(grantsIn(worlds))) })
636
662
  const keyOf = (worlds: IsolationWorlds, row: ChildIsolationRow): string => {
@@ -705,7 +731,7 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
705
731
  }
706
732
  const parent = parentPolicy.scope.dimension
707
733
  const list = parentPolicy.scope.container?.dimension ?? parent
708
- const probeContract = childProbe(contract.vocabulary, parent, list, actions)
734
+ const probeContract = childProbe(contract, parent, list, actions)
709
735
  const kit = testKit(
710
736
  implement(probeContract, {
711
737
  database: layer,
@@ -733,7 +759,7 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
733
759
  },
734
760
  }),
735
761
  }),
736
- { database: postgres.database },
762
+ { database: postgres.database, files: new MemoryFileObjects() },
737
763
  )
738
764
  const session = (worlds: IsolationWorlds) => createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller(grantsIn(worlds))) })
739
765
  /** The parent and key a request names: a crossed row is the other key under the caller's own parent. */
@@ -7,6 +7,7 @@ import {
7
7
  createVerifyService,
8
8
  DEFAULT_VERIFY_PATH,
9
9
  type DelegationIssuer,
10
+ FORWARD_AUTHORIZATION_HEADER,
10
11
  FORWARDED_HOST_HEADER,
11
12
  FORWARDED_METHOD_HEADER,
12
13
  FORWARDED_PROTO_HEADER,
@@ -26,7 +27,10 @@ export type ProxyOutcome =
26
27
  readonly outcome: 'forward'
27
28
  /** Every header only the proxy may assert, with the value the decision gave it. */
28
29
  readonly asserted: ReadonlyMap<string, string>
29
- /** Whether the request's own `Authorization` reaches the application: not on an anonymous endpoint. */
30
+ /**
31
+ * Whether the request's own `Authorization` reaches the application: not on an anonymous endpoint, except a webhook
32
+ * row the decision marked to pass it on.
33
+ */
30
34
  readonly keepsAuthorization: boolean
31
35
  /** What the application's answer is given for an admitted cross-origin request, over its own headers. */
32
36
  readonly decorate: (headers: Headers) => void
@@ -104,7 +108,7 @@ export function endpointProxy(options: EndpointProxyOptions): TestProxy {
104
108
  return {
105
109
  outcome: 'forward',
106
110
  asserted,
107
- keepsAuthorization: asserted.has(PROXY_TOKEN_HEADER),
111
+ keepsAuthorization: asserted.has(PROXY_TOKEN_HEADER) || verification.headers.get(FORWARD_AUTHORIZATION_HEADER) === '1',
108
112
  decorate: (answer) => {
109
113
  if (allowOrigin !== null) applyCrossOriginHeaders(answer, crossOriginHeaders(allowOrigin))
110
114
  },
@@ -0,0 +1,30 @@
1
+ // A small file the testing kits write: the leading bytes `files/sniff.ts` accepts for its type, then a text marker.
2
+
3
+ const randomHex = (): string => crypto.randomUUID().replaceAll('-', '').slice(0, 8)
4
+
5
+ const ascii = (text: string): number[] => [...text].map((char) => char.charCodeAt(0))
6
+ const ZIP_HEAD = [0x50, 0x4b, 0x03, 0x04]
7
+
8
+ /** The leading bytes `files/sniff.ts` accepts for each binary type it verifies; a text type needs none. */
9
+ const SAMPLE_HEADS: Readonly<Record<string, readonly number[]>> = {
10
+ 'image/png': [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
11
+ 'image/jpeg': [0xff, 0xd8, 0xff, 0xe0],
12
+ 'image/gif': ascii('GIF89a'),
13
+ 'image/webp': [...ascii('RIFF'), 0, 0, 0, 0, ...ascii('WEBP')],
14
+ 'application/pdf': ascii('%PDF-1.7\n'),
15
+ 'application/gzip': [0x1f, 0x8b, 0x08],
16
+ 'application/zip': ZIP_HEAD,
17
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_HEAD,
18
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_HEAD,
19
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_HEAD,
20
+ }
21
+ const TEXT_TYPES: ReadonlySet<string> = new Set(['text/plain', 'text/csv', 'text/markdown', 'application/json', 'image/svg+xml'])
22
+
23
+ /** A file of the first of `types` the kit can write; `name` (a collection or a kind) names it in the error when none is. */
24
+ export function sampleFile(name: string, types: readonly string[]): { readonly type: string; readonly bytes: Uint8Array<ArrayBuffer> } {
25
+ for (const type of types) {
26
+ const head = SAMPLE_HEADS[type] ?? (TEXT_TYPES.has(type) ? [] : undefined)
27
+ if (head !== undefined) return { type, bytes: new Uint8Array([...head, ...ascii(`fabrika test file ${randomHex()}`)]) }
28
+ }
29
+ throw new Error(`files.${name}: none of its types is one the testing kit can write a file of`)
30
+ }