@fabrika/app 0.9.0-cloudflare.1 → 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 (41) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +4 -2
  3. package/skill/references/app-setup.md +29 -11
  4. package/skill/references/endpoint.md +5 -0
  5. package/skill/references/errors.md +31 -31
  6. package/skill/references/jobs.md +9 -0
  7. package/skill/references/tests.md +22 -1
  8. package/src/build.ts +6 -1
  9. package/src/bun.ts +10 -0
  10. package/src/cloudflare/application.ts +3 -3
  11. package/src/cloudflare/bindings.ts +26 -5
  12. package/src/cloudflare/names.ts +6 -0
  13. package/src/contract/artifact-schema.ts +6 -1
  14. package/src/contract/build.ts +33 -10
  15. package/src/contract/group.ts +10 -1
  16. package/src/contract/kit.ts +2 -1
  17. package/src/disk-assets.ts +8 -3
  18. package/src/implement/boot.ts +9 -3
  19. package/src/implement/capability.ts +3 -0
  20. package/src/implement/dispatch.ts +4 -4
  21. package/src/implement/egress.ts +341 -8
  22. package/src/implement/invocation.ts +10 -9
  23. package/src/jobs/runner.ts +7 -2
  24. package/src/spa-fallback.ts +12 -0
  25. package/src/testing/endpoint-access.ts +135 -23
  26. package/src/testing/isolation.ts +8 -1
  27. package/types/src/build.d.ts +1 -1
  28. package/types/src/cloudflare/bindings.d.ts +6 -0
  29. package/types/src/cloudflare/names.d.ts +5 -0
  30. package/types/src/contract/group.d.ts +9 -1
  31. package/types/src/contract/kit.d.ts +2 -2
  32. package/types/src/disk-assets.d.ts +4 -1
  33. package/types/src/implement/boot.d.ts +6 -0
  34. package/types/src/implement/capability.d.ts +2 -0
  35. package/types/src/implement/dispatch.d.ts +2 -1
  36. package/types/src/implement/egress.d.ts +36 -1
  37. package/types/src/implement/invocation.d.ts +3 -3
  38. package/types/src/jobs/runner.d.ts +3 -0
  39. package/types/src/spa-fallback.d.ts +2 -0
  40. package/types/src/testing/endpoint-access.d.ts +25 -1
  41. package/types/src/testing/isolation.d.ts +1 -1
