@fabrika/app 0.8.2 → 0.9.0-cloudflare.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/package.json +40 -24
  2. package/skill/SKILL.md +29 -3
  3. package/skill/references/app-setup.md +29 -11
  4. package/skill/references/dev.md +2 -1
  5. package/skill/references/endpoint.md +12 -3
  6. package/skill/references/errors.md +39 -35
  7. package/skill/references/files.md +4 -2
  8. package/skill/references/jobs.md +9 -0
  9. package/skill/references/table-and-migration.md +19 -2
  10. package/skill/references/tests.md +33 -1
  11. package/skill/references/webhooks-links-connectors.md +1 -1
  12. package/src/build-recipe.ts +198 -0
  13. package/src/build.ts +60 -4
  14. package/src/bun.ts +33 -8
  15. package/src/cloudflare/application.ts +352 -0
  16. package/src/cloudflare/bindings.ts +159 -0
  17. package/src/cloudflare/names.ts +16 -0
  18. package/src/cloudflare/ops.ts +246 -0
  19. package/src/cloudflare/r2-files.ts +172 -0
  20. package/src/cloudflare.ts +99 -146
  21. package/src/contract/app-id.ts +5 -0
  22. package/src/contract/artifact-schema.ts +8 -1
  23. package/src/contract/build.ts +68 -11
  24. package/src/contract/compile.ts +1 -0
  25. package/src/contract/context.ts +9 -0
  26. package/src/contract/declaration.ts +2 -0
  27. package/src/contract/define.ts +28 -2
  28. package/src/contract/group.ts +10 -1
  29. package/src/contract/kit.ts +6 -2
  30. package/src/contract/platform.ts +3 -2
  31. package/src/contract/vocabulary.ts +26 -8
  32. package/src/dev/changes.ts +35 -0
  33. package/src/dev/contract-digest.ts +17 -0
  34. package/src/dev/index.ts +11 -0
  35. package/src/dev/launcher.ts +713 -0
  36. package/src/dev/output.ts +72 -0
  37. package/src/dev/wire.ts +136 -0
  38. package/src/disk-assets.ts +8 -3
  39. package/src/files/archive.ts +2 -2
  40. package/src/files/job-names.ts +12 -0
  41. package/src/files/kinds.ts +19 -7
  42. package/src/files/scan.ts +3 -4
  43. package/src/files/streams.ts +3 -1
  44. package/src/implement/anonymous.ts +11 -1
  45. package/src/implement/boot.ts +70 -7
  46. package/src/implement/bounded.ts +21 -2
  47. package/src/implement/capability.ts +18 -3
  48. package/src/implement/dispatch.ts +251 -147
  49. package/src/implement/egress.ts +341 -8
  50. package/src/implement/failures.ts +17 -1
  51. package/src/implement/faults.ts +1 -0
  52. package/src/implement/index.ts +1 -0
  53. package/src/implement/invocation.ts +18 -9
  54. package/src/implement/principal.ts +25 -2
  55. package/src/implement/terminals.ts +93 -0
  56. package/src/implement/workload.ts +24 -13
  57. package/src/jobs/report.ts +5 -1
  58. package/src/jobs/runner.ts +40 -19
  59. package/src/jobs/schedule.ts +7 -1
  60. package/src/jobs/scrub.ts +14 -1
  61. package/src/spa-fallback.ts +12 -0
  62. package/src/testing/endpoint-access.ts +307 -152
  63. package/src/testing/isolation.ts +8 -1
  64. package/src/testing/kit.ts +20 -2
  65. package/src/testing/proxy.ts +5 -5
  66. package/types/src/agent-skill.d.ts +29 -0
  67. package/types/src/build-recipe.d.ts +70 -0
  68. package/types/src/build.d.ts +29 -0
  69. package/types/src/bun.d.ts +66 -0
  70. package/types/src/client/caller.d.ts +19 -0
  71. package/types/src/client/contract.d.ts +131 -0
  72. package/types/src/client/error-reporting.d.ts +66 -0
  73. package/types/src/client/index.d.ts +4 -0
  74. package/types/src/client/rpc.d.ts +14 -0
  75. package/types/src/client/share.d.ts +2 -0
  76. package/types/src/client/transport.d.ts +42 -0
  77. package/types/src/cloudflare/application.d.ts +42 -0
  78. package/types/src/cloudflare/bindings.d.ts +33 -0
  79. package/types/src/cloudflare/names.d.ts +11 -0
  80. package/types/src/cloudflare/ops.d.ts +46 -0
  81. package/types/src/cloudflare/r2-files.d.ts +9 -0
  82. package/types/src/cloudflare.d.ts +42 -0
  83. package/types/src/contract/app-id.d.ts +2 -0
  84. package/types/src/contract/artifact-schema.d.ts +8 -0
  85. package/types/src/contract/attachment.d.ts +41 -0
  86. package/types/src/contract/build.d.ts +12 -0
  87. package/types/src/contract/compile.d.ts +11 -0
  88. package/types/src/contract/connect.d.ts +51 -0
  89. package/types/src/contract/context.d.ts +510 -0
  90. package/types/src/contract/declaration.d.ts +307 -0
  91. package/types/src/contract/defaults.d.ts +208 -0
  92. package/types/src/contract/define.d.ts +376 -0
  93. package/types/src/contract/file.d.ts +44 -0
  94. package/types/src/contract/group.d.ts +282 -0
  95. package/types/src/contract/handled.d.ts +18 -0
  96. package/types/src/contract/index.d.ts +19 -0
  97. package/types/src/contract/kit.d.ts +211 -0
  98. package/types/src/contract/platform.d.ts +9 -0
  99. package/types/src/contract/problems.d.ts +14 -0
  100. package/types/src/contract/redirect.d.ts +25 -0
  101. package/types/src/contract/schema-from-shape.d.ts +10 -0
  102. package/types/src/contract/schema-shape.d.ts +5 -0
  103. package/types/src/contract/snapshot-source.d.ts +15 -0
  104. package/types/src/contract/token.d.ts +58 -0
  105. package/types/src/contract/vocabulary.d.ts +32 -0
  106. package/types/src/contract/webhook-binding.d.ts +24 -0
  107. package/types/src/contract/webhook-scheme.d.ts +23 -0
  108. package/types/src/dev/changes.d.ts +7 -0
  109. package/types/src/dev/contract-digest.d.ts +1 -0
  110. package/types/src/dev/index.d.ts +2 -0
  111. package/types/src/dev/launcher.d.ts +32 -0
  112. package/types/src/dev/output.d.ts +28 -0
  113. package/types/src/dev/wire.d.ts +126 -0
  114. package/types/src/disk-assets.d.ts +6 -0
  115. package/types/src/errors.d.ts +46 -0
  116. package/types/src/fetch.d.ts +7 -0
  117. package/types/src/files/archive.d.ts +26 -0
  118. package/types/src/files/clamscan.d.ts +32 -0
  119. package/types/src/files/handle.d.ts +106 -0
  120. package/types/src/files/job-names.d.ts +9 -0
  121. package/types/src/files/kinds.d.ts +101 -0
  122. package/types/src/files/scan.d.ts +78 -0
  123. package/types/src/files/sniff.d.ts +7 -0
  124. package/types/src/files/streams.d.ts +17 -0
  125. package/types/src/files/treatment.d.ts +18 -0
  126. package/types/src/from-row.d.ts +20 -0
  127. package/types/src/honeypot.d.ts +2 -0
  128. package/types/src/implement/anonymous.d.ts +28 -0
  129. package/types/src/implement/attachment.d.ts +14 -0
  130. package/types/src/implement/boot.d.ts +120 -0
  131. package/types/src/implement/bounded.d.ts +32 -0
  132. package/types/src/implement/browser-config.d.ts +19 -0
  133. package/types/src/implement/capability.d.ts +144 -0
  134. package/types/src/implement/challenge.d.ts +75 -0
  135. package/types/src/implement/client-address.d.ts +11 -0
  136. package/types/src/implement/connect.d.ts +67 -0
  137. package/types/src/implement/crashes.d.ts +31 -0
  138. package/types/src/implement/dispatch.d.ts +221 -0
  139. package/types/src/implement/egress.d.ts +49 -0
  140. package/types/src/implement/environment.d.ts +14 -0
  141. package/types/src/implement/failures.d.ts +39 -0
  142. package/types/src/implement/faults.d.ts +45 -0
  143. package/types/src/implement/headers.d.ts +7 -0
  144. package/types/src/implement/implement.d.ts +58 -0
  145. package/types/src/implement/index.d.ts +25 -0
  146. package/types/src/implement/invocation.d.ts +125 -0
  147. package/types/src/implement/membership.d.ts +201 -0
  148. package/types/src/implement/multipart.d.ts +36 -0
  149. package/types/src/implement/params.d.ts +7 -0
  150. package/types/src/implement/principal.d.ts +57 -0
  151. package/types/src/implement/project.d.ts +11 -0
  152. package/types/src/implement/public.d.ts +61 -0
  153. package/types/src/implement/serve.d.ts +25 -0
  154. package/types/src/implement/terminals.d.ts +33 -0
  155. package/types/src/implement/tokens.d.ts +49 -0
  156. package/types/src/implement/types.d.ts +243 -0
  157. package/types/src/implement/urls.d.ts +15 -0
  158. package/types/src/implement/verifiers.d.ts +19 -0
  159. package/types/src/implement/webhook.d.ts +55 -0
  160. package/types/src/implement/workload.d.ts +56 -0
  161. package/types/src/implement/xlsx.d.ts +6 -0
  162. package/types/src/index.d.ts +12 -0
  163. package/types/src/jobs/exception.d.ts +40 -0
  164. package/types/src/jobs/mail.d.ts +67 -0
  165. package/types/src/jobs/report.d.ts +56 -0
  166. package/types/src/jobs/runner.d.ts +154 -0
  167. package/types/src/jobs/schedule.d.ts +17 -0
  168. package/types/src/jobs/scrub.d.ts +12 -0
  169. package/types/src/jobs/types.d.ts +98 -0
  170. package/types/src/react/caller.d.ts +30 -0
  171. package/types/src/react/errors.d.ts +40 -0
  172. package/types/src/react/hooks.d.ts +110 -0
  173. package/types/src/react/index.d.ts +5 -0
  174. package/types/src/react/provider.d.ts +58 -0
  175. package/types/src/react/store.d.ts +68 -0
  176. package/types/src/react/testing.d.ts +28 -0
  177. package/types/src/react/types.d.ts +33 -0
  178. package/types/src/rpc/builder.d.ts +52 -0
  179. package/types/src/rpc/contract.d.ts +23 -0
  180. package/types/src/rpc/dispatcher.d.ts +11 -0
  181. package/types/src/rpc/types.d.ts +62 -0
  182. package/types/src/service/define.d.ts +42 -0
  183. package/types/src/service/index.d.ts +3 -0
  184. package/types/src/service/serve.d.ts +28 -0
  185. package/types/src/spa-fallback.d.ts +2 -0
  186. package/types/src/standard-schema.d.ts +55 -0
  187. package/types/src/testing/caller.d.ts +95 -0
  188. package/types/src/testing/challenge.d.ts +11 -0
  189. package/types/src/testing/dev.d.ts +97 -0
  190. package/types/src/testing/endpoint-access.d.ts +131 -0
  191. package/types/src/testing/index.d.ts +12 -0
  192. package/types/src/testing/isolation.d.ts +3 -0
  193. package/types/src/testing/kit.d.ts +187 -0
  194. package/types/src/testing/membership.d.ts +132 -0
  195. package/types/src/testing/policy-isolation.d.ts +113 -0
  196. package/types/src/testing/preload.d.ts +1 -0
  197. package/types/src/testing/proxy.d.ts +40 -0
  198. package/types/src/testing/runner.d.ts +8 -0
  199. package/types/src/testing/sample-file.d.ts +5 -0
  200. package/types/src/testing/status.d.ts +4 -0
  201. package/types/src/undumpable.d.ts +5 -0
