@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
@@ -1,23 +1,107 @@
1
- // `egress`: while a site's handler runs, the global `fetch` reaches only the HTTPS origins the site declares. It stops a
2
- // mistake and makes a bypass conspicuous; it is not a sandbox (ADR-0012 §8): code that kept the `fetch` it found before
3
- // boot, or opens its own sockets, escapes it. The framework's own facilities (mail, connectors, IAM, the object store)
4
- // run outside the site's scope, under their own declarations.
1
+ // `egress`: while a site's handler runs, the global `fetch` reaches only the HTTPS origins the site declares, or, for a
2
+ // cron job that declares `egress: 'public'`, only public hosts (ADR-0070). It stops a mistake and makes a bypass
3
+ // conspicuous; it is not a sandbox (ADR-0012 §8): code that kept the `fetch` it found before boot, or opens its own
4
+ // sockets, escapes it. The framework's own facilities (mail, connectors, IAM, the object store) run outside the site's
5
+ // scope, under their own declarations.
5
6
 
7
+ import {
8
+ type DeclaredEgress,
9
+ FABRIKA_EGRESS_REFUSED_HOSTS,
10
+ isPublicAddress,
11
+ normalizedHost,
12
+ PUBLIC_EGRESS,
13
+ publicEgressRefusal,
14
+ } from '@fabrika/auth-core'
6
15
  import { AsyncLocalStorage } from 'node:async_hooks'
7
16
  import { CapabilityRefusedError } from './capability'
8
17
 
18
+ /** The addresses a host name resolves to, where the runtime exposes resolution: Bun's `node:dns`, never a Worker. */
19
+ export type HostResolver = (hostname: string) => Promise<readonly string[]>
20
+
21
+ /** What public egress refuses beyond what a URL alone shows (`publicEgressRefusal`). */
22
+ export interface PublicEgressRules {
23
+ /** The installation's own hosts, normalized: every host a framework variable names, and `FABRIKA_EGRESS_REFUSED_HOSTS`. */
24
+ readonly refusedHosts: ReadonlySet<string>
25
+ /** Resolves each hop's host before it is sent, so a public name of a private address is refused; `null` on Cloudflare. */
26
+ readonly resolve: HostResolver | null
27
+ }
28
+
29
+ /** What one site's `fetch` reaches: its declared origins, or the rules of public egress. */
30
+ export type SiteEgress = readonly string[] | PublicEgressRules
31
+
32
+ /**
33
+ * The bounds of one request under public egress, the redirects it follows included. The handler's own signal still
34
+ * aborts it sooner (`ctx.signal`, an `AbortSignal.timeout` of its own).
35
+ */
36
+ export const PUBLIC_EGRESS_LIMITS = {
37
+ /** One request, its redirects and the reading of its body included: the abort also stops a body still streaming. */
38
+ timeoutMs: 60_000,
39
+ /** One response's body as the handler reads it; a larger one is refused, by its `Content-Length` or while it streams. */
40
+ maxResponseBytes: 16 * 1024 * 1024,
41
+ /** The redirects one request follows when its `redirect` mode is `follow`, each hop checked again. */
42
+ maxRedirects: 10,
43
+ } as const
44
+
9
45
  interface EgressScope {
10
46
  /** The site, as a refusal names it. */
11
47
  readonly site: string
12
48
  readonly origins: ReadonlySet<string>
49
+ /** Public egress: `null` for a site that declares origins, or none. */
50
+ readonly open: PublicEgressRules | null
13
51
  }
14
52
 
15
53
  const scope = new AsyncLocalStorage<EgressScope>()
16
54
  const guards = new WeakSet<object>()
17
55
 
18
56
  /** Runs a site's handler: every `fetch` it starts reaches only `egress`. */