@@ -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'
@@ -110,6 +114,54 @@ export interface EndpointAccessKit {
110
114
  anyone(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>
111
115
  }
112
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
+
113
165
  interface ActionEndpoint {
114
166
  readonly id: string
115
167
  /** The link mount of a link procedure; `null` for every other endpoint. */
@@ -285,7 +337,12 @@ export const managerGrants = (
285
337
  * The endpoint access kit of `app`, whose data layer is `scoped(tables)`, over `postgres`. It boots `app` once, so the
286
338
  * application's layer is bound here and nowhere else in the process.
287
339
  */
288
- 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 {
289
346
  // The objects of the files the cases seed live in memory; their rows are the application's own tables.
290
347
  const objects = new MemoryFileObjects()
291
348
  const { kit, standIn } = standInIamKit(app, {
@@ -404,16 +461,20 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
404
461
  throw new Error(`${row.id} admits neither a human nor a machine caller`)
405
462
  }
406
463
 
407
- /** 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
+ */
408
468
  const ask = async (
409
469
  row: ActionEndpoint,
410
470
  grants: readonly Grant[],
411
471
  input: Readonly<Record<string, unknown>>,
412
472
  chain: Chain,
473
+ refusals: Refusals = 'none',
413
474
  ): Promise<{ readonly status: number; readonly text: string; readonly marks: readonly string[] }> => {
414
475
  const asker = callerFor(row, grants)
415
476
  const asked = withGenerated(row, input, chain, asker.caller)
416
- return { ...(await askAs(row, asker.fetch, asked.input)), marks: asked.marks }
477
+ return { ...(await askAs(row, asker.fetch, asked.input, refusals)), marks: asked.marks }
417
478
  }
418
479
 
419
480
  // A seeded file column references an empty object, which a handler that serves or opens the file reads here.
@@ -427,7 +488,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
427
488
  }
428
489
 
429
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. */
430
- 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]> => {
431
492
  if (row.method === 'GET') {
432
493
  const params = new URLSearchParams(Object.entries(body).map(([name, value]) => [name, value === null ? 'null' : String(value)]))
433
494
  return [params.size === 0 ? row.path : `${row.path}?${params}`, { method: 'GET' }]
@@ -446,7 +507,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
446
507
  for (const item of Array.isArray(value) ? value : [value]) {
447
508
  const kind = fileKindOf(item)
448
509
  if (kind === undefined) throw new Error(`${row.id}: the file field ${name} holds ${JSON.stringify(item)}, not a generated file`)
449
- files.push([name, generatedFile(kind)])
510
+ files.push([name, await generatedFile(row.id, name, kind)])
450
511
  }
451
512
  }
452
513
  const form = new FormData()
@@ -455,30 +516,69 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
455
516
  return [row.path, { method: 'POST', body: form }]
456
517
  }
457
518
 
458
- const generatedFile = (kind: string): File => {
459
- const sample = sampleFile(kind, kit.artifact.fileKinds?.[kind]?.types ?? [])
460
- 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
461
549
  }
462
550
 
463
551
  const askAs = async (
464
552
  row: ActionEndpoint,
465
553
  fetch: RpcFetch,
466
554
  body: Readonly<Record<string, unknown>>,
555
+ refusals: Refusals = 'none',
467
556
  ): Promise<{ readonly status: number; readonly text: string }> => {
468
557
  stockSeededObjects()
469
- const [url, init] = requestOf(row, body)
558
+ const [url, init] = await requestOf(row, body)
470
559
  const response = await fetch(url, init)
471
560
  const text = await response.text()
472
561
  // A 400 hides what the case is about: input is parsed before the access form runs, and a handler's refusal of
473
562
  // the input it was given is no answer to another scope. Either is what the generator could not build.
474
563
  if (response.status === 400) {
475
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
+ }
476
576
  throw new Error(
477
577
  type === 'validation'
478
578
  ? `${row.id}: the case's input does not match the endpoint's input schema; correct its fixture (${text})`
479
579
  : `${row.id}: the handler refused the case's input (400 ${
480
580
  type ?? 'without a type'
481
- }); 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})`,
482
582
  )
483
583
  }
484
584
  return { status: response.status, text }
@@ -494,6 +594,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
494
594
  field: SchemaShape | undefined,
495
595
  seed: Seed,
496
596
  input: Readonly<Record<string, unknown>>,
597
+ pinning: Pinning = NO_PINNING,
497
598
  ): Promise<Scopes> => {
498
599
  const named = rowFields(input)
499
600
  const owner = ownerOf(dimension)
@@ -514,7 +615,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
514
615
  },
515
616
  }
516
617
  }
517
- const worlds = await postgres.seedWorlds(tables, owner, seed)
618
+ const worlds = pinned(await postgres.seedWorlds(tables, owner, seed, pinning.pins), pinning)
518
619
  const own = await rowsIn(worlds.own, named)
519
620
  const other = await rowsIn(worlds.other, named)
520
621
  return {
@@ -569,14 +670,17 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
569
670
  * For a report `in` `dimension`: one row in each scope of every table that lies in it, the table that owns it with
570
671
  * `seed`. Each side's targets are its rows' values of `dimension`, and each caller holds all of them.
571
672
  */
572
- 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) => {
573
674
  const own: InTarget[] = []
574
675
  const other: InTarget[] = []
575
676
  const values: string[] = []
576
677
  for (const name of tableNames) {
577
678
  const mode = policies[name]?.scope.mode
578
679
  if (mode !== 'dimension' && mode !== 'child') continue
579
- 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)
580
684
  const ownAt = worlds.own.chain.findIndex((link) => link.type === dimension)
581
685
  const otherAt = worlds.other.chain.findIndex((link) => link.type === dimension)
582
686
  const ownValue = worlds.own.chain[ownAt]?.value
@@ -588,7 +692,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
588
692
  }
