@fabrika/app 0.8.2 → 0.9.0-cloudflare.1

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 (194) hide show
  1. package/package.json +40 -24
  2. package/skill/SKILL.md +27 -3
  3. package/skill/references/app-setup.md +8 -8
  4. package/skill/references/dev.md +2 -1
  5. package/skill/references/endpoint.md +7 -3
  6. package/skill/references/errors.md +39 -35
  7. package/skill/references/files.md +4 -2
  8. package/skill/references/table-and-migration.md +19 -2
  9. package/skill/references/tests.md +11 -0
  10. package/skill/references/webhooks-links-connectors.md +1 -1
  11. package/src/build-recipe.ts +198 -0
  12. package/src/build.ts +55 -4
  13. package/src/bun.ts +23 -8
  14. package/src/cloudflare/application.ts +352 -0
  15. package/src/cloudflare/bindings.ts +138 -0
  16. package/src/cloudflare/names.ts +10 -0
  17. package/src/cloudflare/ops.ts +246 -0
  18. package/src/cloudflare/r2-files.ts +172 -0
  19. package/src/cloudflare.ts +99 -146
  20. package/src/contract/app-id.ts +5 -0
  21. package/src/contract/artifact-schema.ts +2 -0
  22. package/src/contract/build.ts +35 -1
  23. package/src/contract/compile.ts +1 -0
  24. package/src/contract/context.ts +9 -0
  25. package/src/contract/declaration.ts +2 -0
  26. package/src/contract/define.ts +28 -2
  27. package/src/contract/kit.ts +4 -1
  28. package/src/contract/platform.ts +3 -2
  29. package/src/contract/vocabulary.ts +26 -8
  30. package/src/dev/changes.ts +35 -0
  31. package/src/dev/contract-digest.ts +17 -0
  32. package/src/dev/index.ts +11 -0
  33. package/src/dev/launcher.ts +713 -0
  34. package/src/dev/output.ts +72 -0
  35. package/src/dev/wire.ts +136 -0
  36. package/src/files/archive.ts +2 -2
  37. package/src/files/job-names.ts +12 -0
  38. package/src/files/kinds.ts +19 -7
  39. package/src/files/scan.ts +3 -4
  40. package/src/files/streams.ts +3 -1
  41. package/src/implement/anonymous.ts +11 -1
  42. package/src/implement/boot.ts +61 -4
  43. package/src/implement/bounded.ts +21 -2
  44. package/src/implement/capability.ts +15 -3
  45. package/src/implement/dispatch.ts +249 -145
  46. package/src/implement/failures.ts +17 -1
  47. package/src/implement/faults.ts +1 -0
  48. package/src/implement/index.ts +1 -0
  49. package/src/implement/invocation.ts +8 -0
  50. package/src/implement/principal.ts +25 -2
  51. package/src/implement/terminals.ts +93 -0
  52. package/src/implement/workload.ts +24 -13
  53. package/src/jobs/report.ts +5 -1
  54. package/src/jobs/runner.ts +33 -17
  55. package/src/jobs/schedule.ts +7 -1
  56. package/src/jobs/scrub.ts +14 -1
  57. package/src/testing/endpoint-access.ts +180 -137
  58. package/src/testing/kit.ts +20 -2
  59. package/src/testing/proxy.ts +5 -5
  60. package/types/src/agent-skill.d.ts +29 -0
  61. package/types/src/build-recipe.d.ts +70 -0
  62. package/types/src/build.d.ts +29 -0
  63. package/types/src/bun.d.ts +66 -0
  64. package/types/src/client/caller.d.ts +19 -0
  65. package/types/src/client/contract.d.ts +131 -0
  66. package/types/src/client/error-reporting.d.ts +66 -0
  67. package/types/src/client/index.d.ts +4 -0
  68. package/types/src/client/rpc.d.ts +14 -0
  69. package/types/src/client/share.d.ts +2 -0
  70. package/types/src/client/transport.d.ts +42 -0
  71. package/types/src/cloudflare/application.d.ts +42 -0
  72. package/types/src/cloudflare/bindings.d.ts +27 -0
  73. package/types/src/cloudflare/names.d.ts +6 -0
  74. package/types/src/cloudflare/ops.d.ts +46 -0
  75. package/types/src/cloudflare/r2-files.d.ts +9 -0
  76. package/types/src/cloudflare.d.ts +42 -0
  77. package/types/src/contract/app-id.d.ts +2 -0
  78. package/types/src/contract/artifact-schema.d.ts +8 -0
  79. package/types/src/contract/attachment.d.ts +41 -0
  80. package/types/src/contract/build.d.ts +12 -0
  81. package/types/src/contract/compile.d.ts +11 -0
  82. package/types/src/contract/connect.d.ts +51 -0
  83. package/types/src/contract/context.d.ts +510 -0
  84. package/types/src/contract/declaration.d.ts +307 -0
  85. package/types/src/contract/defaults.d.ts +208 -0
  86. package/types/src/contract/define.d.ts +376 -0
  87. package/types/src/contract/file.d.ts +44 -0
  88. package/types/src/contract/group.d.ts +274 -0
  89. package/types/src/contract/handled.d.ts +18 -0
  90. package/types/src/contract/index.d.ts +19 -0
  91. package/types/src/contract/kit.d.ts +211 -0
  92. package/types/src/contract/platform.d.ts +9 -0
  93. package/types/src/contract/problems.d.ts +14 -0
  94. package/types/src/contract/redirect.d.ts +25 -0
  95. package/types/src/contract/schema-from-shape.d.ts +10 -0
  96. package/types/src/contract/schema-shape.d.ts +5 -0
  97. package/types/src/contract/snapshot-source.d.ts +15 -0
  98. package/types/src/contract/token.d.ts +58 -0
  99. package/types/src/contract/vocabulary.d.ts +32 -0
  100. package/types/src/contract/webhook-binding.d.ts +24 -0
  101. package/types/src/contract/webhook-scheme.d.ts +23 -0
  102. package/types/src/dev/changes.d.ts +7 -0
  103. package/types/src/dev/contract-digest.d.ts +1 -0
  104. package/types/src/dev/index.d.ts +2 -0
  105. package/types/src/dev/launcher.d.ts +32 -0
  106. package/types/src/dev/output.d.ts +28 -0
  107. package/types/src/dev/wire.d.ts +126 -0
  108. package/types/src/disk-assets.d.ts +3 -0
  109. package/types/src/errors.d.ts +46 -0
  110. package/types/src/fetch.d.ts +7 -0
  111. package/types/src/files/archive.d.ts +26 -0
  112. package/types/src/files/clamscan.d.ts +32 -0
  113. package/types/src/files/handle.d.ts +106 -0
  114. package/types/src/files/job-names.d.ts +9 -0
  115. package/types/src/files/kinds.d.ts +101 -0
  116. package/types/src/files/scan.d.ts +78 -0
  117. package/types/src/files/sniff.d.ts +7 -0
  118. package/types/src/files/streams.d.ts +17 -0
  119. package/types/src/files/treatment.d.ts +18 -0
  120. package/types/src/from-row.d.ts +20 -0
  121. package/types/src/honeypot.d.ts +2 -0
  122. package/types/src/implement/anonymous.d.ts +28 -0
  123. package/types/src/implement/attachment.d.ts +14 -0
  124. package/types/src/implement/boot.d.ts +114 -0
  125. package/types/src/implement/bounded.d.ts +32 -0
  126. package/types/src/implement/browser-config.d.ts +19 -0
  127. package/types/src/implement/capability.d.ts +142 -0
  128. package/types/src/implement/challenge.d.ts +75 -0
  129. package/types/src/implement/client-address.d.ts +11 -0
  130. package/types/src/implement/connect.d.ts +67 -0
  131. package/types/src/implement/crashes.d.ts +31 -0
  132. package/types/src/implement/dispatch.d.ts +220 -0
  133. package/types/src/implement/egress.d.ts +14 -0
  134. package/types/src/implement/environment.d.ts +14 -0
  135. package/types/src/implement/failures.d.ts +39 -0
  136. package/types/src/implement/faults.d.ts +45 -0
  137. package/types/src/implement/headers.d.ts +7 -0
  138. package/types/src/implement/implement.d.ts +58 -0
  139. package/types/src/implement/index.d.ts +25 -0
  140. package/types/src/implement/invocation.d.ts +125 -0
  141. package/types/src/implement/membership.d.ts +201 -0
  142. package/types/src/implement/multipart.d.ts +36 -0
  143. package/types/src/implement/params.d.ts +7 -0
  144. package/types/src/implement/principal.d.ts +57 -0
  145. package/types/src/implement/project.d.ts +11 -0
  146. package/types/src/implement/public.d.ts +61 -0
  147. package/types/src/implement/serve.d.ts +25 -0
  148. package/types/src/implement/terminals.d.ts +33 -0
  149. package/types/src/implement/tokens.d.ts +49 -0
  150. package/types/src/implement/types.d.ts +243 -0
  151. package/types/src/implement/urls.d.ts +15 -0
  152. package/types/src/implement/verifiers.d.ts +19 -0
  153. package/types/src/implement/webhook.d.ts +55 -0
  154. package/types/src/implement/workload.d.ts +56 -0
  155. package/types/src/implement/xlsx.d.ts +6 -0
  156. package/types/src/index.d.ts +12 -0
  157. package/types/src/jobs/exception.d.ts +40 -0
  158. package/types/src/jobs/mail.d.ts +67 -0
  159. package/types/src/jobs/report.d.ts +56 -0
  160. package/types/src/jobs/runner.d.ts +151 -0
  161. package/types/src/jobs/schedule.d.ts +17 -0
  162. package/types/src/jobs/scrub.d.ts +12 -0
  163. package/types/src/jobs/types.d.ts +98 -0
  164. package/types/src/react/caller.d.ts +30 -0
  165. package/types/src/react/errors.d.ts +40 -0
  166. package/types/src/react/hooks.d.ts +110 -0
  167. package/types/src/react/index.d.ts +5 -0
  168. package/types/src/react/provider.d.ts +58 -0
  169. package/types/src/react/store.d.ts +68 -0
  170. package/types/src/react/testing.d.ts +28 -0
  171. package/types/src/react/types.d.ts +33 -0
  172. package/types/src/rpc/builder.d.ts +52 -0
  173. package/types/src/rpc/contract.d.ts +23 -0
  174. package/types/src/rpc/dispatcher.d.ts +11 -0
  175. package/types/src/rpc/types.d.ts +62 -0
  176. package/types/src/service/define.d.ts +42 -0
  177. package/types/src/service/index.d.ts +3 -0
  178. package/types/src/service/serve.d.ts +28 -0
  179. package/types/src/standard-schema.d.ts +55 -0
  180. package/types/src/testing/caller.d.ts +95 -0
  181. package/types/src/testing/challenge.d.ts +11 -0
  182. package/types/src/testing/dev.d.ts +97 -0
  183. package/types/src/testing/endpoint-access.d.ts +107 -0
  184. package/types/src/testing/index.d.ts +12 -0
  185. package/types/src/testing/isolation.d.ts +3 -0
  186. package/types/src/testing/kit.d.ts +187 -0
  187. package/types/src/testing/membership.d.ts +132 -0
  188. package/types/src/testing/policy-isolation.d.ts +113 -0
  189. package/types/src/testing/preload.d.ts +1 -0
  190. package/types/src/testing/proxy.d.ts +40 -0
  191. package/types/src/testing/runner.d.ts +8 -0
  192. package/types/src/testing/sample-file.d.ts +5 -0
  193. package/types/src/testing/status.d.ts +4 -0
  194. package/types/src/undumpable.d.ts +5 -0