19
- export function runAsSite<T>(site: string, egress: readonly string[], work: () => Promise<T>): Promise<T> {
20
- return scope.run({ site, origins: new Set(egress) }, work)
57
+ export function runAsSite<T>(site: string, egress: SiteEgress, work: () => Promise<T>): Promise<T> {
58
+ return isOrigins(egress)
59
+ ? scope.run({ site, origins: new Set(egress), open: null }, work)
60
+ : scope.run({ site, origins: new Set(), open: egress }, work)
61
+ }
62
+
63
+ /**
64
+ * The egress a site runs with: its declared origins, or `rules` for `'public'`. Only a cron job's runner holds rules; a
65
+ * site run without them reaches nothing, though the build lets no other site declare `'public'`.
66
+ */
67
+ export function siteEgress(declared: DeclaredEgress, rules: PublicEgressRules | null): SiteEgress {
68
+ return declared === PUBLIC_EGRESS ? (rules ?? []) : declared
69
+ }
70
+
71
+ function isOrigins(egress: SiteEgress): egress is readonly string[] {
72
+ return Array.isArray(egress)
73
+ }
74
+
75
+ /**
76
+ * The rules of public egress in one environment: the hosts its framework variables name (IAM, Operations, its own
77
+ * origin, the platform's receivers, the object store), the installation hosts Control hands it, and the resolver the
78
+ * runtime adapter supplies.
79
+ */
80
+ export function publicEgressRules(framework: Readonly<Record<string, string>>, resolve: HostResolver | null): PublicEgressRules {
81
+ const hosts = new Set<string>()
82
+ for (const [name, value] of Object.entries(framework)) {
83
+ if (name === FABRIKA_EGRESS_REFUSED_HOSTS) {
84
+ for (const host of value.split(',')) {
85
+ const normalized = normalizedHost(host.trim())
86
+ if (normalized !== '') hosts.add(normalized)
87
+ }
88
+ continue
89
+ }
90
+ const host = urlHost(value)
91
+ if (host !== null) hosts.add(host)
92
+ }
93
+ return { refusedHosts: hosts, resolve }
94
+ }
95
+
96
+ /** The host of a framework variable's value when it is an http(s) URL, such as an origin or a DSN; `null` otherwise. */
97
+ function urlHost(value: string): string | null {
98
+ const text = value.trim()
99
+ if (!/^https?:\/\//i.test(text)) return null
100
+ try {
101
+ return normalizedHost(new URL(text).hostname)
102
+ } catch {
103
+ return null
104
+ }
21
105
  }
22
106
 
23
107
  /**
@@ -41,6 +125,8 @@ export function platformCall<T>(work: () => T): T {
41
125
  return scope.exit(work)
42
126
  }
43
127
 
128
+ type Fetch = (input: string, init: RequestInit) => Promise<Response>
129
+
44
130
  /**
45
131
  * Wraps the global `fetch` in the guard, once: a `fetch` that is already a guard stays. A test that replaces `fetch`
46
132
  * after boot replaces the guard with it.
@@ -50,15 +136,20 @@ export function installEgressGuard(): void {
50
136
  if (guards.has(current)) {
51
137
  return
52
138
  }
139
+ const send: Fetch = (input, init) => current(input, init)
53
140
  const guarded = Object.assign(
54
141
  (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
55
142
  const running = scope.getStore()
56
143
  if (running === undefined) {
57
144
  return current(input, init)
58
145
  }
146
+ if (running.open !== null) {
147
+ return publicFetch(send, running.site, running.open, input, init)
148
+ }
59
149
  const origin = originOf(input)
60
150
  if (origin !== null && running.origins.has(origin)) {
61
- return current(input, init)
151
+ // Only the standard fields: a runtime's own (Bun's `unix`, `proxy`, `tls`) would send the request elsewhere.
152
+ return current(input, standardInit(init))
62
153
  }
63
154
  return Promise.reject(
64
155
  new CapabilityRefusedError(
@@ -69,12 +160,47 @@ export function installEgressGuard(): void {
69
160
  ),
70
161
  )
71
162
  },
72
- { preconnect: current.preconnect },
163
+ {
164
+ // Inside a site's scope a preconnect reaches only a declared origin; public egress, which cannot check a hop
165
+ // before it connects, refuses every one.
166
+ preconnect: (...args: Parameters<typeof current.preconnect>): void => {
167
+ const running = scope.getStore()
168
+ if (running !== undefined) {
169
+ const origin = originOf(args[0])
170
+ if (running.open !== null || origin === null || !running.origins.has(origin)) {
171
+ throw new CapabilityRefusedError(
172
+ running.open === null ? 'undeclared_egress' : 'refused_egress',
173
+ `${running.site} preconnects to ${origin ?? 'a URL that is not absolute'}, which its egress does not reach`,
174
+ )
175
+ }
176
+ }
177
+ if (typeof current.preconnect === 'function') current.preconnect(...args)
178
+ },
179
+ },
73
180
  )
74
181
  guards.add(guarded)
75
182
  globalThis.fetch = guarded
76
183
  }
77
184
 
185
+ /** `init` with the fields of the Fetch standard only. */
186
+ function standardInit(init: RequestInit | undefined): RequestInit | undefined {
187
+ if (init === undefined) return undefined
188
+ return {
189
+ ...(init.method === undefined ? {} : { method: init.method }),
190
+ ...(init.headers === undefined ? {} : { headers: init.headers }),
191
+ ...(init.body === undefined ? {} : { body: init.body }),
192
+ ...(init.redirect === undefined ? {} : { redirect: init.redirect }),
193
+ ...(init.signal === undefined ? {} : { signal: init.signal }),
194
+ ...(init.keepalive === undefined ? {} : { keepalive: init.keepalive }),
195
+ ...(init.integrity === undefined ? {} : { integrity: init.integrity }),
196
+ ...(init.referrer === undefined ? {} : { referrer: init.referrer }),
197
+ ...(init.referrerPolicy === undefined ? {} : { referrerPolicy: init.referrerPolicy }),
198
+ ...(init.cache === undefined ? {} : { cache: init.cache }),
199
+ ...(init.credentials === undefined ? {} : { credentials: init.credentials }),
200
+ ...(init.mode === undefined ? {} : { mode: init.mode }),
201
+ }
202
+ }
203
+
78
204
  function originOf(input: string | URL | Request): string | null {
79
205
  try {
80
206
  return new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).origin
@@ -82,3 +208,210 @@ function originOf(input: string | URL | Request): string | null {
82
208
  return null
83
209
  }
84
210
  }
211
+
212
+ const REDIRECTS: ReadonlySet<number> = new Set([301, 302, 303, 307, 308])
213
+ /** The headers a redirect that turns a request into a `GET` drops with its body (Fetch, "HTTP-redirect fetch"). */
214
+ const BODY_HEADERS = ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location']
215
+ /**
216
+ * The request headers public egress never sends, on any hop: the Fetch standard's forbidden request headers (`Host`
217
+ * among them, which would route a request for a public name to another site at the same address), the forwarding
218
+ * headers a proxy trusts, and the platform's own `X-Fabrika-*`.
219
+ */
220
+ const FORBIDDEN_HEADERS = [
221
+ 'accept-charset',
222
+ 'accept-encoding',
223
+ 'access-control-request-headers',
224
+ 'access-control-request-method',
225
+ 'connection',
226
+ 'content-length',
227
+ 'cookie2',
228
+ 'date',
229
+ 'dnt',
230
+ 'expect',
231
+ 'forwarded',
232
+ 'host',
233
+ 'keep-alive',
234
+ 'origin',
235
+ 'referer',
236
+ 'set-cookie',
237
+ 'te',
238
+ 'trailer',
239
+ 'transfer-encoding',
240
+ 'upgrade',
241
+ 'via',
242
+ 'x-http-method',
243
+ 'x-http-method-override',
244
+ 'x-method-override',
245
+ 'x-real-ip',
246
+ ]
247
+ const FORBIDDEN_HEADER_PREFIXES = ['proxy-', 'sec-', 'x-forwarded-', 'x-fabrika-']
248
+
249
+ function withoutForbiddenHeaders(headers: Headers): Headers {
250
+ for (const name of [...headers.keys()]) {
251
+ if (FORBIDDEN_HEADERS.includes(name) || FORBIDDEN_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))) {
252
+ headers.delete(name)
253
+ }
254
+ }
255
+ return headers
256
+ }
257
+
258
+ /** The credentials a redirect to another origin drops. */
259
+ const CREDENTIAL_HEADERS = ['authorization', 'proxy-authorization', 'cookie']
260
+
261
+ /**
262
+ * One request under public egress. Every hop is sent with `redirect: 'manual'`, so the runtime follows no redirect the
263
+ * guard has not checked: the caller's `follow` is done here, at most `maxRedirects` hops, each host checked again;
264
+ * `manual` hands the caller the redirect, whose next `fetch` passes the guard again; `error` refuses one. The request
265
+ * is bounded in time and its answer's body in size.
266
+ */
267
+ async function publicFetch(
268
+ send: Fetch,
269
+ site: string,
270
+ rules: PublicEgressRules,
271
+ input: string | URL | Request,
272
+ init?: RequestInit,
273
+ ): Promise<Response> {
274
+ const request = new Request(input, init)
275
+ // A timer the guard clears once the body is read, not `AbortSignal.timeout`: on workerd an uncleared timer keeps the
276
+ // Durable Object awake until it fires.
277
+ const deadline = new AbortController()
278
+ const timer = setTimeout(() => deadline.abort(new DOMException('public egress request timed out', 'TimeoutError')), PUBLIC_EGRESS_LIMITS.timeoutMs)
279
+ const done = (): void => clearTimeout(timer)
280
+ try {
281
+ return await follow(send, site, rules, request, AbortSignal.any([request.signal, deadline.signal]), done)
282
+ } catch (error) {
283
+ done()
284
+ throw error
285
+ }
286
+ }
287
+
288
+ /** The hops of one request; `done` clears its deadline once its last answer's body is read. */
289
+ async function follow(
290
+ send: Fetch,
291
+ site: string,
292
+ rules: PublicEgressRules,
293
+ request: Request,
294
+ signal: AbortSignal,
295
+ done: () => void,
296
+ ): Promise<Response> {
297
+ const mode = request.redirect
298
+ let url = new URL(request.url)
299
+ let method = request.method
300
+ const headers = withoutForbiddenHeaders(new Headers(request.headers))
301
+ // Read once, so that a 307 or a 308 sends it again.
302
+ let body: ArrayBuffer | null = request.body === null ? null : await request.arrayBuffer()
303
+ for (let hop = 0;; hop++) {
304
+ await admit(site, rules, url)
305
+ const response = await send(url.href, { method, headers: new Headers(headers), body, redirect: 'manual', signal })
306
+ const location = REDIRECTS.has(response.status) ? response.headers.get('location') : null
307
+ if (location === null || mode === 'manual') {
308
+ return await capped(site, response, url, hop > 0, done)
309
+ }
310
+ await response.body?.cancel()
311
+ if (mode === 'error') {
312
+ throw new TypeError(`${site}: ${url.origin} redirects, and the request's redirect mode is 'error'`)
313
+ }
314
+ if (hop >= PUBLIC_EGRESS_LIMITS.maxRedirects) {
315
+ throw new TypeError(`${site}: more than ${PUBLIC_EGRESS_LIMITS.maxRedirects} redirects, the last from ${url.origin}`)
316
+ }
317
+ const next = new URL(location, url)
318
+ const toGet = (response.status === 303 && method !== 'GET' && method !== 'HEAD')
319
+ || ((response.status === 301 || response.status === 302) && method === 'POST')
320
+ if (toGet) {
321
+ method = 'GET'
322
+ body = null
323
+ for (const name of BODY_HEADERS) headers.delete(name)
324
+ }
325
+ if (next.origin !== url.origin) {
326
+ for (const name of CREDENTIAL_HEADERS) headers.delete(name)
327
+ }
328
+ url = next
329
+ }
330
+ }
331
+
332
+ /** Refuses a hop public egress does not reach: by the URL, by the installation's hosts, by what its name resolves to. */
333
+ async function admit(site: string, rules: PublicEgressRules, url: URL): Promise<void> {
334
+ const refusal = publicEgressRefusal(url, rules.refusedHosts)
335
+ if (refusal !== null) {
336
+ throw new CapabilityRefusedError('refused_egress', `${site} calls ${url.origin}, which public egress refuses: ${refusal}`)
337
+ }
338
+ const resolve = rules.resolve
339
+ if (resolve === null) {
340
+ return
341
+ }
342
+ const host = normalizedHost(url.hostname)
343
+ const addresses = await platformCall(() => resolve(host))
344
+ const internal = addresses.find((address) => !isPublicAddress(address))
345
+ if (addresses.length === 0 || internal !== undefined) {
346
+ throw new CapabilityRefusedError(
347
+ 'refused_egress',
348
+ `${site} calls ${url.origin}, which public egress refuses: ${
349
+ internal === undefined ? `${host} resolves to no address` : `${host} resolves to ${internal}, which is not a public address`
350
+ }`,
351
+ )
352
+ }
353
+ }
354
+
355
+ /**
356
+ * `response` with its body bounded by `maxResponseBytes`, and the URL and redirect flag of the request it answers,
357
+ * which a response the guard builds would otherwise lose.
358
+ */
359
+ async function capped(site: string, response: Response, url: URL, redirected: boolean, done: () => void): Promise<Response> {
360
+ const max = PUBLIC_EGRESS_LIMITS.maxResponseBytes
361
+ const tooLarge = () =>
362
+ new CapabilityRefusedError(
363
+ 'oversized_egress',
364
+ `${site}: ${url.origin} answers more than ${max} bytes, the most public egress reads of one response`,
365
+ )
366
+ const declared = Number(response.headers.get('content-length') ?? '0')
367
+ if (Number.isFinite(declared) && declared > max) {
368
+ await response.body?.cancel()
369
+ throw tooLarge()
370
+ }
371
+ if (response.body === null || response.status < 200 || response.status > 599) {
372
+ done()
373
+ return withRequestUrl(response, url, redirected)
374
+ }
375
+ const reader = response.body.getReader()
376
+ let seen = 0
377
+ const bound = new ReadableStream<Uint8Array>({
378
+ async pull(controller) {
379
+ const read = await reader.read().catch((error: unknown) => {
380
+ done()
381
+ throw error
382
+ })
383
+ if (read.done) {
384
+ done()
385
+ controller.close()
386
+ return
387
+ }
388
+ seen += read.value.byteLength
389
+ if (seen > max) {
390
+ done()
391
+ await reader.cancel()
392
+ controller.error(tooLarge())
393
+ return
394
+ }
395
+ controller.enqueue(read.value)
396
+ },
397
+ async cancel(reason) {
398
+ done()
399
+ await reader.cancel(reason)
400
+ },
401
+ })
402
+ const answer = new Response(bound, {
403
+ status: response.status,
404
+ statusText: response.statusText,
405
+ headers: response.headers,
406
+ })
407
+ return withRequestUrl(answer, url, redirected)
408
+ }
409
+
410
+ function withRequestUrl(response: Response, url: URL, redirected: boolean): Response {
411
+ try {
412
+ Object.defineProperties(response, { url: { value: url.href }, redirected: { value: redirected } })
413
+ } catch {
414
+ // A runtime whose responses refuse own properties keeps its own `url`; the body bound holds either way.
415
+ }
416
+ return response
417
+ }
@@ -2,7 +2,7 @@
2
2
  // fixed codes and bounded identifiers only, never an error's name or message: application code, drivers and even the