589
693
  }
590
694
  if (own.length === 0) {
591
- const scopes = await scopesOf(action, dimension, field, seed, {})
695
+ const scopes = await scopesOf(action, dimension, field, seed, {}, pinning)
592
696
  return { own: [scopes.own], other: [scopes.other], values: scopes.other.values }
593
697
  }
594
698
  return { own, other, values }
@@ -711,6 +815,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
711
815
  mount: string,
712
816
  seed: Seed,
713
817
  input: Readonly<Record<string, unknown>>,
818
+ pinning: Pinning,
714
819
  ): Promise<{ readonly fetch: RpcFetch; readonly rows: Readonly<Record<string, string>> }> => {
715
820
  const access = row.access
716
821
  if (access.form !== 'on') {
@@ -720,7 +825,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
720
825
  if (owner === undefined) {
721
826
  throw new Error(`${row.id}: no table owns ${access.dimension}, so no link can be bound to one`)
722
827
  }
723
- const { own } = await postgres.seedWorlds(tables, owner, seed)
828
+ const { own } = pinned(await postgres.seedWorlds(tables, owner, seed, pinning.pins), pinning)
724
829
  const holder = kit.testCaller([{ action: row.action, scope: { type: access.dimension, value: own.key } }], {
725
830
  kind: 'link',
726
831
  link: { mount, chain: own.chain },
@@ -737,6 +842,9 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
737
842
  // the call and the runs it kicked are over. A list per call, so a concurrent case never removes the rows another
738
843
  // was given; the generated cases run one after another, since a row a required foreign key needed is shared.
739
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
+
740
848
  /** One row of each application table `appRows` names, for this call only. */
741
849
  const seedAppRows = async (appRows: AppRows): Promise<void> => {
742
850
  if (Object.keys(appRows).length === 0) return
@@ -750,7 +858,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
750
858
  if (access.form !== 'on' && access.form !== 'in') {
751
859
  throw new Error(`${endpoint} has the access form ${access.form}; only on and in name an object or a scope value`)
752
860
  }
753
- const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
861
+ const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
754
862
  const asked = addressing(input, access.from, scopes.other)
755
863
  const { status } = await ask(row, holding(row, input, scopes.own.grants, scopes.own.chain), asked, scopes.own.chain)
756
864
  return { status }
@@ -760,12 +868,12 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
760
868
  const { action, access } = row
761
869
  if (access.form === 'in') {
762
870
  await seedAppRows(appRows)
763
- const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed)
871
+ const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, pinningOf(endpoint))
764
872
  const grants = (targets: readonly InTarget[]): Grant[] =>
765
873
  targets.flatMap(({ target, chain }) => holding(row, input, [{ action, scope: { type: access.dimension, value: target } }], chain))
766
874
  const marks: string[] = []
767
875
  for (const side of scopes.other) {
768
- const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain)
876
+ const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain, 'declared')
769
877
  marks.push(...answer.marks)
770
878
  }
771
879
  const answers = []
@@ -779,14 +887,17 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
779
887
  }
780
888
  await seedAppRows(appRows)
781
889
  const scopes = access.form === 'on'
782
- ? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
890
+ ? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
783
891
  : await listScopesOf(action, access.dimension)
784
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.
785
895
  const other = await ask(
786
896
  row,
787
897
  holding(row, input, scopes.other.grants, scopes.other.chain),
788
898
  addressing(input, from, scopes.other),
789
899
  scopes.other.chain,
900
+ 'declared',
790
901
  )
791
902
  const answer = await ask(
792
903
  row,
@@ -804,16 +915,17 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
804
915
  }
805
916
  await seedAppRows(appRows)
806
917
  if (row.link !== null) {
807
- const holder = await linkHolder(row, row.link, seed, input)
808
- const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows })
918
+ const holder = await linkHolder(row, row.link, seed, input, pinningOf(endpoint))
919
+ const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows }, 'declared')
809
920
  return { status }
