@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
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-]*$/
@@ -24,6 +24,7 @@ import {
24
24
  type SchemaShape,
25
25
  type StringShape,
26
26
  type TablePolicyArtifact,
27
+ terminalIdentifyProblem,
27
28
  type TupleShape,
28
29
  } from '@fabrika/auth-core'
29
30
  import { isPolicyOrigin, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
@@ -425,6 +426,7 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
425
426
  .optional(),
426
427
  members: z.strictObject({ manage: action, list: action }).optional(),
427
428
  directory: z.literal('labels').optional(),
429
+ terminal: z.strictObject({ identify: z.string().refine((path) => terminalIdentifyProblem(path) === null, 'not an identification path') }).optional(),
428
430
  procedures: record(z.strictObject({
429
431
  rate: z.strictObject({
430
432
  perClient: bucket,
@@ -13,6 +13,7 @@ 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,
@@ -48,6 +49,7 @@ import {
48
49
  type MessageKind,
49
50
  type MountArtifact,
50
51
  type OutputShape,
52
+ planEndpoint,
51
53
  type ProcedureArtifact,
52
54
  type ProcedureChallenge,
53
55
  type RoleArtifact,
@@ -55,11 +57,14 @@ import {
55
57
  type ScopeBinding,
56
58
  tableActions,
57
59
  type TablePolicyArtifact,
60
+ type TerminalArtifact,
61
+ terminalIdentifyProblem,
58
62
  type TokenPurposeArtifact,
59
63
  type WebhookScheme,
60
64
  } from '@fabrika/auth-core'
61
65
  import { isPolicyOrigin, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
62
66
  import { z } from 'zod/v4'
67
+ import { CONTRACT_APP_ID_PATTERN as APP_ID } from './app-id'
63
68
  import { contractArtifactSchema } from './artifact-schema'
64
69
  import {
65
70
  type AccessDeclaration,
@@ -117,7 +122,6 @@ import { ContractError, Problems } from './problems'
117
122
  import { TOKEN_MAX_DAYS } from './token'
118
123
  import { hmacSchemeSchema, WEBHOOK_SCHEME_ALLOWED, webhookSchemeSchema } from './webhook-scheme'
119
124
 
120
- const APP_ID = /^[a-z0-9][a-z0-9-]*$/
121
125
  const NAME = /^[A-Za-z][A-Za-z0-9_]*$/
122
126
  const DIMENSION = /^[a-z][A-Za-z0-9_]*$/
123
127
  const ACTION = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/
@@ -234,6 +238,7 @@ class ArtifactBuilder {
234
238
  const displayName = this.#displayName()
235
239
  const mail = this.#mail()
236
240
  const people = this.#people()
241
+ const terminal = this.#terminal()
237
242
  const artifact: ContractArtifact = {
238
243
  format: CONTRACT_FORMAT,
239
244
  app: contract.app,
@@ -256,6 +261,7 @@ class ArtifactBuilder {
256
261
  ...(displayName === null ? {} : { displayName }),
257
262
  ...(mail === null ? {} : { mail }),
258
263
  ...people,
264
+ ...(terminal === null ? {} : { terminal }),
259
265
  ...(Object.keys(this.#procedureRules).length === 0 ? {} : { procedures: this.#procedureRules }),
260
266
  ...(Object.keys(tokens).length === 0 ? {} : { tokens }),
261
267
  }
@@ -263,10 +269,38 @@ class ArtifactBuilder {
263
269
  assertNoAmbiguity(artifact)
264
270
  } catch (error) {
265
271
  this.#problem('endpoints', error instanceof Error ? error.message : String(error))
272
+ return artifact
273
+ }
274
+ if (terminal !== null) {
275
+ this.#checkIdentifyPage(artifact, terminal)
266
276
  }
267
277
  return artifact
268
278
  }
269
279
 
280
+ /** The terminal's identification page, when its path is one. */
281
+ #terminal(): TerminalArtifact | null {
282
+ const terminal = this.#contract.terminal
283
+ if (terminal === undefined) return null
284
+ const problem = terminalIdentifyProblem(terminal.identify)
285
+ if (problem !== null) {
286
+ this.#problem('terminal.identify', `'${terminal.identify}' ${problem}`)
287
+ return null
288
+ }
289
+ return { identify: terminal.identify }
290
+ }
291
+
292
+ /**
293
+ * Nobody is signed in on the identification page, so a public SPA mount must serve it: under a credentialed one, the
294
+ * proxy would bounce the terminal's browser back to sign-in, which sends it to the page again.
295
+ */
296
+ #checkIdentifyPage(artifact: ContractArtifact, terminal: TerminalArtifact): void {
297
+ const plan = planEndpoint(compileEndpointTable(artifact), 'GET', terminal.identify)
298
+ if (plan.outcome !== 'endpoint' || plan.endpoint.kind !== 'public-spa') {
299
+ const served = plan.outcome === 'endpoint' ? `'${plan.endpoint.id}' (${plan.endpoint.kind})` : 'no endpoint'
300
+ this.#problem('terminal.identify', `'${terminal.identify}' is served by ${served}; serve it from an spa mount with access: { public: true }`)
301
+ }
302
+ }
303
+
270
304
  /** The member directory each endpoint reads, recorded so the approval and the digest cover the reach. */
271
305
  #directories(dimensions: ContractArtifact['dimensions']): Record<string, EndpointDirectory> {
272
306
  const directories: Record<string, EndpointDirectory> = {}
@@ -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()),
@@ -45,6 +45,7 @@ import {
45
45
  type Schema,
46
46
  type Surface,
47
47
  surfaceKit,
48
+ terminalOf,
48
49
  type WebhookMount,
49
50
  } from './define'
50
51
  import type {
@@ -73,13 +74,14 @@ type Owned<K extends string> = { readonly [P in K]?: never }
73
74
 
74
75
  /** The kit's types, computed once from the vocabulary, the tables' index, the imports and the token purposes. */
75
76
  export type KitOf<V, Ix, Imp extends KitImports, Tk extends TokenPurposes = Record<never, never>> = V extends
76
- AppVocabulary<infer A, infer D, infer R, infer Dims, infer M> ? {
77
+ AppVocabulary<infer A, infer D, infer R, infer Dims, infer M, infer T> ? {
77
78
  readonly action: A
78
79
  readonly dimension: D
79
80
  readonly creatable: CreatableDimension<Dims>
80
81
  readonly role: R
81
82
  readonly dimensions: Dims
82
83
  readonly members: M
84
+ readonly terminal: [T] extends [never] ? false : true
83
85
  readonly index: Ix
84
86
  readonly tables: Ix extends { readonly tables: infer T } ? keyof T & string : never
85
87
  readonly imports: Imp
@@ -555,6 +557,7 @@ function runtimeApp(vocabulary: AnyAppVocabulary, options: KitOptions | undefine
555
557
  roles: vocabulary.roles,
556
558
  ...presentationOf(vocabulary),
557
559
  ...peopleOf(vocabulary),
560
+ ...terminalOf(vocabulary),
558
561
  },
559
562
  dimensions: vocabulary.dimensions,
560
563
  surface: declared,
@@ -23,8 +23,8 @@ import {
23
23
  /**
24
24
  * The IAM vocabulary of a contract application, marked `contract` so IAM refuses every wildcard written for it: the
25
25
  * dimensions as scopes with their container, creator role and members actions; the actions; the roles, a membership
26
- * role with its scope and the roles that may assign it; the link mounts; the delegations; the workloads; and the locale,
27
- * display name and e-mail wording IAM names the application with. Every artifact
26
+ * role with its scope and the roles that may assign it; the link mounts; the delegations; the workloads; the locale,
27
+ * display name and e-mail wording IAM names the application with; and its terminal identification page. Every artifact
28
28
  * dimension is a scope, a file collection's `file:<name>` included, because access to a file is checked there.
29
29
  */
30
30
  export function schemaFromContract(artifact: ContractArtifact): AppSchema {
@@ -46,6 +46,7 @@ export function schemaFromContract(artifact: ContractArtifact): AppSchema {
46
46
  ...(artifact.mail === undefined ? {} : { mail: artifact.mail }),
47
47
  ...(artifact.members === undefined ? {} : { members: { manage: artifact.members.manage, list: artifact.members.list } }),
48
48
  ...(artifact.directory === 'labels' ? { directory: 'labels' } : {}),
49
+ ...(artifact.terminal === undefined ? {} : { terminal: { identify: artifact.terminal.identify } }),
49
50
  }
50
51
  }
51
52
 
@@ -2,20 +2,35 @@
2
2
  // nothing else, so the tables, the kit, the groups and the contract can each be typed by it without a cycle.
3
3
  // Browser-safe: plain data.
4
4
 
5
- import { type AppMembers, type DimensionDef, type Presentation, presentationOf, type RoleDefs, type Vocabulary, type WildcardMessage } from './define'
5
+ import {
6
+ type AppMembers,
7
+ type DimensionDef,
8
+ type Presentation,
9
+ presentationOf,
10
+ type RoleDefs,
11
+ type TerminalDeclaration,
12
+ type Vocabulary,
13
+ type WildcardMessage,
14
+ } from './define'
6
15
 
7
16
  /**
8
- * The vocabulary as `defineVocabulary` returned it, with its literal names, dimension definitions and the actions of its
9
- * people (`M`, `never` when it declares none).
17
+ * The vocabulary as `defineVocabulary` returned it, with its literal names, dimension definitions, the actions of its
18
+ * people (`M`, `never` when it declares none) and its terminal (`T`, `never` when it declares none).
10
19
  */
11
- export interface AppVocabulary<A extends string, D extends string, R extends string, Dims, M extends AppMembers<A> = never>
12
- extends Vocabulary<A, D, R>
13
- {
20
+ export interface AppVocabulary<
21
+ A extends string,
22
+ D extends string,
23
+ R extends string,
24
+ Dims,
25
+ M extends AppMembers<A> = never,
26
+ T extends TerminalDeclaration = never,
27
+ > extends Vocabulary<A, D, R> {
14
28
  readonly dimensions: Dims & { readonly [K in D]: DimensionDef<A, D, R> }
15
29
  readonly members?: M
30
+ readonly terminal?: T
16
31
  }
17
32
 
18
- export type AnyAppVocabulary = AppVocabulary<string, string, string, unknown, AppMembers<string>>
33
+ export type AnyAppVocabulary = AppVocabulary<string, string, string, unknown, AppMembers<string>, TerminalDeclaration>
19
34
 
20
35
  /**
21
36
  * Declares the dimensions, actions and roles, and how IAM names the application (`locale`, `displayName`). `src/vocabulary.ts` exports it; `defineTables`, `defineApp` and the
@@ -27,6 +42,7 @@ export function defineVocabulary<
27
42
  const R extends string,
28
43
  const Dims extends { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> },
29
44
  const M extends AppMembers<NoInfer<A>> = never,
45
+ const T extends TerminalDeclaration = never,
30
46
  >(
31
47
  vocabulary: Presentation & {
32
48
  readonly app: string
@@ -35,8 +51,9 @@ export function defineVocabulary<
35
51
  readonly roles: RoleDefs<NoInfer<A>, NoInfer<D>, R>
36
52
  readonly members?: M
37
53
  readonly directory?: 'labels'
54
+ readonly terminal?: T
38
55
  },
39
- ): AppVocabulary<A, D, R, Dims, M> {
56
+ ): AppVocabulary<A, D, R, Dims, M, T> {
40
57
  const people = vocabulary.members
41
58
  return {
42
59
  app: vocabulary.app,
@@ -46,5 +63,6 @@ export function defineVocabulary<
46
63
  ...presentationOf(vocabulary),
47
64
  ...(people === undefined ? {} : { members: people }),
48
65
  ...(vocabulary.directory === undefined ? {} : { directory: vocabulary.directory }),
66
+ ...(vocabulary.terminal === undefined ? {} : { terminal: vocabulary.terminal }),
49
67
  }
50
68
  }