3
3
  // framework's own refusals can put request data there. The event adds what was thrown as its cause chain, scrubbed of
4
4
  // the environment's secrets and of credential patterns, a framework failure's message replaced by its catalog text
5
- // (ADR-0059).
5
+ // (ADR-0059). A writing invocation that held its Durable Object's transaction too long is an event on the same budget.
6
6
 
7
7
  import { CAPABILITY_ERRORS, CapabilityError, QUERY_REFUSALS, QueryRefusedError } from '@fabrika/db/framework'
8
8
  import { isStructuralError } from '../errors'
@@ -127,4 +127,20 @@ export class FailureReports {
127
127
  error: failure.error,
128
128
  })
129
129
  }
130
+
131
+ /**
132
+ * A writing invocation that held its object's transaction past `@fabrika/db`'s report threshold, which already
133
+ * logged it. Never rejects; the delivery may outlive the response like a failure's.
134
+ */
135
+ held(endpoint: string, heldMs: number): Promise<void> {
136
+ if (this.#reporter === null || !this.#budget.admit()) {
137
+ return Promise.resolve()
138
+ }
139
+ return this.#reporter.report({
140
+ fingerprint: ['fabrika-transaction-held', endpoint],
141
+ type: 'TransactionHeld',
142
+ message: `endpoint '${endpoint}' held its object's transaction for ${heldMs} ms`,
143
+ tags: { endpoint, code: 'transaction_held', held_ms: String(heldMs) },
144
+ })
145
+ }
130
146
  }