810
921
  }
811
- const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
922
+ const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input, pinningOf(endpoint))
812
923
  const { status } = await ask(
813
924
  row,
814
925
  holding(row, input, scopes.own.grants, scopes.own.chain),
815
926
  addressing(input, access.from, scopes.own),
816
927
  scopes.own.chain,
928
+ 'declared',
817
929
  )
818
930
  return { status }
819
931
  },
@@ -864,7 +976,7 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
864
976
  }
865
977
  const { access } = named
866
978
  if (access.form === 'on' || access.form === 'in') {
867
- const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input)
979
+ const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input, pinningOf(endpoint))
868
980
  asked = addressing(asked, access.from, scopes.other)
869
981
  grants.push(...scopes.own.grants)
870
982
  }
@@ -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,
@@ -10,7 +10,7 @@ export declare const GENERATED_MIGRATIONS_FILE = "fabrika/migrations.generated.t
10
10
  export declare const MIGRATIONS_DIRECTORY = "db/migrations";
11
11
  export type EntryRuntime = 'bun' | 'cloudflare';
12
12
  export * from './build-recipe.js';
13
- export { CLOUDFLARE_APPLICATION_BINDING, CLOUDFLARE_APPLICATION_CLASS, CLOUDFLARE_FILES_BINDING } from './cloudflare/names.js';
13
+ export { CLOUDFLARE_APPLICATION_BINDING, CLOUDFLARE_APPLICATION_CLASS, CLOUDFLARE_ASSETS_BINDING, CLOUDFLARE_FILES_BINDING, } from './cloudflare/names.js';
14
14
  /** The entrypoint source for one runtime. */
15
15
  export declare function generatedEntry(runtime: EntryRuntime): string;
16
16
  /** Writes the entrypoint under `root` and returns its path; the Cloudflare one also writes the migrations module it imports. */
@@ -18,6 +18,12 @@ export declare function bindingTerminals(binding: unknown): IamTerminalRpc | und
18
18
  * An answer that is not IAM's decided outcome throws: "IAM could not be asked".
19
19
  */
20
20
  export declare function bindingWorkloadTokens(binding: unknown): WorkloadTokenSource | undefined;
21
+ /**
22
+ * The SPA and link mounts' files from the Workers Assets binding, whose directory mirrors the request paths: the mount
23
+ * `/admin` reads `admin/…` of it, the root mount the rest (the Cloudflare deploy stages each mount's `dir` there). A page
24
+ * route that names no file (`isPageRoute`) gets the mount's `index.html`, as `diskAssets` serves it: Workers Assets
25
+ * answers `<prefix>/` with it. A missing asset stays 404.
26
+ */
21
27
  export declare function bindingAssets(binding: unknown): SpaAssets | undefined;
22
28
  /**
23
29
  * The service bindings to owners' proxy Workers (`FABRIKA_CONNECT_<IMPORT>`), which `captureEnvironment` keeps from
@@ -4,3 +4,8 @@ export declare const CLOUDFLARE_APPLICATION_CLASS = "FabrikaApplication";
4
4
  export declare const CLOUDFLARE_APPLICATION_BINDING = "FABRIKA_APPLICATION";
5
5
  /** The R2 bucket binding of the environment's stored files. */
6
6
  export declare const CLOUDFLARE_FILES_BINDING = "FABRIKA_FILES";