@@ -58,6 +58,13 @@ export type ReachOutcome = { readonly answered: true } | { readonly answered: fa
58
58
  /** Column → value of the row of the table that owns the endpoint's dimension, in both scopes: the author's seed. */
59
59
  export type Seed = Readonly<Record<string, SampleValue>>
60
60
 
61
+ /**
62
+ * Table → column → value: one row of each application table the handler reads for the caller's own scope, seeded
63
+ * before a leak or an own case asks and removed once it is over, with every row that refers to it, so no other case
64
+ * finds it. Both scopes may see it, so its values are never counted as the other scope's.
65
+ */
66
+ export type AppRows = Readonly<Record<string, Seed>>
67
+
61
68
  export interface EndpointAccessKit {
62
69
  /**
63
70
  * A caller holding the endpoint's action in one scope names an object (`on`) or a scope value (`in`) of another;
@@ -66,16 +73,18 @@ export interface EndpointAccessKit {
66
73
  denied(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
67
74
  /**
68
75
  * A caller of the other scope asks first, then a caller of one scope asks for its own object (`on`), its list
69
- * (`over`) or, for every table that lies in the dimension, its scope value (`in`).
76
+ * (`over`) or, for every table that lies in the dimension, its scope value (`in`). `appRows` are seeded first, as
77
+ * for `own`.
70
78
  */
71
- leaked(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<LeakOutcome>
79
+ leaked(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed, appRows?: AppRows): Promise<LeakOutcome>
72
80
  /**
73
81
  * A caller that holds only the endpoint's action asks for its own object: a person or a script in its scope, or, on
74
82
  * a link mount, the holder of a link bound to the object. A write action reads its own target. Where IAM requires more
75
83
  * of the caller, it holds that too: a member manager an assigning role and the assigned role's actions, a link's
76
- * issuer the actions the link grants.
84
+ * issuer the actions the link grants. `appRows` are the rows of application tables its handler reads (the guide a link
85
+ * holder downloads), each seeded first.
77
86
  */
78
- own(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed): Promise<DeniedOutcome>
87
+ own(endpoint: string, input: Readonly<Record<string, unknown>>, seed?: Seed, appRows?: AppRows): Promise<DeniedOutcome>
79
88
  /**
80
89
  * An `app` or a `creates` endpoint asked by a caller who holds its action in one value of a dimension only, never
81
90
  * globally: refused (403), by the proxy for `app` and by IAM for `creates`, since both need a global grant.
@@ -724,147 +733,181 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
724
733
  return { fetch, rows: (await rowsIn(own, rowFields(input))).rows }
725
734
  }
726
735
 
727
- const accessKit: EndpointAccessKit = {
728
- async denied(endpoint, input, seed = {}) {
729
- const row = endpointOf(endpoint)
730
- const { action, access } = row
731
- if (access.form !== 'on' && access.form !== 'in') {
732
- throw new Error(`${endpoint} has the access form ${access.form}; only on and in name an object or a scope value`)
733
- }
734
- const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
735
- const asked = addressing(input, access.from, scopes.other)
736
- const { status } = await ask(row, holding(row, input, scopes.own.grants, scopes.own.chain), asked, scopes.own.chain)
737
- return { status }
738
- },
739
- async leaked(endpoint, input, seed = {}) {
740
- const row = endpointOf(endpoint)
741
- const { action, access } = row
742
- if (access.form === 'in') {
743
- const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed)
744
- const grants = (targets: readonly InTarget[]): Grant[] =>
745
- targets.flatMap(({ target, chain }) => holding(row, input, [{ action, scope: { type: access.dimension, value: target } }], chain))
746
- const marks: string[] = []
747
- for (const side of scopes.other) {
748
- const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain)
749
- marks.push(...answer.marks)
736
+ // The cases of one call: `removals` takes the removal of the application rows it seeds, which `drained` runs once
737
+ // the call and the runs it kicked are over. A list per call, so a concurrent case never removes the rows another
738
+ // was given; the generated cases run one after another, since a row a required foreign key needed is shared.
739
+ const accessKitOf = (removals: (() => Promise<void>)[]): EndpointAccessKit => {
740
+ /** One row of each application table `appRows` names, for this call only. */
741
+ const seedAppRows = async (appRows: AppRows): Promise<void> => {
742
+ if (Object.keys(appRows).length === 0) return
743
+ removals.push((await postgres.seedApplicationRows(tables, appRows)).remove)
744
+ }
745
+
746
+ return {
747
+ async denied(endpoint, input, seed = {}) {
748
+ const row = endpointOf(endpoint)
749
+ const { action, access } = row
750
+ if (access.form !== 'on' && access.form !== 'in') {
751
+ throw new Error(`${endpoint} has the access form ${access.form}; only on and in name an object or a scope value`)
750
752
  }
751
- const answers = []
752
- for (const side of scopes.own) {
753
- answers.push(await ask(row, grants(scopes.own), addressing(input, access.from, side), side.chain))
753
+ const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
754
+ const asked = addressing(input, access.from, scopes.other)
755
+ const { status } = await ask(row, holding(row, input, scopes.own.grants, scopes.own.chain), asked, scopes.own.chain)
756
+ return { status }
757
+ },
758
+ async leaked(endpoint, input, seed = {}, appRows = {}) {
759
+ const row = endpointOf(endpoint)
760
+ const { action, access } = row
761
+ if (access.form === 'in') {
762
+ await seedAppRows(appRows)
763
+ const scopes = await inScopesOf(action, access.dimension, fieldOf(row.input, access.from), seed)
764
+ const grants = (targets: readonly InTarget[]): Grant[] =>
765
+ targets.flatMap(({ target, chain }) => holding(row, input, [{ action, scope: { type: access.dimension, value: target } }], chain))
766
+ const marks: string[] = []
767
+ for (const side of scopes.other) {
768
+ const answer = await ask(row, grants(scopes.other), addressing(input, access.from, side), side.chain)
769
+ marks.push(...answer.marks)
770
+ }
771
+ const answers = []
772
+ for (const side of scopes.own) {
773
+ answers.push(await ask(row, grants(scopes.own), addressing(input, access.from, side), side.chain))
774
+ }
775
+ return leakOutcome(answers, [...scopes.values, ...marks])
754
776
  }
755
- return leakOutcome(answers, [...scopes.values, ...marks])
756
- }
757
- if (access.form !== 'on' && access.form !== 'over') {
758
- throw new Error(`${endpoint} has the access form ${access.form}; only on, in and over answer with rows of a scope`)
759
- }
760
- const scopes = access.form === 'on'
761
- ? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
762
- : await listScopesOf(action, access.dimension)
763
- const from = access.form === 'on' ? access.from : null
764
- const other = await ask(
765
- row,
766
- holding(row, input, scopes.other.grants, scopes.other.chain),
767
- addressing(input, from, scopes.other),
768
- scopes.other.chain,
769
- )
770
- const answer = await ask(
771
- row,
772
- holding(row, input, scopes.own.grants, scopes.own.chain),
773
- addressing(input, from, scopes.own),
774
- scopes.own.chain,
775
- )
776
- return leakOutcome([answer], [...scopes.other.values, ...other.marks])
777
- },
778
- async own(endpoint, input, seed = {}) {
779
- const row = endpointOf(endpoint)
780
- const { action, access } = row
781
- if (access.form !== 'on') {
782
- throw new Error(`${endpoint} has the access form ${access.form}; only on addresses an object of its own`)
783
- }
784
- if (row.link !== null) {
785
- const holder = await linkHolder(row, row.link, seed, input)
786
- const { status } = await askAs(row, holder.fetch, { ...input, ...holder.rows })
777
+ if (access.form !== 'on' && access.form !== 'over') {
778
+ throw new Error(`${endpoint} has the access form ${access.form}; only on, in and over answer with rows of a scope`)
779
+ }
780
+ await seedAppRows(appRows)
781
+ const scopes = access.form === 'on'
782
+ ? await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
783
+ : await listScopesOf(action, access.dimension)
784
+ const from = access.form === 'on' ? access.from : null
785
+ const other = await ask(
786
+ row,
787
+ holding(row, input, scopes.other.grants, scopes.other.chain),
788
+ addressing(input, from, scopes.other),
789
+ scopes.other.chain,
790
+ )
791
+ const answer = await ask(
792
+ row,
793
+ holding(row, input, scopes.own.grants, scopes.own.chain),
794
+ addressing(input, from, scopes.own),
795
+ scopes.own.chain,
796
+ )
797
+ return leakOutcome([answer], [...scopes.other.values, ...other.marks])
798
+ },
799
+ async own(endpoint, input, seed = {}, appRows = {}) {
800
+ const row = endpointOf(endpoint)
801
+ const { action, access } = row
802
+ if (access.form !== 'on') {
803
+ throw new Error(`${endpoint} has the access form ${access.form}; only on addresses an object of its own`)
804
+ }
805
+ await seedAppRows(appRows)
806
+ 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 })
809
+ return { status }
810
+ }
811
+ const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
812
+ const { status } = await ask(
813
+ row,
814
+ holding(row, input, scopes.own.grants, scopes.own.chain),
815
+ addressing(input, access.from, scopes.own),
816
+ scopes.own.chain,
817
+ )
787
818
  return { status }
819
+ },
820
+ async scoped(endpoint, input) {
821
+ const row = globalEndpointOf(endpoint)
822
+ const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
823
+ const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
824
+ return { status }
825
+ },
826
+ async anyone(endpoint, input) {
827
+ const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
828
+ if (row?.kind !== 'public') {
829
+ throw new Error(`${endpoint} is not a public procedure of the contract`)
830
+ }
831
+ const response = await kit.fetch(row.path, {
832
+ method: row.method,
833
+ headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
834
+ body: JSON.stringify(input),
835
+ })
836
+ const status = response.status
837
+ const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
838
+ return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
839
+ },
840
+ async global(endpoint, input) {
841
+ const row = globalEndpointOf(endpoint)
842
+ const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
843
+ // Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
844
+ return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
845
+ },
846
+ async capability(endpoint, capability, input, seed = {}) {
847
+ const capabilities = capabilitiesEndpointOf(endpoint)
848
+ const named = capabilities.get(capability)
849
+ if (named === undefined) {
850
+ throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
851
+ }
852
+ const grants: Grant[] = []
853
+ const global: { readonly action: string }[] = []
854
+ let asked = input
855
+ for (const [name, other] of capabilities) {
856
+ if (name === capability) continue
857
+ if (other.access.form === 'over') {
858
+ global.push({ action: other.action })
859
+ } else if (other.access.form === 'on' || other.access.form === 'in') {
860
+ const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
861
+ asked = addressing(asked, other.access.from, own)
862
+ grants.push(...own.grants)
863
+ }
864
+ }
865
+ const { access } = named
866
+ if (access.form === 'on' || access.form === 'in') {
867
+ const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input)
868
+ asked = addressing(asked, access.from, scopes.other)
869
+ grants.push(...scopes.own.grants)
870
+ }
871
+ const { status } = await askAs(named, callerFor(named, grants, global).fetch, asked)
872
+ return { status }
873
+ },
874
+ }
875
+ }
876
+ // Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one, and
877
+ // only then removes its application rows, which those runs may still read. Every step runs whatever failed before
878
+ // it, and the case's own error is thrown first.
879
+ const drained =
880
+ <A extends readonly unknown[], R>(method: (kit: EndpointAccessKit) => (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
881
+ const removals: (() => Promise<void>)[] = []
882
+ let outcome: { readonly ok: true; readonly value: R } | { readonly ok: false; readonly error: unknown }
883
+ try {
884
+ outcome = { ok: true, value: await method(accessKitOf(removals))(...args) }
885
+ } catch (error) {
886
+ outcome = { ok: false, error }
788
887
  }
789
- const scopes = await scopesOf(action, access.dimension, fieldOf(row.input, access.from), seed, input)
790
- const { status } = await ask(
791
- row,
792
- holding(row, input, scopes.own.grants, scopes.own.chain),
793
- addressing(input, access.from, scopes.own),
794
- scopes.own.chain,
795
- )
796
- return { status }
797
- },
798
- async scoped(endpoint, input) {
799
- const row = globalEndpointOf(endpoint)
800
- const dimension = row.access.form === 'creates' ? row.access.dimension : scopeTypeOf(kit.artifact.dimensions)
801
- const { status } = await ask(row, [{ action: row.action, scope: { type: dimension, value: freshValue(dimension, 'own', undefined) } }], input, [])
802
- return { status }
803
- },
804
- async anyone(endpoint, input) {
805
- const row = kit.artifact.endpoints.find((candidate) => candidate.id === endpoint)
806
- if (row?.kind !== 'public') {
807
- throw new Error(`${endpoint} is not a public procedure of the contract`)
808
- }
809
- const response = await kit.fetch(row.path, {
810
- method: row.method,
811
- headers: { 'Content-Type': 'application/json', Origin: kit.origin, 'Sec-Fetch-Site': 'same-origin' },
812
- body: JSON.stringify(input),
813
- })
814
- const status = response.status
815
- const challenged = status === 400 && errorType(await response.text()) === 'challenge_failed'
816
- return status >= 500 || status === 401 || status === 403 || challenged ? { answered: false, status } : { answered: true }
817
- },
818
- async global(endpoint, input) {
819
- const row = globalEndpointOf(endpoint)
820
- const { status } = await askAs(row, callerFor(row, [], [{ action: row.action }]).fetch, input)
821
- // Neither form refuses with 404, so a 404 is the handler's own answer (a row the sampled input names is missing).
822
- return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
823
- },
824
- async capability(endpoint, capability, input, seed = {}) {
825
- const capabilities = capabilitiesEndpointOf(endpoint)
826
- const named = capabilities.get(capability)
827
- if (named === undefined) {
828
- throw new Error(`${endpoint} has no capability '${capability}' (capabilities: ${[...capabilities.keys()].join(', ')})`)
829
- }
830
- const grants: Grant[] = []
831
- const global: { readonly action: string }[] = []
832
- let asked = input
833
- for (const [name, other] of capabilities) {
834
- if (name === capability) continue
835
- if (other.access.form === 'over') {
836
- global.push({ action: other.action })
837
- } else if (other.access.form === 'on' || other.access.form === 'in') {
838
- const { own } = await scopesOf(other.action, other.access.dimension, fieldOf(other.input, other.access.from), {}, input)
839
- asked = addressing(asked, other.access.from, own)
840
- grants.push(...own.grants)
888
+ const failures: unknown[] = []
889
+ for (const step of [() => kit.drain(), ...removals]) {
890
+ try {
891
+ await step()
892
+ } catch (error) {
893
+ failures.push(error)
841
894
  }
842
895
  }
843
- const { access } = named
844
- if (access.form === 'on' || access.form === 'in') {
845
- const scopes = await scopesOf(named.action, access.dimension, fieldOf(named.input, access.from), seed, input)
846
- asked = addressing(asked, access.from, scopes.other)
847
- grants.push(...scopes.own.grants)
896
+ if (!outcome.ok) {
897
+ if (failures.length === 0) throw outcome.error
898
+ throw new AggregateError([outcome.error, ...failures], 'the case failed, and so did its cleanup')
848
899
  }
849
- const { status } = await askAs(named, callerFor(named, grants, global).fetch, asked)
850
- return { status }
851
- },
852
- }
853
- // Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one.
854
- const drained = <A extends readonly unknown[], R>(method: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
855
- try {
856
- return await method(...args)
857
- } finally {
858
- await kit.drain()
900
+ if (failures.length === 1) throw failures[0]
901
+ if (failures.length > 1) throw new AggregateError(failures, 'the cleanup of the case failed')
902
+ return outcome.value
859
903
  }
860
- }
861
904
  return Object.freeze({
862
- denied: drained(accessKit.denied),
863
- leaked: drained(accessKit.leaked),
864
- own: drained(accessKit.own),
865
- scoped: drained(accessKit.scoped),
866
- global: drained(accessKit.global),
867
- anyone: drained(accessKit.anyone),
868
- capability: drained(accessKit.capability),
905
+ denied: drained((cases) => cases.denied),
906
+ leaked: drained((cases) => cases.leaked),
907
+ own: drained((cases) => cases.own),
908
+ scoped: drained((cases) => cases.scoped),
909
+ global: drained((cases) => cases.global),
910
+ anyone: drained((cases) => cases.anyone),
911
+ capability: drained((cases) => cases.capability),
869
912
  })
870
913
  }
@@ -16,13 +16,16 @@ import {
16
16
  FABRIKA_IAM_ISSUER,
17
17
  FABRIKA_TOKEN_KEYS,
18
18
  type IamMembershipRpc,
19
+ type IamTerminalRpc,
19
20
  isNormalizedMailbox,
20
21
  type Jwks,
21
22
  MAX_ACCESS_TOKEN_BYTES,
23
+ MAX_APPLICATION_TOKEN_BYTES,
22
24
  normalizeEmailIdentity,
23
25
  type PermissionEntry,
24
26
  PROXY_TOKEN_HEADER,
25
27
  SESSION_COOKIE,
28
+ type TerminalFailure,
26
29
  TOKEN_ALG,
27
30
  UNTRUSTED_FORWARD_HEADERS,
28
31
  workloadKeyVariable,
@@ -78,6 +81,14 @@ import {
78
81
  import { endpointProxy } from './proxy'
79
82
 
80
83
  export const TEST_ISSUER = 'https://iam.test'
84
+
85
+ /** The kit's IAM without a terminal: no identification is open, so every one is `not_found`. */
86
+ const noIdentification = (): Promise<TerminalFailure> => Promise.resolve({ ok: false, reason: 'not_found' })
87
+ const NO_IDENTIFICATIONS: IamTerminalRpc = Object.freeze({
88
+ inspectIdentification: noIdentification,
89
+ assertIdentity: noIdentification,
90
+ cancelIdentification: noIdentification,
91
+ })
81
92
  export const TEST_ENVIRONMENT = 'test'
82
93
  const KEY_ID = 'test-key'
83
94
  const TOKEN_TTL_SECONDS = 300
@@ -112,6 +123,11 @@ export interface TestKitOptions {
112
123
  * kit's proxy obtains each delegation grant from it. Without it the kit's IAM answers them under IAM's rules.
113
124
  */
114
125
  readonly membership?: IamMembershipRpc
126
+ /**
127
+ * IAM's identifications at a terminal (ADR-0067), for a contract that declares one. Without it the kit's IAM knows no
128
+ * open identification: `inspect` answers null and `assert` `not_found`.
129
+ */
130
+ readonly terminals?: IamTerminalRpc
115
131
  /**
116
132
  * Each import's owner, by the import's name: its origin and edge key, which the platform writes as the import's
117
133
  * framework variables, and how a request reaches it — usually the owner kit's `fetch`.
@@ -428,6 +444,7 @@ export function bootTestKit<C extends AnyContract>(
428
444
  ...dev,
429
445
  ...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
430
446
  membership,
447
+ terminals: options.terminals ?? (artifact.terminal === undefined ? undefined : NO_IDENTIFICATIONS),
431
448
  connectBindings,
432
449
  workloadTokens,
433
450
  mailStandIn: options.mail === 'none' ? 'none' : mail,
@@ -523,7 +540,7 @@ export function bootTestKit<C extends AnyContract>(
523
540
 
524
541
  const scope = <D extends DimensionOf<C>>(dimension: D, value: string): TestScope<D> => Object.freeze({ type: dimension, value })
525
542
 
526
- /** The caller's token, or why IAM signs none: a disabled principal, or a token above `MAX_ACCESS_TOKEN_BYTES`. */
543
+ /** The caller's token, or why IAM signs none: a disabled principal, or a token above IAM's bound for it. */
527
544
  async function mint(caller: TestCaller): Promise<{ readonly ok: true; readonly token: string; readonly expiresAt: number } | MintRefusal> {
528
545
  if (caller.disabled) return { ok: false, reason: 'disabled' }
529
546
  const now = Math.floor(Date.now() / 1000)
@@ -542,7 +559,8 @@ export function bootTestKit<C extends AnyContract>(
542
559
  })
543
560
  const { privateKey } = await signing
544
561
  const token = await new SignJWT({ ...claims }).setProtectedHeader({ alg: TOKEN_ALG, kid: KEY_ID }).sign(privateKey)
545
- if (token.length > MAX_ACCESS_TOKEN_BYTES) return { ok: false, reason: 'token_too_large' }
562
+ // IAM bounds a workload's own token as any installation token, and a token the proxy injects at the application's.
563
+ if (token.length > (caller.app === null ? MAX_APPLICATION_TOKEN_BYTES : MAX_ACCESS_TOKEN_BYTES)) return { ok: false, reason: 'token_too_large' }
546
564
  byToken.set(token, { caller, expiresAt: claims.exp })
547
565
  return { ok: true, token, expiresAt: claims.exp }
548
566
  }
@@ -19,7 +19,7 @@ import {
19
19
  type TokenCache,
20
20
  } from '@fabrika/proxy-core'
21
21
  import { applyCrossOriginHeaders, crossOriginHeaders } from '@fabrika/proxy-core/headers'
22
- import { contractProxyTable } from '@fabrika/proxy-core/manifest'
22
+ import { contractProxyEntry, contractProxyTable } from '@fabrika/proxy-core/manifest'
23
23
 
24
24
  /** What the proxy did with a request: answered it itself, or forwarded it with what only the proxy may set. */
25
25
  export type ProxyOutcome =
@@ -66,14 +66,14 @@ const ASSERTED_HEADERS = [PROXY_TOKEN_HEADER, REQUEST_ID_HEADER, ENDPOINT_HEADER
66
66
  export function endpointProxy(options: EndpointProxyOptions): TestProxy {
67
67
  const verify = createVerifyService({
68
68
  manifest: {
69
- apps: [{
69
+ apps: [contractProxyEntry({
70
70
  id: options.appId,
71
71
  hosts: [options.host],
72
72
  upstream: 'app',
73
73
  scheme: 'https',
74
- ...contractProxyTable(options.artifact),
75
- ...(options.crossOrigins === undefined || options.crossOrigins.length === 0 ? {} : { crossOrigins: [...options.crossOrigins] }),
76
- }],
74
+ table: contractProxyTable(options.artifact),
75
+ ...(options.crossOrigins === undefined ? {} : { crossOrigins: options.crossOrigins }),
76
+ })],
77
77
  },
78
78
  iam: options.iam,
79
79
  issuer: options.issuer,
@@ -0,0 +1,29 @@
1
+ export declare const APP_AGENT_SKILL_NAME = "fabrika-app";
2
+ export interface AppAgentSkillReference {
3
+ /** The file's path in the skill directory, `references/<topic>.md`: the path `SKILL.md` names it by. */
4
+ readonly path: string;
5
+ /** The topic `get_app_rules` answers it under: its file name without `.md`. */
6
+ readonly topic: string;
7
+ readonly text: string;
8
+ }
9
+ export interface AppAgentSkill {
10
+ /** The `version` of the frontmatter: the version of the `@fabrika/app` that ships it. */
11
+ readonly version: string;
12
+ /** The whole `SKILL.md`, frontmatter included: what a skill directory holds. */
13
+ readonly text: string;
14
+ /** `SKILL.md` without the frontmatter. */
15
+ readonly body: string;
16
+ /** The reference files, by path. */
17
+ readonly references: readonly AppAgentSkillReference[];
18
+ }
19
+ /** The skill of this package. */
20
+ export declare function readAppAgentSkill(): AppAgentSkill;
21
+ /** The skill in `skillDir`, the `skill/` directory of an installed `@fabrika/app`. */
22
+ export declare function readAppAgentSkillAt(skillDir: string): AppAgentSkill;
23
+ /**
24
+ * The skill of the `@fabrika/app` an application installed: the nearest `node_modules/@fabrika/app` from `appRoot`
25
+ * up, as Bun resolves the package. `null` when the application has not installed it.
26
+ */
27
+ export declare function installedAppAgentSkill(appRoot: string): AppAgentSkill | null;
28
+ /** The `AGENTS.md` of an application repository: the skill's body, then each reference file. */
29
+ export declare function renderAgentsFile(skill: AppAgentSkill): string;
@@ -0,0 +1,70 @@
1
+ export { CONTRACT_APP_ID_PATTERN } from './contract/app-id.js';
2
+ export { FILE_SCAN_JOB, FILE_SWEEP_JOB } from './files/job-names.js';
3
+ /** The TypeScript release of the platform's type check; the application's own `typescript` is not run. */
4
+ export declare const PLATFORM_TYPESCRIPT_VERSION = "5.9.3";
5
+ /** A `c.cron` job name, which a crontab line carries unquoted. */
6
+ export declare const CONTRACT_JOB_NAME_PATTERN: RegExp;
7
+ /** Five cron fields of digits and `*`, `/`, `,`, `-` only. */
8
+ export declare const CRON_SCHEDULE_PATTERN: RegExp;
9
+ /** What one contract application's recipe depends on. `contractRecipe` refuses a value that a shell would read as more. */
10
+ export interface ContractRecipeInput {
11
+ readonly appId: string;
12
+ /** The `@fabrika/check` release whose reader read the application (ADR-0062). */
13
+ readonly checkRelease: string;
14
+ /** The absolute directory the build runs in: the checker reads the source there. */
15
+ readonly sourceRoot: string;
16
+ /** The absolute directory the built application runs from: each generated script starts there. */
17
+ readonly runtimeRoot: string;
18
+ /** With a database, every container migrates and checks its schema before the server starts. */
19
+ readonly database: boolean;
20
+ /** Whether the build uploads its source maps, last and never failing the build. */
21
+ readonly sourceMaps: boolean;
22
+ readonly jobs: readonly {
23
+ readonly name: string;
24
+ readonly schedule: string;
25
+ }[];
26
+ /** The contract declares a file collection: the sweep of removed files is scheduled. */
27
+ readonly files: boolean;
28
+ /** A file collection scans its uploads: the scan pass is scheduled. */
29
+ readonly scan: boolean;
30
+ }
31
+ /** One build step: a command run in the source root, or a generated file written there, line by line. */
32
+ export type ContractBuildStep = {
33
+ readonly run: string;
34
+ } | {
35
+ readonly write: string;
36
+ readonly lines: readonly string[];
37
+ };
38
+ /** One check of the build: a command run in the source root that fails the build. */
39
+ export interface ContractCheck {
40
+ readonly name: 'tests' | 'tsc' | 'fabrika-check';
41
+ readonly command: string;
42
+ }
43
+ export interface ContractRecipe {
44
+ /** The steps after the authored SPA commands, in order. */
45
+ readonly build: readonly ContractBuildStep[];
46
+ /** The checks among `build`, in its order. */
47
+ readonly checks: readonly ContractCheck[];
48
+ /** The generated scripts every container runs before `start`, in order; any failure stops the activation. */
49
+ readonly init: readonly string[];
50
+ /** The generated script that starts the server, which only `exec`s it. */
51
+ readonly start: string;
52
+ /** The command the start script `exec`s, run from the runtime root. */
53
+ readonly serve: readonly string[];
54
+ /** The command that migrates the application's database, run from the runtime root. */
55
+ readonly migrate: readonly string[];
56
+ /** The jobs to schedule, each on one container; a command runs from the runtime root. */
57
+ readonly jobs: readonly {
58
+ readonly schedule: string;
59
+ readonly command: string;
60
+ }[];
61
+ }
62
+ /**
63
+ * The recipe of one contract application. The application's tests run first: they are authored code, so everything
64
+ * after them is reinstalled, regenerated or checked again. They run `--isolate`, as the application skill runs them: a
65
+ * test file's DOM globals otherwise leak into the next file. `tsc` runs before the checker, as in the skill's loop: the
66
+ * usual quick fix of a type error is a cast or an `any`, which only the checker refuses, so the checker is the last word
67
+ * on the code. The build has no database, so the schema checks run as init scripts, after the migrations and before the
68
+ * readiness window opens.
69
+ */
70
+ export declare function contractRecipe(input: ContractRecipeInput): ContractRecipe;
@@ -0,0 +1,29 @@
1
+ /** An application root with this file is a contract application. The paths equal `@fabrika/check`'s layout. */
2
+ export declare const CONTRACT_FILE = "fabrika.contract.ts";
3
+ /** Relative to the application root. */
4
+ export declare const GENERATED_ENTRY_FILE = "fabrika/entry.generated.ts";
5
+ /** The module whose default export is `serve(contract, …)`, relative to the application root. */
6
+ export declare const IMPLEMENTATION_FILE = "src/app.ts";
7
+ /** The application's migrations as a module, relative to the application root: a Worker reads no file system. */
8
+ export declare const GENERATED_MIGRATIONS_FILE = "fabrika/migrations.generated.ts";
9
+ /** Where an application keeps its migrations, relative to its root; `fabrika db new` writes there. */
10
+ export declare const MIGRATIONS_DIRECTORY = "db/migrations";
11
+ export type EntryRuntime = 'bun' | 'cloudflare';
12
+ export * from './build-recipe.js';
13
+ export { CLOUDFLARE_APPLICATION_BINDING, CLOUDFLARE_APPLICATION_CLASS, CLOUDFLARE_FILES_BINDING } from './cloudflare/names.js';
14
+ /** The entrypoint source for one runtime. */
15
+ export declare function generatedEntry(runtime: EntryRuntime): string;
16
+ /** Writes the entrypoint under `root` and returns its path; the Cloudflare one also writes the migrations module it imports. */
17
+ export declare function writeGeneratedEntry(root: string, runtime: EntryRuntime): Promise<string>;
18
+ /** The application's migrations under `root`, every `.sql` file of `db/migrations` by name; none without the directory. */
19
+ export declare function applicationMigrations(root: string): Promise<readonly {
20
+ readonly name: string;
21
+ readonly sql: string;
22
+ }[]>;
23
+ /** The migrations module source: the files' names and texts, verbatim, so their checksums stay the files'. */
24
+ export declare function generatedMigrations(migrations: readonly {
25
+ readonly name: string;
26
+ readonly sql: string;
27
+ }[]): string;
28
+ /** Writes the migrations module under `root` and returns its path. */
29
+ export declare function writeGeneratedMigrations(root: string): Promise<string>;