@@ -19,6 +19,7 @@ export const SERVER_FAULTS = {
19
19
  webhook_unverifiable: 'a webhook verifies a signature over its public URL (Twilio), and the environment has no FABRIKA_APP_ORIGIN',
20
20
  webhook_claim_lost: "a delivery's claim expired and another copy of the delivery took it over before this one committed; nothing it wrote is kept",
21
21
  workload_unauthorized: "the workload's key or token does not carry the authority its declaration needs",
22
+ terminal_refused: "IAM refused the application's own credential for an identification at a terminal",
22
23
  request_cancelled: 'the request was cancelled or outlived its budget before its transaction committed; nothing it wrote is kept',
23
24
  file_object_missing: "a file's object is missing from storage",
24
25
  file_object_mismatch: "a file's object has another size than its row records",
@@ -53,6 +53,7 @@ export type {
53
53
  } from './membership'
54
54
  export type { AppPrincipal, LinkHolder, Principal, PrincipalOf, PublicPrincipal, ServicePrincipal, UserPrincipal } from './principal'
55
55
  export { type AppDeps, defineDeps, serve, type ServeOptions } from './serve'
56
+ export type { AssertedIdentity, OpenIdentification, TerminalIdentification } from './terminals'
56
57
  export type {
57
58
  AccessContext,
58
59
  ConnectCall,
@@ -3,7 +3,7 @@
3
3
  // commits it after the handler returned and its output passed validation, and rolls it back otherwise. Every handle
4
4
  // is bound to the invocation: called from another request, another `boot`, or after it ended, it refuses.
5
5
 
6
- import { type ContractArtifact, JOB_MAILERS, type JobMailer } from '@fabrika/auth-core'
6
+ import { type ContractArtifact, type DeclaredEgress, JOB_MAILERS, type JobMailer, PUBLIC_EGRESS } from '@fabrika/auth-core'
7
7
  import {
8
8
  type DataAccess,
9
9
  type DataHandles,
@@ -34,8 +34,8 @@ export interface SiteDeclaration {
34
34
  readonly binds: string | null
35
35
  /** The token purposes `ctx.tokens.sign` may sign. */
36
36
  readonly signs: readonly string[]
37
- /** The HTTPS origins the global `fetch` reaches while the site's handler runs. */
38
- readonly egress: readonly string[]
37
+ /** The HTTPS origins the global `fetch` reaches while the site's handler runs, or `'public'`: every public host (a cron job). */
38
+ readonly egress: DeclaredEgress
39
39
  }
40
40
 
41
41
  /** A public procedure's reach: the texts the verified signed tokens of its request carry, which name the rows it changes. */
@@ -166,10 +166,11 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
166
166
  problems.push(`${where}: signs is not a list of token purposes`)
167
167
  }
168
168
  const egressField = ownField(declared, 'egress')
169
- const egress = Array.isArray(egressField) ? egressField.filter((origin): origin is string => typeof origin === 'string') : []
170
- if (egressField !== undefined && (!Array.isArray(egressField) || egress.length !== egressField.length)) {
171
- problems.push(`${where}: egress is not a list of origins`)
169
+ const origins = Array.isArray(egressField) ? egressField.filter((origin): origin is string => typeof origin === 'string') : []
170
+ if (egressField !== undefined && egressField !== PUBLIC_EGRESS && (!Array.isArray(egressField) || origins.length !== egressField.length)) {
171
+ problems.push(`${where}: egress is not a list of origins or '${PUBLIC_EGRESS}'`)
172
172
  }
173
+ const egress: DeclaredEgress = egressField === PUBLIC_EGRESS ? PUBLIC_EGRESS : Object.freeze([...new Set(origins)].sort())
173
174
  return Object.freeze({
174
175
  sql: sql === 'read' || sql === 'write' ? sql : null,
175
176
  unscoped: Object.freeze(unscoped),
@@ -179,7 +180,7 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
179
180
  kicks: Object.freeze([...new Set(kicks)].sort()),
180
181
  binds: typeof binds === 'string' ? binds : null,
181
182
  signs: Object.freeze([...new Set(signs)].sort()),
182
- egress: Object.freeze([...new Set(egress)].sort()),
183
+ egress,
183
184
  })
184
185
  }
185
186
 
@@ -224,7 +225,7 @@ export function approvedReach(site: string, artifact: ContractArtifact): SiteDec
224
225
  kicks: Object.freeze([...(reach.kicks ?? [])]),
225
226
  binds: reach.binds ?? null,
226
227
  signs: Object.freeze([...(reach.signs ?? [])]),
227
- egress: Object.freeze([...(reach.egress ?? [])]),
228
+ egress: reach.egress === PUBLIC_EGRESS ? PUBLIC_EGRESS : Object.freeze([...(reach.egress ?? [])]),
228
229
  })