7
+ /**
8
+ * The Workers Assets binding the SPA and link mounts serve their files from. Its directory mirrors the request paths:
9
+ * the mount `/admin` serves `<directory>/admin/…`, the root mount `<directory>/…`.
10
+ */
11
+ export declare const CLOUDFLARE_ASSETS_BINDING = "ASSETS";
@@ -74,7 +74,15 @@ export type SignsField<Sg, Purposes extends string> = {
74
74
  * `fetch` refuses any other while the site runs; a public procedure declares none.
75
75
  */
76
76
  export interface EgressField {
77
- readonly egress?: readonly `https://${string}`[];
77
+ readonly egress?: readonly `https://${string}`[] | Unavailable<"egress: 'public' is a cron job's only: record a row and kick a job that declares it">;
78
+ }
79
+ /**
80
+ * A cron job's `egress`: its exact HTTPS origins, or `'public'`, every public host over HTTPS or HTTP on its default
81
+ * port, for a job that calls the websites its operators name (ADR-0070). The guard refuses IP addresses, local and
82
+ * internal names and the installation's own hosts, checks each redirect, and bounds each request's time and body.
83
+ */
84
+ export interface JobEgressField {
85
+ readonly egress?: readonly `https://${string}`[] | 'public';
78
86
  }
79
87
  /** Each purpose `signs` names is one of `defineApp`'s `tokens`. */
80
88
  type KnownPurposes<Sg, Purposes extends string> = Sg extends readonly (infer P extends string)[] ? [Exclude<P, Purposes>] extends [never] ? LiteralList<Sg> : Unavailable<`signs names ${ListOf<Exclude<P, Purposes>>}, which defineApp's tokens do not declare`> : unknown;
@@ -4,7 +4,7 @@ import type { z } from 'zod/v4';
4
4
  import type { Declared, JobKitContext, KickContext, KitImports, KitTypes, NoTablesIndex, SqlMode, TokensContext, WebhookKitContext } from './context';
5
5
  import { type ExportAccessDeclaration, type ImportDeclaration, type LinkAccessDeclaration, type ProcedureType } from './declaration';
6
6
  import { type Contract, type CreatableDimension, type CronJob, type Export, type InputSchema, type JobLimitsOption, type Kit, type LinkMount, type NotLoose, type Path, type ProcedureTree, type RpcMount, type Schema, type Surface, type WebhookMount } from './define';
7
- import type { DeclaredFields, EgressField, Endpoint, ExportKit, Group, GroupBuild, GroupInput, GroupKit, KicksField, LimitsOption, LinkGroupKit, ListOf, PendingIn, SignsField } from './group';
7
+ import type { DeclaredFields, EgressField, Endpoint, ExportKit, Group, GroupBuild, GroupInput, GroupKit, JobEgressField, KicksField, LimitsOption, LinkGroupKit, ListOf, PendingIn, SignsField } from './group';
8
8
  import type { TokenPurposes } from './token';
9
9
  import type { AnyAppVocabulary, AppVocabulary } from './vocabulary';
10
10
  /** Fields a builder sets itself: an options object that carries one does not type-check. */
@@ -31,7 +31,7 @@ export type CronOptions<Acts, S, U, Is, Ix extends AnyDbIndex, M = never, Ks = n
31
31
  readonly actions: Acts;
32
32
  readonly limits?: JobLimitsOption;
33
33
  readonly retries?: number;
34
- } & DeclaredFields<S, U, never, Is, Ix> & MailerField<M> & KicksField<Ks> & SignsField<Sg, Purposes> & EgressField;
34
+ } & DeclaredFields<S, U, never, Is, Ix> & MailerField<M> & KicksField<Ks> & SignsField<Sg, Purposes> & JobEgressField;
35
35
  /** The mail transport a job reaches, inferred from its optional field and then required, like a declared field. */