package/src/cloudflare.ts CHANGED
@@ -1,17 +1,31 @@
1
1
  // ─── Cloudflare Workers adapter ──────────────────────────────────────
2
2
 
3
- import { DecodedIamMembership } from '@fabrika/auth'
4
- import type { IamMembershipRpc } from '@fabrika/auth-core'
5
- import { parseJwks } from '@fabrika/jwks'
3
+ import {
4
+ type ApplicationObjects,
5
+ FABRIKA_APPLICATION_OBJECT,
6
+ FABRIKA_APPLICATION_OBJECTS,
7
+ FABRIKA_PROXY_SECRET,
8
+ OPS_PREFIX,
9
+ opsRefusal,
10
+ parseApplicationObjects,
11
+ parseOpsOperation,
12
+ PROXY_SECRET_HEADER,
13
+ } from '@fabrika/auth-core'
14
+ import { type ContractWorkerOptions, type ImplementationLoader, registerApplication, TICK_HEADER } from './cloudflare/application.js'
15
+ import { method } from './cloudflare/bindings.js'
16
+ import { CLOUDFLARE_APPLICATION_BINDING } from './cloudflare/names.js'
17
+ import { jsonBody, objectNamed, opsAccess, opsAuthenticated } from './cloudflare/ops.js'
6
18
  import type { FetchService, RequestExecutionContext } from './fetch.js'