229
230
  }
230
231
 
@@ -243,7 +244,7 @@ function describeDeclaration(declaration: SiteDeclaration): string {
243
244
  ...(declaration.kicks.length === 0 ? {} : { kicks: declaration.kicks }),
244
245
  ...(declaration.binds === null ? {} : { binds: declaration.binds }),
245
246
  ...(declaration.signs.length === 0 ? {} : { signs: declaration.signs }),
246
- ...(declaration.egress.length === 0 ? {} : { egress: declaration.egress }),
247
+ ...(declaration.egress !== PUBLIC_EGRESS && declaration.egress.length === 0 ? {} : { egress: declaration.egress }),
247
248
  })
248
249
  }
249
250
 
@@ -321,6 +322,14 @@ export class Invocation {
321
322
  return { check: boundTo(this.#frame, () => transaction.ended, handle), session: () => transaction.session() }
322
323
  }
323
324
 
325
+ /**
326
+ * Runs `work`, from the handler to `commit`, in this invocation's transaction. On SQLite it encloses `work`, and a
327
+ * hold past `@fabrika/db`'s report threshold reaches `held`, logged under `site`.
328
+ */
329
+ enclose<T>(work: () => Promise<T>, site: string, held: (heldMs: number) => void): Promise<T> {
330
+ return this.#transaction.enclose(work, { site, held })
331
+ }
332
+
324
333
  /** Commits; a request the caller abandoned meanwhile rolls back instead. */
325
334
  async commit(signal: AbortSignal | null): Promise<void> {
326
335
  if (signal?.aborted === true) {
@@ -1,6 +1,15 @@
1
1
  // Who called: the caller kind a verified token presents, and the principal a handler sees for it.
2
2
 
3
- import { type AccessTokenClaims, APP_CALLER_PREFIX, type Caller, callerOf, matchAction, OAUTH_CALLER, type PermissionEntry } from '@fabrika/auth-core'
3
+ import {
4
+ type AccessTokenClaims,
5
+ APP_CALLER_PREFIX,
6
+ type Caller,
7
+ callerOf,
8
+ matchAction,
9
+ OAUTH_CALLER,
10
+ type PermissionEntry,
11
+ type TerminalTokenClaims,
12
+ } from '@fabrika/auth-core'
4
13
  import type { PageCaller } from '../client/caller'
5
14
 
6
15
  /** A person: the IAM session, or a `px_` key bound to a user principal. */
@@ -11,6 +20,11 @@ export interface UserPrincipal {
11
20
  readonly label: string
12
21
  /** The mailbox IAM recorded for the person, lower-cased: the one to mail, not proof of possession. Only a person has one. */
13
22
  readonly email: string
23
+ /**
24
+ * The enrolled terminal the person signed in on, when an application asserted them there (ADR-0067); absent for every
25
+ * other sign-in. Information for the handler: what the session may do is in its permissions already.
26
+ */
27
+ readonly terminal?: TerminalTokenClaims
14
28
  }
15
29
 
16
30
  /** A machine: a `px_` key bound to a service principal. */
@@ -65,7 +79,16 @@ export function presentedBy(claims: AccessTokenClaims): Presented | Unpresented
65
79
  switch (caller) {
66
80
  case 'human':
67
81
  if (claims.email === undefined) return 'no_mailbox'
68
- return { caller, principal: { kind: 'user', id: claims.sub, label: claims.label ?? claims.sub, email: claims.email } }
82
+ return {
83
+ caller,
84
+ principal: {
85
+ kind: 'user',
86
+ id: claims.sub,
87
+ label: claims.label ?? claims.sub,
88
+ email: claims.email,
89
+ ...(claims.terminal === undefined ? {} : { terminal: Object.freeze({ id: claims.terminal.id, label: claims.terminal.label }) }),
90
+ },
91
+ }
69
92
  case 'machine':
70
93
  return { caller, principal: { kind: 'service', id: claims.sub, label: claims.label ?? claims.sub } }
71
94
  case 'link':
@@ -0,0 +1,93 @@
1
+ // `ctx.terminals`: the identification of a person at an enrolled terminal (ADR-0067). The application's identification
2
+ // page sends what the person entered to its own procedure, which asks IAM through these calls. The framework presents
3
+ // the request's credential (a public procedure's workload token), and IAM takes the asserting application from the
4
+ // verified transport. Throttling guesses is the application's: `inspect` hands it the terminal to key them on.
5
+
6
+ import type { IamTerminalRpc, IdentificationTerminal, MembershipCaller, TerminalFailure } from '@fabrika/auth-core'
7
+ import { ServerFault } from './faults'
8
+
9
+ /** An identification id longer than this is none IAM issued, so no call is made for it. */
10
+ const MAX_IDENTIFICATION_ID_LENGTH = 200
11
+
12
+ /** An open identification: the terminal it was opened on and when it expires (unix seconds). */
13
+ export interface OpenIdentification {
14
+ readonly terminal: IdentificationTerminal
15
+ readonly expiresAt: number
16
+ }
17
+
18
+ /**
19
+ * IAM's answer to an assertion: the URL the browser goes to next, or why IAM refused. `not_found` is an identification
20
+ * that is unknown, expired, used, cancelled or no longer open to this application; `invalid_principal` a principal
21
+ * that cannot sign in on a terminal (unknown, disabled, not a person, or external).
22
+ */
23
+ export type AssertedIdentity =
24
+ | { readonly ok: true; readonly completionUrl: string }
25
+ | { readonly ok: false; readonly reason: 'not_found' | 'invalid_principal' }
26
+
27
+ /** `ctx.terminals` of an application that declares `terminal: { identify }`. */
28
+ export interface TerminalIdentification {
29
+ /** The open identification `id` (the page's `identification` query parameter), or null when there is none. */
30
+ inspect(id: string): Promise<OpenIdentification | null>
31
+ /**
32
+ * The application's word that `principalId` stands at the identification's terminal. IAM uses the identification up
33
+ * and, on `ok`, the handler sends the browser to `completionUrl`: IAM signs the person in there, and only on the
34
+ * terminal's own browser.
35
+ */
36
+ assert(id: string, principalId: string): Promise<AssertedIdentity>
37
+ /** Ends an identification the person abandoned; one that is no longer open is already ended. */
38
+ cancel(id: string): Promise<void>
39
+ }
40
+
41
+ /** `ctx.terminals` for one request: every call carries the request's credential and correlation id. */
42
+ export function terminalIdentification(rpc: IamTerminalRpc, caller: MembershipCaller): TerminalIdentification {
43
+ return Object.freeze({
44
+ async inspect(id: string): Promise<OpenIdentification | null> {
45
+ if (!isIdentificationId(id)) return null
46
+ const result = await ask(() => rpc.inspectIdentification({ ...caller, id }), 'inspect an identification')
47
+ if (!result.ok) return null
48
+ return Object.freeze({ terminal: Object.freeze({ id: result.terminal.id, label: result.terminal.label }), expiresAt: result.expiresAt })
49
+ },
50
+ async assert(id: string, principalId: string): Promise<AssertedIdentity> {
51
+ if (!isIdentificationId(id)) return { ok: false, reason: 'not_found' }
52
+ const result = await ask(() => rpc.assertIdentity({ ...caller, id, principalId }), 'assert an identity')
53
+ return result.ok ? { ok: true, completionUrl: result.completionUrl } : { ok: false, reason: result.reason }
54
+ },
55
+ async cancel(id: string): Promise<void> {
56
+ if (isIdentificationId(id)) {
57
+ await ask(() => rpc.cancelIdentification({ ...caller, id }), 'cancel an identification')
58
+ }
59
+ },
60
+ })
61
+ }
62
+
63
+ function isIdentificationId(id: string): boolean {
64
+ return id !== '' && id.length <= MAX_IDENTIFICATION_ID_LENGTH
65
+ }
66
+
67
+ /**
68
+ * Runs one IAM call. A transport failure is a 503 that names nothing; IAM refusing the application's own credential is
69
+ * a server fault, because the visitor cannot change it. What remains is the handler's answer.
70
+ */
71
+ async function ask<T extends { readonly ok: true }>(
72
+ work: () => Promise<T | TerminalFailure>,
73
+ operation: string,
74
+ ): Promise<T | { readonly ok: false; readonly reason: 'not_found' | 'invalid_principal' }> {
75
+ let result: T | TerminalFailure
76
+ try {
77
+ result = await work()
78
+ } catch {
79
+ throw new ServerFault('iam_unavailable', `IAM could not be asked to ${operation}`, 503)
80
+ }
81
+ if (result.ok) {
82
+ return result
83
+ }
84
+ switch (result.reason) {
85
+ case 'not_found':
86
+ case 'invalid_principal':
87
+ return { ok: false, reason: result.reason }
88
+ case 'missing_token':
89
+ case 'invalid_token':
90
+ case 'not_allowed':
91
+ throw new ServerFault('terminal_refused', `IAM refused the application's credential to ${operation}: ${result.reason}`)
92
+ }
93
+ }