@fabrika/app 0.6.0 → 0.8.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 (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -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 +450 -255
  10. package/skill/references/spa.md +6 -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 +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  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 +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. 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,22 @@ 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'
39
+ import { challengeUrl } from '../client/contract'
35
40
  import type { RpcFetch } from '../client/transport'
36
41
  import { compileContract } from '../contract/compile'
37
42
  import type { AnyContract, DimensionOf } from '../contract/define'
43
+ import type { TokenPurpose } from '../contract/token'
38
44
  import { diskAssets } from '../disk-assets'
39
45
  import type { RequestExecutionContext } from '../fetch'
40
- import type { FILE_SWEEP_JOB } from '../files/collections'
46
+ import type { FILE_SWEEP_JOB } from '../files/kinds'
41
47
  import type { FILE_SCAN_JOB, FileScanner } from '../files/scan'
42
48
  import { boot, type BootOptions, type ContractApp } from '../implement/boot'
43
- import type { ChallengeFetch } from '../implement/challenge'
49
+ import { type ChallengeFetch, MINIMAL_CHALLENGE_WORK } from '../implement/challenge'
44
50
  import { type ConnectFetch, connectVariables } from '../implement/connect'
45
51
  import type { SpaAssets } from '../implement/dispatch'
46
52
  import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
53
+ import { readTokenKeys, SignedTokens } from '../implement/tokens'
47
54
  import type { WorkloadKindOf, WorkloadTokenSource } from '../implement/workload'
48
55
  import type { MailKeyScope } from '../jobs/mail'
49
56
  import type { HandlerErrorSink } from '../jobs/runner'
@@ -58,6 +65,7 @@ import {
58
65
  type TestGrant,
59
66
  type TestScope,
60
67
  } from './caller'
68
+ import { solveChallengeAt } from './challenge'
61
69
  import {
62
70
  credentialCallers,
63
71
  type JobCredential,
@@ -80,6 +88,11 @@ type MintRefusal = { readonly ok: false; readonly reason: 'disabled' | 'token_to
80
88
  export interface TestKitOptions {
81
89
  /** The application's own variables, as `deps` receives them. */
82
90
  readonly env?: ApplicationEnvironment
91
+ /**
92
+ * The address the kit's proxy reports for every request, as the platform's proxy reports the visitor's
93
+ * (`X-Fabrika-Client-Ip`): what `ctx.client.ip` reads and a per-client bucket counts. `192.0.2.1` by default.
94
+ */
95
+ readonly clientIp?: string
83
96
  /**
84
97
  * The application root an SPA mount's `dir` is relative to; the working directory when omitted. A mount whose built
85
98
  * directory exists there is served from it as the deploy serves it: a file by its path, every other path by the
@@ -90,9 +103,9 @@ export interface TestKitOptions {
90
103
  readonly assets?: SpaAssets
91
104
  /** The connection a `scoped(tables)` database is bound to, as the runtime adapter supplies it to `boot`. */
92
105
  readonly database?: Database
93
- /** The object storage of the file collections, as the runtime adapter supplies it to `boot`. */
106
+ /** The object storage of the stored files, as the runtime adapter supplies it to `boot`. */
94
107
  readonly files?: FileObjectStore
95
- /** The scanner of the scan job of `scan: true` collections; without it, the job leaves every upload quarantined. */
108
+ /** The scanner of the scan job of `scan: true` file kinds; without it, the job leaves every such file quarantined. */
96
109
  readonly scanner?: FileScanner
97
110
  /**
98
111
  * 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 +117,7 @@ export interface TestKitOptions {
104
117
  * framework variables, and how a request reaches it — usually the owner kit's `fetch`.
105
118
  */
106
119
  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. */
120
+ /** What answers a public procedure's challenge verification in place of the provider: a stand-in for its siteverify. */
108
121
  readonly challengeFetch?: ChallengeFetch
109
122
  /** What a job's handler threw, shown on the developer's own machine: `fabrika app dev` sets it, a test need not. */
110
123
  readonly revealHandlerErrors?: HandlerErrorSink
@@ -116,7 +129,7 @@ export interface TestKitOptions {
116
129
  */
117
130
  readonly mail?: MemoryEmailSender | 'none'
118
131
  /**
119
- * The origins the environment lists for the contract's `crossOrigin` endpoints, as an operator sets them in
132
+ * The origins the environment lists for the contract's endpoints that admit `origins: 'listed'`, as an operator sets them in
120
133
  * Control; a page on one of them may call such an endpoint with a key. None when omitted.
121
134
  */
122
135
  readonly crossOrigins?: readonly string[]
@@ -161,12 +174,12 @@ export interface TestKit<C extends AnyContract> {
161
174
  acceptInvitation(invitationId: string, person?: LabelledTestCaller): LabelledTestCaller
162
175
  /**
163
176
  * 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.
177
+ * its key, its writes fenced. It first waits for the runs a site's `ctx.kick` sent, so it never races them.
178
+ * `FILE_SCAN_JOB` runs the scan of `scan: true` file kinds with the kit's `scanner`, `FILE_SWEEP_JOB` the sweep of
179
+ * unreferenced files. Needs the application's data layer on Postgres.
167
180
  */
168
181
  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. */
182
+ /** Waits for the work requests left running: the runs a site's `ctx.kick` sent, a failure's report. */
170
183
  drain(): Promise<void>
171
184
  /**
172
185
  * The stand-in for every job's transport: what reached it (`accepted`, `handoffs`) and its faults (`refuse`,
@@ -176,11 +189,30 @@ export interface TestKit<C extends AnyContract> {
176
189
  readonly mail: MemoryEmailSender
177
190
  /** Whose keys this kit's prefix separates: `mailKeyPrefix(kit.mailKeyScope)` is what a job's key gets in front. */
178
191
  readonly mailKeyScope: MailKeyScope
192
+ /**
193
+ * A token of `purpose`, signed with the key the kit handed the application, as a site's `ctx.tokens.sign` signs it:
194
+ * what a test sends where an input carries `signedToken('<purpose>')`.
195
+ */
196
+ signToken<const P extends TokenPurposeName<C>>(purpose: P, claims: TokenClaimsInput<C, P>, options: { readonly days: number }): string
197
+ /**
198
+ * A solved ALTCHA challenge of `procedure`, a procedure of a client over this kit whose challenge is
199
+ * `provider: 'altcha'`: what the page sends as `challengeToken`. Each one passes once. The kit issues the cheapest
200
+ * challenge whatever the contract's `cost`, so solving one takes milliseconds.
201
+ */
202
+ solveChallenge(procedure: object): Promise<string>
179
203
  }
180
204
 
181
205
  /** The names of the contract's cron jobs. */
182
206
  export type JobName<C> = C extends { readonly surface: { readonly jobs: infer J } } ? keyof J & string : never
183
207
 
208
+ type TokensOf<C> = C extends { readonly surface: { readonly tokens: infer T } } ? T : Record<never, never>
209
+
210
+ /** The token purposes of the contract's kit. */
211
+ export type TokenPurposeName<C> = keyof TokensOf<C> & string
212
+
213
+ /** What a token of purpose `P` carries, as a site passes it to `sign`. */
214
+ export type TokenClaimsInput<C, P extends string> = TokensOf<C> extends { readonly [K in P]: TokenPurpose<infer Claims> } ? z.input<Claims> : never
215
+
184
216
  /** Each SPA mount from its built directory under `root` when one exists, as the deploy serves it; an empty page otherwise. */
185
217
  function builtAssets(root: string): SpaAssets {
186
218
  const disk = diskAssets(root)
@@ -190,13 +222,27 @@ function builtAssets(root: string): SpaAssets {
190
222
  }
191
223
  }
192
224
 
193
- function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Request {
225
+ function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Promise<Request> {
194
226
  if (!(input instanceof Request)) {
195
- return new Request(new URL(input, origin), init)
227
+ return withLength(new Request(new URL(input, origin), init), init?.body)
196
228
  }
197
229
  // Never `new Request(request, …)`: Bun would buffer the body the application must meet as a stream.
198
230
  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 })
231
+ return init === undefined ? Promise.resolve(input) : withLength(new Request(input.url, { ...base, ...init }), init.body)
232
+ }
233
+
234
+ /**
235
+ * `request` as a browser sends a body it knows the length of (a string, a form, bytes): with `Content-Length`, which a
236
+ * `bytesPerHour` budget charges. A stream goes without one, as `fetch` sends it chunked, and is charged the body limit.
237
+ */
238
+ async function withLength(request: Request, body: BodyInit | null | undefined): Promise<Request> {
239
+ if (body === undefined || body === null || body instanceof ReadableStream || request.headers.has('Content-Length')) {
240
+ return request
241
+ }
242
+ const headers = new Headers(request.headers)
243
+ const bytes = await request.arrayBuffer()
244
+ headers.set('Content-Length', String(bytes.byteLength))
245
+ return new Request(request.url, { method: request.method, headers, body: bytes, signal: request.signal, redirect: request.redirect })
200
246
  }
201
247
 
202
248
  /**
@@ -207,6 +253,9 @@ function withHeaders(request: Request, headers: Headers): Request {
207
253
  return new Request(request.url, { method: request.method, headers, body: request.body, signal: request.signal, redirect: request.redirect })
208
254
  }
209
255
 
256
+ /** The client every request of a kit comes from, unless `clientIp` names another: TEST-NET-1 (RFC 5737). */
257
+ const TEST_CLIENT_IP = '192.0.2.1'
258
+
210
259
  /** Every header only the proxy may assert, deleted before the decision as Caddy and the proxy Worker delete them. */
211
260
  function stripClientAsserted(headers: Headers): void {
212
261
  for (const header of [...headers.keys()]) {
@@ -283,7 +332,7 @@ export function bootTestKit<C extends AnyContract>(
283
332
  connectVariablesOf[variables.key] = connection.key
284
333
  connectBindings[variables.binding] = connection.fetch
285
334
  }
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.
335
+ // The kit stands in for Control too: every workload gets a key, which its stand-in IAM signs the declared actions for.
287
336
  const workloadVariables: Record<string, string> = {}
288
337
  const workloadsByKey = new Map<string, { readonly kind: WorkloadKindOf; readonly name: string; readonly actions: readonly string[] }>()
289
338
  // 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 +341,7 @@ export function bootTestKit<C extends AnyContract>(
292
341
  const job = Object.hasOwn(artifact.jobs, name) ? artifact.jobs[name] : undefined
293
342
  return job === undefined ? undefined : { id: `job-${name}`, actions: job.actions, issues: artifact.declared?.[`job:${name}`]?.issues ?? null }
294
343
  }
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) {
344
+ for (const workload of contractWorkloads(artifact)) {
302
345
  const key = `${API_KEY_PREFIX}test_${crypto.randomUUID().replaceAll('-', '')}`
303
346
  workloadVariables[workloadKeyVariable(workload.kind, workload.name)] = key
304
347
  workloadsByKey.set(key, workload)
@@ -347,6 +390,19 @@ export function bootTestKit<C extends AnyContract>(
347
390
  const membership = options.membership ?? standIn?.rpc
348
391
  const mail = options.mail === undefined || options.mail === 'none' ? new MemoryEmailSender() : options.mail
349
392
  const mailKeyScope: MailKeyScope = Object.freeze({ issuer: TEST_ISSUER, appId })
393
+ // The kit stands in for Control's token key too: one random key, which signs and verifies.
394
+ const tokenKey = `${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}:${
395
+ Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
396
+ }`
397
+ const tokenVariables: Record<string, string> = artifact.tokens === undefined ? {} : { [FABRIKA_TOKEN_KEYS]: tokenKey }
398
+ const kitTokens = new SignedTokens(
399
+ new Map(
400
+ Object.entries(app.contract.surface.tokens ?? {}).map(([purpose, declared]) => [purpose, { claims: declared.claims, maxDays: declared.maxDays }]),
401
+ ),
402
+ readTokenKeys({ [FABRIKA_TOKEN_KEYS]: tokenKey }, { ...artifact, tokens: artifact.tokens ?? {} }, []),
403
+ )
404
+ const signToken = (purpose: string, claims: unknown, signOptions: { readonly days: number }): string =>
405
+ kitTokens.context('the test kit', [purpose]).sign(purpose, claims, signOptions)
350
406
  const application: ContractApp = boot(app, {
351
407
  environment: {
352
408
  // The deploy writes the approved contract's digest; the kit's contract is the approved one by definition.
@@ -354,8 +410,11 @@ export function bootTestKit<C extends AnyContract>(
354
410
  [FABRIKA_IAM_APP_ID]: appId,
355
411
  [FABRIKA_IAM_ISSUER]: TEST_ISSUER,
356
412
  [FABRIKA_CONTRACT_DIGEST]: contractDigest(artifact),
413
+ // The deploy writes the environment's public origin; the kit's is its own.
414
+ [FABRIKA_APP_ORIGIN]: origin,
357
415
  ...connectVariablesOf,
358
416
  ...workloadVariables,
417
+ ...tokenVariables,
359
418
  },
360
419
  application: options.env ?? {},
361
420
  },
@@ -365,12 +424,15 @@ export function bootTestKit<C extends AnyContract>(
365
424
  files: options.files,
366
425
  ...(options.scanner === undefined ? {} : { scanner: options.scanner }),
367
426
  ...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
427
+ challengeWork: MINIMAL_CHALLENGE_WORK,
368
428
  ...dev,
369
429
  ...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
370
430
  membership,
371
431
  connectBindings,
372
432
  workloadTokens,
373
433
  mailStandIn: options.mail === 'none' ? 'none' : mail,
434
+ // The kit's proxy sets `X-Fabrika-Client-Ip` from the request it was handed, as the Worker sets it from Cloudflare's.
435
+ clientAddress: 'vouched',
374
436
  })
375
437
 
376
438
  let issued = 0
@@ -543,7 +605,7 @@ export function bootTestKit<C extends AnyContract>(
543
605
  ...(options.crossOrigins === undefined ? {} : { crossOrigins: options.crossOrigins }),
544
606
  })
545
607
 
546
- // What requests leave running: a job a form's or a mutation's `then` kicked, or a failure's report.
608
+ // What requests leave running: a job a site's `ctx.kick` sent, or a failure's report.
547
609
  const background = new Set<Promise<void>>()
548
610
  const exec: RequestExecutionContext = {
549
611
  waitUntil(promise) {
@@ -554,7 +616,7 @@ export function bootTestKit<C extends AnyContract>(
554
616
  }
555
617
 
556
618
  const kitFetch: RpcFetch = async (input, init) => {
557
- const request = toRequest(origin, input, init)
619
+ const request = await toRequest(origin, input, init)
558
620
  if (new URL(request.url).origin !== origin) {
559
621
  throw new TypeError(`the test kit serves only ${origin}, not ${new URL(request.url).origin}`)
560
622
  }
@@ -572,6 +634,7 @@ export function bootTestKit<C extends AnyContract>(
572
634
  for (const [header, value] of outcome.asserted) {
573
635
  headers.set(header, value)
574
636
  }
637
+ headers.set(CLIENT_ADDRESS_HEADER, options.clientIp ?? TEST_CLIENT_IP)
575
638
  const answer = await application.fetch(withHeaders(request, headers), exec)
576
639
  const decorated = new Headers(answer.headers)
577
640
  outcome.decorate(decorated)
@@ -582,8 +645,8 @@ export function bootTestKit<C extends AnyContract>(
582
645
  if (caller !== undefined && caller.cookie === null) {
583
646
  throw new Error(`a browser carries a session; pass the ${caller.kind}'s key to createClient instead`)
584
647
  }
585
- return (input, init) => {
586
- const request = toRequest(origin, input, init)
648
+ return async (input, init) => {
649
+ const request = await toRequest(origin, input, init)
587
650
  // Bun's Request does not keep its credentials mode, so `init` is the only reliable reading of it.
588
651
  const credentials = init?.credentials ?? (input instanceof Request ? input.credentials : undefined)
589
652
  const headers = new Headers(request.headers)
@@ -647,6 +710,8 @@ export function bootTestKit<C extends AnyContract>(
647
710
  acceptInvitation,
648
711
  mail,
649
712
  mailKeyScope,
713
+ signToken,
714
+ solveChallenge: (procedure: object) => solveChallengeAt(kitFetch, new URL(challengeUrl(procedure), origin).href),
650
715
  }),
651
716
  standIn,
652
717
  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
+ }