7
- import { boot, type ContractApp, type IamKeySource } from './implement/boot.js'
8
- import { CONNECT_BINDING_PREFIX, type ConnectFetch } from './implement/connect.js'
9
- import { type CrashEvents, environmentCrashReporter, reportSurvivedCrashes } from './implement/crashes.js'
10
- import type { SpaAssets } from './implement/dispatch.js'
11
- import { captureEnvironment } from './implement/environment.js'
12
- import type { ContractImplementation } from './implement/implement.js'
13
19
 
14
20
  export { markProductionProcess } from '@fabrika/db/production'
21
+ export {
22
+ type ApplicationMigration,
23
+ type ApplicationState,
24
+ type ApplicationStorage,
25
+ type ContractWorkerOptions,
26
+ FabrikaApplication,
27
+ type ImplementationLoader,
28
+ } from './cloudflare/application.js'
15
29
 
16
30
  /** Minimal structural view of a Workers `ExecutionContext`. */
17
31
  export interface CloudflareExecutionContext extends RequestExecutionContext {
@@ -58,166 +72,105 @@ export function createCloudflareWorker<Env>(
58
72
 
59
73
  // ─── Contract applications ───────────────────────────────────────────
60
74
 
61
- /** The framework's bindings: IAM's application entrypoint and the Workers Assets of the SPA. No handler sees them. */
62
- const FRAMEWORK_BINDINGS: ReadonlySet<string> = new Set(['IAM', 'ASSETS'])
63
-
64
- function method(target: unknown, name: string): ((...args: unknown[]) => unknown) | null {
65
- if (typeof target !== 'object' || target === null || !(name in target)) {
66
- return null
67
- }
68
- const found: unknown = Reflect.get(target, name)
69
- return typeof found === 'function' ? (...args) => Reflect.apply(found, target, args) : null
70
- }
71
-
72
- function bindingKeySource(binding: unknown): IamKeySource {
73
- const getJwks = method(binding, 'getJwks')
74
- if (getJwks === null) {
75
- throw new Error('the IAM binding is missing (env.IAM); the platform binds it to every application Worker')
76
- }
77
- return {
78
- async getJwks() {
79
- const jwks = parseJwks(await getJwks())
80
- if (jwks === null) {
81
- throw new Error('IAM returned no usable key set')
82
- }
83
- return jwks
84
- },
75
+ /** The environment's proxy secret, which every request the Worker forwards to its object carries. */
76
+ function proxySecretOf(env: Readonly<Record<string, unknown>>): string {
77
+ const secret = env[FABRIKA_PROXY_SECRET]
78
+ if (typeof secret !== 'string' || secret === '') {
79
+ throw new Error(`${FABRIKA_PROXY_SECRET} is not set; the application's Worker forwards nothing to its object without it`)
85
80
  }
81
+ return secret
86
82
  }
87
83
 
88
84
  /**
89
- * IAM's membership operations on the `IAM` binding (`PropustkaApplication`), every answer decoded like the HTTP
90
- * transport's. A binding without them is no membership transport, and `boot` refuses a contract that needs one.
85
+ * The object of ordinary traffic and the objects an ops request may name, all in the environment's own namespace.
86
+ * Without either variable (an older deploy) the one object is `application`.
91
87
  */
92
- function bindingMembership(binding: unknown): IamMembershipRpc | undefined {
93
- const methods = ['requestDelegation', 'createScope', 'assignMember', 'revokeMember', 'listMembers', 'issueLink', 'revokeLink'] as const
94
- if (!methods.every((name) => method(binding, name) !== null)) {
95
- return undefined
88
+ function applicationObjects(env: Readonly<Record<string, unknown>>): ApplicationObjects {
89
+ const object = env[FABRIKA_APPLICATION_OBJECT]
90
+ const objects = env[FABRIKA_APPLICATION_OBJECTS]
91
+ const parsed = parseApplicationObjects(typeof object === 'string' ? object : undefined, typeof objects === 'string' ? objects : undefined)
92
+ if (parsed === null) {
93
+ throw new Error(`${FABRIKA_APPLICATION_OBJECT} and ${FABRIKA_APPLICATION_OBJECTS} name no valid set of objects; a deploy writes both`)
96
94
  }
97
- return new DecodedIamMembership(async (name, input) => {
98
- const call = method(binding, name)
99
- if (call === null) {
100
- throw new Error(`the IAM binding has no ${name}`)
101
- }
102
- return await call(input)
103
- })
95
+ return parsed
104
96
  }
105
97
 
106
- function bindingAssets(binding: unknown): SpaAssets | undefined {
107
- const fetchAsset = method(binding, 'fetch')
108
- if (fetchAsset === null) {
109
- return undefined
98
+ /** The object `name` of the environment's namespace (`FABRIKA_APPLICATION`), as a fetch. */
99
+ function applicationObject(env: Readonly<Record<string, unknown>>, name: string): (request: Request) => Promise<Response> {
100
+ const namespace = env[CLOUDFLARE_APPLICATION_BINDING]
101
+ const idFromName = method(namespace, 'idFromName')
102
+ const get = method(namespace, 'get')
103
+ if (idFromName === null || get === null) {
104
+ throw new Error(`the ${CLOUDFLARE_APPLICATION_BINDING} binding is missing; the provider binds the application's Durable Object to its Worker`)
110
105
  }
111
- return {
112
- async serve(_mount, request) {
113
- const response: unknown = await fetchAsset(request)
114
- if (!(response instanceof Response)) {
115
- throw new Error('the ASSETS binding answered something that is not a Response')
116
- }
117
- return response
118
- },
106
+ const stub: unknown = get(idFromName(name))
107
+ // Read, not tested with `in`: a stub answers `fetch` without owning the property.
108
+ const fetchObject: unknown = typeof stub === 'object' && stub !== null ? Reflect.get(stub, 'fetch') : undefined
109
+ if (typeof fetchObject !== 'function') {
110
+ throw new Error(`the ${CLOUDFLARE_APPLICATION_BINDING} binding answered no object`)
111
+ }
112
+ return async (request) => {
113
+ const response: unknown = await Reflect.apply(fetchObject, stub, [request])
114
+ if (!(response instanceof Response)) {
115
+ throw new Error("the application's object answered something that is not a Response")
116
+ }
117
+ return response
119
118
  }
120
119
  }
121
120
 
122
121
  /**
123
- * The service bindings to owners' proxy Workers (`FABRIKA_CONNECT_<IMPORT>`), which `captureEnvironment` keeps from
124
- * the application because no `FABRIKA_*` value reaches it. A binding routes by itself: the URL it is called with
125
- * carries the owner's host, which the proxy Worker selects the application by.
122
+ * An ops request reaches the object its body names, when the environment lists it (ADR-0069 §1). Every refusal here
123
+ * is the same answer the object gives, and the object checks everything again.
126
124
  */
127
- function connectBindings(env: Readonly<Record<string, unknown>>): Record<string, ConnectFetch> {
128
- const bindings: Record<string, ConnectFetch> = {}
129
- for (const [name, binding] of Object.entries(env)) {
130
- // Read, not tested with `in`: a binding stub may answer `fetch` without owning the property (Lopata's does not).
131
- const fetchOwner: unknown = name.startsWith(CONNECT_BINDING_PREFIX) && typeof binding === 'object' && binding !== null
132
- ? Reflect.get(binding, 'fetch')
133
- : undefined
134
- if (typeof fetchOwner === 'function') {
135
- bindings[name] = async (request) => {
136
- const response: unknown = await Reflect.apply(fetchOwner, binding, [request])
137
- if (!(response instanceof Response)) {
138
- throw new Error(`the ${name} binding answered something that is not a Response`)
139
- }
140
- return response
141
- }
142
- }
125
+ async function forwardOps(request: Request, env: Readonly<Record<string, unknown>>): Promise<Response> {
126
+ const access = opsAccess(env)
127
+ // Receiver authentication of Control's key, as the object runs it; no enforcement decision. It runs before the body is
128
+ // read or any object looked up, so an unauthenticated caller wakes no object and learns nothing of the names.
129
+ if (access === null || !opsAuthenticated(request, access)) {
130
+ return opsRefusal()
143
131
  }
144
- return bindings
145
- }
146
-
147
- /** The application module, imported only after the environment was captured: the generated entrypoint's `import()`. */
148
- export type ImplementationLoader = () => Promise<{ readonly default: ContractImplementation }>
149
-
150
- export interface ContractWorkerOptions {
151
- /** Where the isolate hears of an unhandled rejection; the global `unhandledrejection` event by default. */
152
- readonly rejections?: CrashEvents
153
- /** What posts a report to Operations' ingest; `fetch` by default. */
154
- readonly operationsFetch?: (request: Request) => Promise<Response>
132
+ const operation = parseOpsOperation(new URL(request.url).pathname)
133
+ const object = operation === null ? null : objectNamed(operation, await jsonBody(request.clone()))
134
+ if (object === null || !access.objects.objects.includes(object)) {
135
+ return opsRefusal()
136
+ }
137
+ return await applicationObject(env, object)(request)
155
138
  }
156
139
 
157
- /** The isolate's `unhandledrejection` event. Listening leaves the runtime's own handling, its log line, in place. */
158
- function globalRejectionEvents(): CrashEvents {
159
- return {
160
- listen(listener) {
161
- const rejection = (event: Event) => listener('unhandled_rejection', Reflect.get(event, 'reason'))
162
- globalThis.addEventListener('unhandledrejection', rejection)
163
- return () => globalThis.removeEventListener('unhandledrejection', rejection)
164
- },
165
- }
140
+ export interface ContractWorker {
141
+ fetch(request: Request, env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<Response>
142
+ scheduled(controller: CloudflareScheduledController, env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<void>
166
143
  }
167
144
 
168
145
  /**
169
- * A Worker module for a contract application, called only by the generated entrypoint. A Worker receives its
170
- * environment with the first request, so the framework captures it, scrubs the process environment and only then
171
- * imports the application and boots it, once per environment object. With an Operations DSN, the first failed boot
172
- * of an isolate is reported, and once one booted, every unhandled rejection of the isolate is, within a budget.
146
+ * The Worker module of a contract application, called only by the generated entrypoint, which also exports
147
+ * `FabrikaApplication`. The application runs in the environment's Durable Object (ADR-0065): `fetch` forwards every
148
+ * request to it with the proxy secret, the proxy's own headers unchanged, and `scheduled` forwards the minute tick.
149
+ * An ops request goes to the object its body names instead (ADR-0069). The object captures the environment, imports
150
+ * the application and boots it.
173
151
  */
174
- export function createContractWorker(load: ImplementationLoader, options: ContractWorkerOptions = {}): {
175
- fetch(request: Request, env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<Response>
176
- } {
177
- const booted = new WeakMap<object, Promise<ContractApp>>()
178
- let bootFailureReported = false
179
- let watchingRejections = false
180
- const bootFor = async (env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<ContractApp> => {
181
- const variables = Object.fromEntries(Object.entries(env).filter(([name]) => !FRAMEWORK_BINDINGS.has(name)))
182
- const environment = captureEnvironment(variables)
183
- const crashes = environmentCrashReporter(environment, options.operationsFetch)
184
- let contractApp: ContractApp
185
- try {
186
- const { default: app } = await load()
187
- contractApp = boot(app, {
188
- environment,
189
- iam: bindingKeySource(env['IAM']),
190
- assets: bindingAssets(env['ASSETS']),
191
- membership: bindingMembership(env['IAM']),
192
- connectBindings: connectBindings(env),
193
- // The proxy Worker sets `X-Fabrika-Client-Ip` from `CF-Connecting-IP`, the visitor Cloudflare saw.
194
- clientAddress: 'vouched',
195
- ...(options.operationsFetch === undefined ? {} : { operationsFetch: options.operationsFetch }),
196
- })
197
- } catch (error) {
198
- // Every request of a failed isolate boots again; one report says it.
199
- if (crashes !== null && !bootFailureReported) {
200
- bootFailureReported = true
201
- exec.waitUntil(crashes.report('boot_failed', error))
202
- }
203
- throw error
204
- }
205
- if (crashes !== null && !watchingRejections) {
206
- watchingRejections = true
207
- reportSurvivedCrashes(options.rejections ?? globalRejectionEvents(), crashes)
208
- }
209
- return contractApp
210
- }
152
+ export function createContractWorker(load: ImplementationLoader, options: ContractWorkerOptions = {}): ContractWorker {
153
+ registerApplication(load, options)
211
154
  return {
212
- async fetch(request, env, exec) {
213
- let contractApp = booted.get(env)
214
- if (contractApp === undefined) {
215
- contractApp = bootFor(env, exec)
216
- booted.set(env, contractApp)
217
- // A failed boot is not cached: the next request boots again and fails with the same problems.
218
- contractApp.catch(() => booted.delete(env))
155
+ async fetch(request, env) {
156
+ const headers = new Headers(request.headers)
157
+ headers.delete(TICK_HEADER)
158
+ headers.set(PROXY_SECRET_HEADER, proxySecretOf(env))
159
+ const forwarded = new Request(request, { headers })
160
+ if (new URL(request.url).pathname.startsWith(OPS_PREFIX)) {
161
+ return await forwardOps(forwarded, env)
162
+ }
163
+ return await applicationObject(env, applicationObjects(env).object)(forwarded)
164
+ },
165
+ async scheduled(controller, env) {
166
+ const tick = new Request('https://application.invalid/', {
167
+ method: 'POST',
168
+ headers: { [PROXY_SECRET_HEADER]: proxySecretOf(env), [TICK_HEADER]: String(controller.scheduledTime) },
169
+ })
170
+ const response = await applicationObject(env, applicationObjects(env).object)(tick)
171
+ if (!response.ok) {
172
+ throw new Error(`the application's object answered the minute tick with ${response.status}`)
219
173
  }
220
- return (await contractApp).fetch(request, exec)
221
174
  },
222
175
  }
223
176
  }
@@ -0,0 +1,5 @@
1
+ // A leaf module: the contract (client side included) and the build recipe both read the id rule, and the contract must
2
+ // not reach the recipe's server-side modules through it.
3
+
4
+ /** The contract's application id rule; an id that holds to it reaches a generated script unquoted. */
5
+ export const CONTRACT_APP_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
@@ -14,16 +14,19 @@ import {
14
14
  FILE_RESPONSE_KINDS,
15
15
  isOwnWorkloadName,
16
16
  JOB_MAILERS,
17
+ JOB_SITE_PREFIX,
17
18
  LOCALES,
18
19
  mailTemplateProblems,
19
20
  MAX_CACHE_AGE_SECONDS,
20
21
  type MessageKind,
21
22
  type NumberShape,
22
23
  type OutputShape,
24
+ PUBLIC_EGRESS,
23
25
  type RecordShape,
24
26
  type SchemaShape,
25
27
  type StringShape,
26
28
  type TablePolicyArtifact,
29
+ terminalIdentifyProblem,
27
30
  type TupleShape,
28
31
  } from '@fabrika/auth-core'
29
32
  import { isPolicyOrigin, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
@@ -410,9 +413,12 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
410
413
  kicks: z.array(name).nonempty().optional(),
411
414
  binds: name.optional(),
412
415
  signs: z.array(name).nonempty().optional(),
413
- egress: z.array(z.string().refine((origin) => isPolicyOrigin(origin))).nonempty().optional(),
416
+ egress: z.union([z.array(z.string().refine((origin) => isPolicyOrigin(origin))).nonempty(), z.literal(PUBLIC_EGRESS)]).optional(),
414
417
  })).superRefine((declared, context) => {
415
418
  for (const [site, reach] of Object.entries(declared)) {
419
+ if (reach.egress === PUBLIC_EGRESS && !site.startsWith(JOB_SITE_PREFIX)) {
420
+ context.addIssue({ code: 'custom', path: [site, 'egress'], message: "egress: 'public' belongs to a cron job, keyed by job:<name>" })
421
+ }
416
422
  if (reach.actions !== undefined && !isOwnWorkloadName('public', site)) {
417
423
  context.addIssue({ code: 'custom', path: [site, 'actions'], message: 'workload actions belong to a public procedure, keyed by its endpoint id' })
418
424
  }
@@ -425,6 +431,7 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
425
431
  .optional(),
426
432
  members: z.strictObject({ manage: action, list: action }).optional(),
427
433
  directory: z.literal('labels').optional(),
434
+ terminal: z.strictObject({ identify: z.string().refine((path) => terminalIdentifyProblem(path) === null, 'not an identification path') }).optional(),
428
435
  procedures: record(z.strictObject({
429
436
  rate: z.strictObject({
430
437
  perClient: bucket,
@@ -13,10 +13,12 @@ import {
13
13
  type CapabilityAccessForm,
14
14
  type CapabilityRequirement,
15
15
  type ChallengeProvider,
16
+ compileEndpointTable,
16
17
  CONTRACT_FORMAT,
17
18
  type ContractArtifact,
18
19
  type CspExtensions,
19
20
  type CspSource,
21
+ type DeclaredEgress,
20
22
  type DeclaredReach,
21
23
  type DimensionArtifact,
22
24
  directoryReach,
@@ -48,18 +50,23 @@ import {
48
50
  type MessageKind,
49
51
  type MountArtifact,
50
52
  type OutputShape,
53
+ planEndpoint,
51
54
  type ProcedureArtifact,
52
55
  type ProcedureChallenge,
56
+ PUBLIC_EGRESS,
53
57
  type RoleArtifact,
54
58
  type SchemaShape,
55
59
  type ScopeBinding,
56
60
  tableActions,
57
61
  type TablePolicyArtifact,
62
+ type TerminalArtifact,
63
+ terminalIdentifyProblem,
58
64
  type TokenPurposeArtifact,
59
65
  type WebhookScheme,
60
66
  } from '@fabrika/auth-core'
61
67
  import { isPolicyOrigin, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
62
68
  import { z } from 'zod/v4'
69
+ import { CONTRACT_APP_ID_PATTERN as APP_ID } from './app-id'
63
70
  import { contractArtifactSchema } from './artifact-schema'
64
71
  import {
65
72
  type AccessDeclaration,
@@ -117,7 +124,6 @@ import { ContractError, Problems } from './problems'
117
124
  import { TOKEN_MAX_DAYS } from './token'
118
125
  import { hmacSchemeSchema, WEBHOOK_SCHEME_ALLOWED, webhookSchemeSchema } from './webhook-scheme'
119
126
 
120
- const APP_ID = /^[a-z0-9][a-z0-9-]*$/
121
127
  const NAME = /^[A-Za-z][A-Za-z0-9_]*$/
122
128
  const DIMENSION = /^[a-z][A-Za-z0-9_]*$/
123
129
  const ACTION = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/
@@ -143,7 +149,7 @@ const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
143
149
  /** The most origins one site calls: a site that reaches more is an integration hub, which a connector import serves. */
144
150
  const MAX_EGRESS_ORIGINS = 16
145
151
  const DECLARED_ALLOWED =
146
- "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', mailer: 'installation' | 'own', kicks: ['<cron job>'], binds: '<webhook>', signs: ['<token purpose>'], egress: ['https://<host>']"
152
+ "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', mailer: 'installation' | 'own', kicks: ['<cron job>'], binds: '<webhook>', signs: ['<token purpose>'], egress: ['https://<host>'] | 'public' (a cron job)"
147
153
  const CSP_FIELDS = ['connectSrc', 'imgSrc', 'frameAncestors', 'scriptSrc', 'frameSrc', 'styleSrc', 'fontSrc']
148
154
  /** An environment variable an application reads; a challenge names its secret by one. */
149
155
  const APPLICATION_VARIABLE = /^[A-Z][A-Z0-9_]*$/
@@ -234,6 +240,7 @@ class ArtifactBuilder {
234
240
  const displayName = this.#displayName()
235
241
  const mail = this.#mail()
236
242
  const people = this.#people()
243
+ const terminal = this.#terminal()
237
244
  const artifact: ContractArtifact = {
238
245
  format: CONTRACT_FORMAT,
239
246
  app: contract.app,
@@ -256,6 +263,7 @@ class ArtifactBuilder {
256
263
  ...(displayName === null ? {} : { displayName }),
257
264
  ...(mail === null ? {} : { mail }),
258
265
  ...people,
266
+ ...(terminal === null ? {} : { terminal }),
259
267
  ...(Object.keys(this.#procedureRules).length === 0 ? {} : { procedures: this.#procedureRules }),
260
268
  ...(Object.keys(tokens).length === 0 ? {} : { tokens }),
261
269
  }
@@ -263,10 +271,38 @@ class ArtifactBuilder {
263
271
  assertNoAmbiguity(artifact)
264
272
  } catch (error) {
265
273
  this.#problem('endpoints', error instanceof Error ? error.message : String(error))
274
+ return artifact
275
+ }
276
+ if (terminal !== null) {
277
+ this.#checkIdentifyPage(artifact, terminal)
266
278
  }
267
279
  return artifact
268
280
  }
269
281
 
282
+ /** The terminal's identification page, when its path is one. */
283
+ #terminal(): TerminalArtifact | null {
284
+ const terminal = this.#contract.terminal
285
+ if (terminal === undefined) return null
286
+ const problem = terminalIdentifyProblem(terminal.identify)
287
+ if (problem !== null) {
288
+ this.#problem('terminal.identify', `'${terminal.identify}' ${problem}`)
289
+ return null
290
+ }
291
+ return { identify: terminal.identify }
292
+ }
293
+
294
+ /**
295
+ * Nobody is signed in on the identification page, so a public SPA mount must serve it: under a credentialed one, the
296
+ * proxy would bounce the terminal's browser back to sign-in, which sends it to the page again.
297
+ */
298
+ #checkIdentifyPage(artifact: ContractArtifact, terminal: TerminalArtifact): void {
299
+ const plan = planEndpoint(compileEndpointTable(artifact), 'GET', terminal.identify)
300
+ if (plan.outcome !== 'endpoint' || plan.endpoint.kind !== 'public-spa') {
301
+ const served = plan.outcome === 'endpoint' ? `'${plan.endpoint.id}' (${plan.endpoint.kind})` : 'no endpoint'
302
+ this.#problem('terminal.identify', `'${terminal.identify}' is served by ${served}; serve it from an spa mount with access: { public: true }`)
303
+ }
304
+ }
305
+
270
306
  /** The member directory each endpoint reads, recorded so the approval and the digest cover the reach. */
271
307
  #directories(dimensions: ContractArtifact['dimensions']): Record<string, EndpointDirectory> {
272
308
  const directories: Record<string, EndpointDirectory> = {}
@@ -2072,7 +2108,7 @@ class ArtifactBuilder {
2072
2108
  ...(kicks.length === 0 ? {} : { kicks }),
2073
2109
  ...(binds === undefined ? {} : { binds }),
2074
2110
  ...(signs.length === 0 ? {} : { signs }),
2075
- ...(egress.length === 0 ? {} : { egress }),
2111
+ ...(egress === null ? {} : { egress }),
2076
2112
  }
2077
2113
  if (place.kind !== 'rpc') {
2078
2114
  this.#rpcOnly(where, 'connect', value['connect'])
@@ -2271,12 +2307,13 @@ class ArtifactBuilder {
2271
2307
 
2272
2308
  /**
2273
2309
  * The HTTPS origins a site's code calls: a credentialed procedure of an RPC mount, a webhook or a job, never a public,
2274
- * export or link procedure. Each is an exact origin with no path or wildcard, once; `[]` when it declares none or after
2275
- * a problem.
2310
+ * export or link procedure. Each is an exact origin with no path or wildcard, once. Or `'public'`, every public host
2311
+ * (ADR-0070), which only a cron job declares: it calls outside any transaction, and its schedule, not a caller,
2312
+ * decides when. `null` when it declares none or after a problem.
2276
2313
  */
2277
- #egress(where: string, value: unknown, place: DeclaringSite): string[] {
2314
+ #egress(where: string, value: unknown, place: DeclaringSite): DeclaredEgress | null {
2278
2315
  if (value === undefined) {
2279
- return []
2316
+ return null
2280
2317
  }
2281
2318
  const refusedBy = place.kind === 'export'
2282
2319
  ? 'an export procedure'
@@ -2287,15 +2324,35 @@ class ArtifactBuilder {
2287
2324
  : null
2288
2325
  if (refusedBy !== null) {
2289
2326
  this.#problem(where, `declares egress, which ${refusedBy} never does: a credentialed procedure, a webhook or a job calls out`)
2290
- return []
2327
+ return null
2328
+ }
2329
+ if (value === PUBLIC_EGRESS) {
2330
+ if (place.kind !== 'job') {
2331
+ this.#problem(
2332
+ where,
2333
+ `declares egress: '${PUBLIC_EGRESS}', which only a cron job declares: a procedure or a webhook names its origins (allowed: egress: ['https://api.example.com'])`,
2334
+ )
2335
+ return null
2336
+ }
2337
+ return PUBLIC_EGRESS
2291
2338
  }
2292
2339
  if (!Array.isArray(value) || value.length === 0 || !value.every((origin) => typeof origin === 'string')) {
2293
- this.#problem(where, "egress is not a list of origins (allowed: egress: ['https://api.example.com'])")
2294
- return []
2340
+ this.#problem(
2341
+ where,
2342
+ place.kind === 'job'
2343
+ ? "egress is not a list of origins or 'public' (allowed: egress: ['https://api.example.com'] or egress: 'public')"
2344
+ : "egress is not a list of origins (allowed: egress: ['https://api.example.com'])",
2345
+ )
2346
+ return null
2295
2347
  }
2296
2348
  const origins: string[] = value.filter((origin) => typeof origin === 'string')
2297
2349
  if (origins.length > MAX_EGRESS_ORIGINS) {
2298
- this.#problem(where, `${origins.length} egress origins exceed the limit of ${MAX_EGRESS_ORIGINS}`)
2350
+ this.#problem(
2351
+ where,
2352
+ `${origins.length} egress origins exceed the limit of ${MAX_EGRESS_ORIGINS}${
2353
+ place.kind === 'job' ? " (a cron job that calls the websites its operators name declares egress: 'public')" : ''
2354
+ }`,
2355
+ )
2299
2356
  }
2300
2357
  for (const origin of origins) {
2301
2358
  if (!isPolicyOrigin(origin)) {
@@ -81,6 +81,7 @@ export function describeContract(contract: AnyContract): ContractDeclaration<Sch
81
81
  ...(vocabulary.mail === undefined ? {} : { mail: vocabulary.mail }),
82
82
  ...(vocabulary.members === undefined ? {} : { members: vocabulary.members }),
83
83
  ...(vocabulary.directory === undefined ? {} : { directory: vocabulary.directory }),
84
+ ...(vocabulary.terminal === undefined ? {} : { terminal: { identify: vocabulary.terminal.identify } }),
84
85
  }
85
86
  }
86
87
 
@@ -25,6 +25,7 @@ import type { StoredFilesHandle, StoredFilesReadHandle } from '../files/handle'
25
25
  import type { AppCapability, Ref, Within } from '../implement/capability'
26
26
  import type { ApplicationDirectory, LinkIssuing, MemberDirectory, MemberLookup, MemberManagement } from '../implement/membership'
27
27
  import type { AppPrincipal, PrincipalOf, PublicPrincipal, ServicePrincipal } from '../implement/principal'
28
+ import type { TerminalIdentification } from '../implement/terminals'
28
29
  import type { CapabilityOf, ConnectCall, RequestContext } from '../implement/types'
29
30
  import type { WebhookDelivery, WebhookPrincipal } from '../implement/webhook'
30
31
  import type { AppMail, JobContext } from '../jobs/types'
@@ -56,6 +57,8 @@ export interface KitTypes {
56
57
  readonly dimensions: unknown
57
58
  /** The actions of the application's own people, as written; `never` when the vocabulary declares none. */
58
59
  readonly members: { readonly manage: string; readonly list: string }
60
+ /** Whether the vocabulary declares `terminal: { identify }`, which gives `ctx.terminals`. */
61
+ readonly terminal: boolean
59
62
  readonly index: AnyDbIndex
60
63
  /** The table names of `src/db/tables.ts`. */
61
64
  readonly tables: string
@@ -357,6 +360,10 @@ type ConnectContext<K extends KitTypes, Cn extends readonly string[]> = [Cn] ext
357
360
  ? { readonly connect: Unavailable<"declare connect: ['<connector>'] on this endpoint to call another application"> }
358
361
  : { readonly connect: { readonly [N in Cn[number]]: ConnectorOf<K['imports'][N]> } }
359
362
 
363
+ /** `ctx.terminals`, where the vocabulary declares the terminal identification page. */
364
+ type TerminalsContext<K extends KitTypes> = K['terminal'] extends true ? { readonly terminals: TerminalIdentification }
365
+ : { readonly terminals: Unavailable<"the application declares no terminal: terminal: { identify: '/identify' } in the vocabulary"> }
366
+
360
367
  /** What the handler of an RPC endpoint receives, for mount callers `Caller`, kind `Kind` and its declaration. */
361
368
  export type EndpointContext<
362
369
  K extends KitTypes,
@@ -370,6 +377,7 @@ export type EndpointContext<
370
377
  & MembersContext<K, Acc>
371
378
  & LinksContext<Acc, Of<D, 'issues'>>
372
379
  & ConnectContext<K, Of<D, 'connect'>>
380
+ & TerminalsContext<K>
373
381
  & SqlContext<Of<D, 'sql'>, K['index']>
374
382
  & FilesContext<K['index'], Kind>
375
383
  & { readonly db: DataOf<K['index'], EndpointData<K['index'], Kind, Acc, Of<D, 'unscoped'>>> }
@@ -396,6 +404,7 @@ export type PublicContext<K extends KitTypes, Kind extends DataKind, Acts extend
396
404
  readonly files: Kind extends 'query' ? StoredFilesReadHandle : StoredFilesHandle
397
405
  readonly sql: Unavailable<'a public procedure reaches its actions only: it declares no sql'>
398
406
  }
407
+ & TerminalsContext<K>
399
408
  & {
400
409
  readonly db: DataOf<
401
410
  K['index'],
@@ -26,6 +26,8 @@ export interface ContractDeclaration<S> {
26
26
  readonly members?: { readonly manage: string; readonly list: string }
27
27
  /** `'labels'` only; `buildArtifact` refuses another value, and refuses it without `members`. */
28
28
  readonly directory?: string
29
+ /** The identification page of a terminal; `buildArtifact` refuses a path a public SPA mount does not serve. */
30
+ readonly terminal?: { readonly identify: string }
29
31
  }
30
32
 
31
33
  /**
@@ -26,6 +26,11 @@ import type { TokenPurposes } from './token'
26
26
 
27
27
  export type Path = `/${string}`
28
28
 
29
+ /** Whether `value` is written as a `Path`; what `buildArtifact` refuses of one is a separate check. */
30
+ export function isPath(value: string): value is Path {
31
+ return value.startsWith('/')
32
+ }
33
+
29
34
  /** A job attempt's time limit: 15 minutes by default, at most a day. A job reads no body, so it has no `maxBytes`. */
30
35
  export interface JobLimitsOption {
31
36
  readonly timeoutMs?: number
@@ -92,7 +97,7 @@ export interface RoleDef<A extends string, D extends string, R extends string> {
92
97
  readonly external?: true
93
98
  }
94
99
 
95
- export interface Vocabulary<A extends string, D extends string, R extends string> extends Presentation, AppPeople<A> {
100
+ export interface Vocabulary<A extends string, D extends string, R extends string> extends Presentation, AppPeople<A>, AppTerminal {
96
101
  readonly app: string
97
102
  readonly dimensions: { readonly [K in D]: DimensionDef<A, D, R> }
98
103
  readonly actions: { readonly [K in A]: string }
@@ -124,6 +129,26 @@ export function peopleOf<A extends string>(vocabulary: AppPeople<A>): AppPeople<
124
129
  }
125
130
  }
126
131
 
132
+ /** The page that identifies a person at an enrolled terminal (ADR-0067). */
133
+ export interface TerminalDeclaration {
134
+ /** An absolute path on the application's own origin, served by a public SPA mount; IAM appends `?identification=<id>`. */
135
+ readonly identify: Path
136
+ }
137
+
138
+ /**
139
+ * `terminal: { identify: '/identify' }`: the application identifies a person at an enrolled terminal on that page and
140
+ * asserts who stands there (`ctx.terminals`). IAM sends a terminal's browser there only for an application its
141
+ * administrator lists on the terminal. A reviewed declaration.
142
+ */
143
+ export interface AppTerminal {
144
+ readonly terminal?: TerminalDeclaration
145
+ }
146
+
147
+ /** A vocabulary's terminal, copied field by field: it exists only when declared. */
148
+ export function terminalOf(vocabulary: AppTerminal): AppTerminal {
149
+ return vocabulary.terminal === undefined ? {} : { terminal: { identify: vocabulary.terminal.identify } }
150
+ }
151
+
127
152
  /** How IAM names the application to a person: on its sign-in, password and invitation pages, and in its e-mails. */
128
153
  export interface Presentation {
129
154
  /** The language of those pages and e-mails; absent, the installation's default. */
@@ -535,7 +560,7 @@ export function defineContract<
535
560
  const Dims extends { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> },
536
561
  const S extends Surface,
537
562
  >(
538
- vocabulary: Presentation & AppPeople<NoInfer<A>> & {
563
+ vocabulary: Presentation & AppPeople<NoInfer<A>> & AppTerminal & {
539
564
  readonly app: string
540
565
  readonly dimensions: Dims & { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> }
541
566
  readonly actions: { readonly [K in A]: K extends `${string}*${string}` ? WildcardMessage : string }
@@ -551,6 +576,7 @@ export function defineContract<
551
576
  roles: vocabulary.roles,
552
577
  ...presentationOf(vocabulary),
553
578
  ...peopleOf(vocabulary),
579
+ ...terminalOf(vocabulary),
554
580
  },
555
581
  dimensions: vocabulary.dimensions,
556
582
  surface: surface(surfaceKit()),