36
36
  type MailerField<M> = {
37
37
  /** `'installation'`: the installation's mail transport; `'own'`: the application's own. */
@@ -1,3 +1,6 @@
1
1
  import type { SpaAssets } from './implement/dispatch.js';
2
- /** Serves each SPA mount from its build directory under `root`; a path that names no file gets the SPA's `index.html`. */
2
+ /**
3
+ * Serves each SPA mount from its build directory under `root`; a page route that names no file gets the SPA's
4
+ * `index.html` (`isPageRoute`), and a missing asset is 404.
5
+ */
3
6
  export declare function diskAssets(root: string): SpaAssets;
@@ -9,6 +9,7 @@ import { type ContractJobs, type HandlerErrorSink } from '../jobs/runner';
9
9
  import type { ChallengeFetch, ChallengeWork } from './challenge';
10
10
  import { type ConnectFetch } from './connect';
11
11
  import { type SpaAssets } from './dispatch';
12
+ import { type HostResolver } from './egress';
12
13
  import type { CapturedEnvironment } from './environment';
13
14
  import type { ContractImplementation } from './implement';
14
15
  import { type WorkloadTokenSource } from './workload';
@@ -57,6 +58,11 @@ export interface BootOptions {
57
58
  * with a job or a webhook does not boot.
58
59
  */
59
60
  readonly workloadTokens?: WorkloadTokenSource;
61
+ /**
62
+ * Resolves a host name to its addresses, which the Bun adapter supplies (`node:dns`): a cron job's public egress then
63
+ * refuses a public name of a private address before each hop. A Worker exposes no resolution and passes none.
64
+ */
65
+ readonly resolveHost?: HostResolver;
60
66
  /** What posts a report to Operations' ingest; `fetch` by default. */
61
67
  readonly operationsFetch?: (request: Request) => Promise<Response>;
62
68
  /** What asks a public procedure's challenge provider to verify a token; `fetch` by default. */
@@ -11,6 +11,8 @@ export declare const CAPABILITY_REFUSALS: {
11
11
  readonly undeclared_binding: "the site named a webhook it does not declare in binds";
12
12
  readonly undeclared_signs: "the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows";
13
13
  readonly undeclared_egress: "the site called an origin it does not declare in egress";
14
+ readonly refused_egress: "the site's public egress refused the host: an IP address, a local or internal name, a non-default port, a host of this installation, or a name that resolves to a private address";
15
+ readonly oversized_egress: "a response to the site's public egress was larger than it reads";
14
16
  readonly wrong_binding: "a webhook binding was handed a capability of another kind or dimension than the webhook is bound to";
15
17
  readonly missing_id: "the job named an object without an id";
16
18
  readonly wrong_capability: "an operation was handed a capability of another dimension or action than it needs";
@@ -11,6 +11,7 @@ import type { BrowserConfig } from './browser-config';
11
11
  import { type CapabilityMint } from './capability';
12
12
  import type { ChallengeIssuer } from './challenge';
13
13
  import { type ImportWire } from './connect';
14
+ import { type SiteEgress } from './egress';
14
15
  import type { FailureReports } from './failures';
15
16
  import { type HeaderPolicy } from './headers';
16
17
  import type { ContractDatabase } from './implement';
@@ -66,7 +67,7 @@ export type ServedEndpoint = {
66
67
  /** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
67
68
  readonly site?: ServedSite;
68
69
  /** The origins a staged-kit site's `fetch` reaches while its handler runs; absent for a handler `implement` bound. */
69
- readonly egress?: readonly string[];
70
+ readonly egress?: SiteEgress;
70
71
  /** Its buckets and time limit; without them (a generated endpoint) no bucket and the request budget. */
71
72
  readonly admission?: ProcedureAdmission;
72
73
  /** A POST's `query`, parsed from the URL beside the body: its schema and its shape. */
@@ -1,5 +1,40 @@
1
+ import { type DeclaredEgress } from '@fabrika/auth-core';
2
+ /** The addresses a host name resolves to, where the runtime exposes resolution: Bun's `node:dns`, never a Worker. */
3
+ export type HostResolver = (hostname: string) => Promise<readonly string[]>;
4
+ /** What public egress refuses beyond what a URL alone shows (`publicEgressRefusal`). */
5
+ export interface PublicEgressRules {
6
+ /** The installation's own hosts, normalized: every host a framework variable names, and `FABRIKA_EGRESS_REFUSED_HOSTS`. */
7
+ readonly refusedHosts: ReadonlySet<string>;
8
+ /** Resolves each hop's host before it is sent, so a public name of a private address is refused; `null` on Cloudflare. */
9
+ readonly resolve: HostResolver | null;
10
+ }
11
+ /** What one site's `fetch` reaches: its declared origins, or the rules of public egress. */
12
+ export type SiteEgress = readonly string[] | PublicEgressRules;
13
+ /**
14
+ * The bounds of one request under public egress, the redirects it follows included. The handler's own signal still
15
+ * aborts it sooner (`ctx.signal`, an `AbortSignal.timeout` of its own).
16
+ */
17
+ export declare const PUBLIC_EGRESS_LIMITS: {
18
+ /** One request, its redirects and the reading of its body included: the abort also stops a body still streaming. */
19
+ readonly timeoutMs: 60000;
20
+ /** One response's body as the handler reads it; a larger one is refused, by its `Content-Length` or while it streams. */
21
+ readonly maxResponseBytes: number;
22
+ /** The redirects one request follows when its `redirect` mode is `follow`, each hop checked again. */
23
+ readonly maxRedirects: 10;
24
+ };
1
25
  /** Runs a site's handler: every `fetch` it starts reaches only `egress`. */
2
- export declare function runAsSite<T>(site: string, egress: readonly string[], work: () => Promise<T>): Promise<T>;
26
+ export declare function runAsSite<T>(site: string, egress: SiteEgress, work: () => Promise<T>): Promise<T>;
27
+ /**
28
+ * The egress a site runs with: its declared origins, or `rules` for `'public'`. Only a cron job's runner holds rules; a
29
+ * site run without them reaches nothing, though the build lets no other site declare `'public'`.
30
+ */
31
+ export declare function siteEgress(declared: DeclaredEgress, rules: PublicEgressRules | null): SiteEgress;
32
+ /**
33
+ * The rules of public egress in one environment: the hosts its framework variables name (IAM, Operations, its own
34
+ * origin, the platform's receivers, the object store), the installation hosts Control hands it, and the resolver the
35
+ * runtime adapter supplies.
36
+ */
37
+ export declare function publicEgressRules(framework: Readonly<Record<string, string>>, resolve: HostResolver | null): PublicEgressRules;
3
38
  /**
4
39
  * `facility` with each of its methods run outside any site's scope: a framework facility a handler calls, whose own
5
40
  * requests are the platform's, not the site's.
@@ -1,4 +1,4 @@
1
- import { type ContractArtifact, type JobMailer } from '@fabrika/auth-core';
1
+ import { type ContractArtifact, type DeclaredEgress, type JobMailer } from '@fabrika/auth-core';
2
2
  import { type DataAccess, type DataHandles, type FileBinding, type Session, type SqlTag } from '@fabrika/db/framework';
3
3
  import type { DataKind, UnscopedMode } from '@fabrika/db/tables';
4
4
  import type { SqlMode } from '../contract/context';
@@ -16,8 +16,8 @@ export interface SiteDeclaration {
16
16
  readonly binds: string | null;
17
17
  /** The token purposes `ctx.tokens.sign` may sign. */
18
18
  readonly signs: readonly string[];
19
- /** The HTTPS origins the global `fetch` reaches while the site's handler runs. */
20
- readonly egress: readonly string[];
19
+ /** The HTTPS origins the global `fetch` reaches while the site's handler runs, or `'public'`: every public host (a cron job). */
20
+ readonly egress: DeclaredEgress;
21
21
  }
22
22
  /** A public procedure's reach: the texts the verified signed tokens of its request carry, which name the rows it changes. */
23
23
  export interface PublicReach {
@@ -4,6 +4,7 @@ import type { CapabilityAuthority } from '@fabrika/db/capability';
4
4
  import { type DataAccess, type FrameworkTables } from '@fabrika/db/framework';
5
5
  import { type StoredFileKinds } from '../files/kinds';
6
6
  import { type CapabilityMint } from '../implement/capability';
7
+ import { type PublicEgressRules } from '../implement/egress';
7
8
  import type { ContractDatabase } from '../implement/implement';
8
9
  import type { SignedTokens } from '../implement/tokens';
9
10
  import { type AppUrl } from '../implement/urls';
@@ -130,6 +131,8 @@ export interface JobBindingInput {
130
131
  readonly tokens?: SignedTokens;
131
132
  /** `ctx.url`: absolute links to the application's own procedures; without it every link is refused. */
132
133
  readonly url?: AppUrl;
134
+ /** The rules of public egress, which `boot` reads from the environment; without them a public job reaches nothing. */
135
+ readonly publicEgress?: PublicEgressRules;
133
136
  }
134
137
  export interface BoundJobs {
135
138
  readonly jobs: ContractJobs;
@@ -0,0 +1,2 @@
1
+ /** A GET or HEAD whose last path segment has no extension, or one that asks for HTML (a navigation to `/notes/v1.2`). */
2
+ export declare function isPageRoute(request: Request): boolean;
@@ -78,6 +78,30 @@ export interface EndpointAccessKit {
78
78
  */
79
79
  anyone(endpoint: string, input: Readonly<Record<string, unknown>>): Promise<ReachOutcome>;
80
80
  }
81
+ export interface EndpointAccessOptions {
82
+ /**
83
+ * The file every case of an endpoint sends in a file field instead of the kit's sample, by endpoint id and input
84
+ * field: a file of the application's repository that a handler inspecting the content accepts (a valid package). Its
85
+ * type is the first of the field's kind that its bytes are; a file of none of them throws. `files` of the fixtures.
86
+ */
87
+ readonly files?: Readonly<Record<string, Readonly<Record<string, URL>>>>;
88
+ /**
89
+ * The error types of the refusals (400) an endpoint's handler answers its own object with when the case's input meets
90
+ * no domain rule the kit can satisfy, by endpoint id: such a 400 is the handler's own answer, so the request passed
91
+ * the access form, and the endpoint's own case gets it as `{ status: 400 }` instead of failing; so does the other
92
+ * scope's request a leak case makes first. The own scope's answer of a leak case, and a global or capability case,
93
+ * still throw on it (an answer that holds nothing proves no isolation), so does any other 400, and an input the schema
94
+ * refuses (`validation`) always does. `refusals` of the fixtures.
95
+ */
96
+ readonly refusals?: Readonly<Record<string, readonly string[]>>;
97
+ /**
98
+ * Known values for text columns of the row the endpoint addresses (the row of the table that owns its dimension), by
99
+ * endpoint id: the value in the own scope's row, and `<value>-other` in the other scope's, which counts among the
100
+ * other scope's values a leak shows. For a handler whose rule ties a column to the input or a file fixture (a package
101
+ * that names the skill's slug). `pins` of the fixtures.
102
+ */
103
+ readonly pins?: Readonly<Record<string, Readonly<Record<string, string>>>>;
104
+ }
81
105
  export type Grant = {
82
106
  readonly action: string;
83
107
  readonly scope: {
@@ -104,4 +128,4 @@ export declare const managerGrants: (artifact: Pick<ContractArtifact, "roles">,
104
128
  * The endpoint access kit of `app`, whose data layer is `scoped(tables)`, over `postgres`. It boots `app` once, so the
105
129
  * application's layer is bound here and nowhere else in the process.
106
130
  */
107
- export declare function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase): EndpointAccessKit;
131
+ export declare function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract>, tables: Tables<DB, P>, postgres: TestDatabase, options?: EndpointAccessOptions): EndpointAccessKit;
@@ -1,3 +1,3 @@
1
- export { type DeniedOutcome, type EndpointAccessKit, endpointAccessKit, type LeakOutcome, type ReachOutcome } from './endpoint-access';
1
+ export { type DeniedOutcome, type EndpointAccessKit, endpointAccessKit, type EndpointAccessOptions, type LeakOutcome, type ReachOutcome, } from './endpoint-access';
2
2
  export { type AppCaller, type AppCountOutcome, type AppIsolationKit, appPolicyIsolationKit, type ChildIsolationRow, type CountOutcome, type IsolationOutcome, type IsolationRow, type ListOutcome, type MisuseOutcome, type PolicyIsolationKit, policyIsolationKit, type ReadOutcome, } from './policy-isolation';
3
3
  export { afterAll, describe, expect, test } from './runner';