@fabrika/app 0.9.0-cloudflare.1 → 0.9.0-cloudflare.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +4 -2
  3. package/skill/references/app-setup.md +29 -11
  4. package/skill/references/endpoint.md +5 -0
  5. package/skill/references/errors.md +31 -31
  6. package/skill/references/jobs.md +9 -0
  7. package/skill/references/tests.md +22 -1
  8. package/src/build.ts +6 -1
  9. package/src/bun.ts +10 -0
  10. package/src/cloudflare/application.ts +3 -3
  11. package/src/cloudflare/bindings.ts +26 -5
  12. package/src/cloudflare/names.ts +6 -0
  13. package/src/contract/artifact-schema.ts +6 -1
  14. package/src/contract/build.ts +33 -10
  15. package/src/contract/group.ts +10 -1
  16. package/src/contract/kit.ts +2 -1
  17. package/src/disk-assets.ts +8 -3
  18. package/src/implement/boot.ts +9 -3
  19. package/src/implement/capability.ts +3 -0
  20. package/src/implement/dispatch.ts +4 -4
  21. package/src/implement/egress.ts +341 -8
  22. package/src/implement/invocation.ts +10 -9
  23. package/src/jobs/runner.ts +7 -2
  24. package/src/spa-fallback.ts +12 -0
  25. package/src/testing/endpoint-access.ts +135 -23
  26. package/src/testing/isolation.ts +8 -1
  27. package/types/src/build.d.ts +1 -1
  28. package/types/src/cloudflare/bindings.d.ts +6 -0
  29. package/types/src/cloudflare/names.d.ts +5 -0
  30. package/types/src/contract/group.d.ts +9 -1
  31. package/types/src/contract/kit.d.ts +2 -2
  32. package/types/src/disk-assets.d.ts +4 -1
  33. package/types/src/implement/boot.d.ts +6 -0
  34. package/types/src/implement/capability.d.ts +2 -0
  35. package/types/src/implement/dispatch.d.ts +2 -1
  36. package/types/src/implement/egress.d.ts +36 -1
  37. package/types/src/implement/invocation.d.ts +3 -3
  38. package/types/src/jobs/runner.d.ts +3 -0
  39. package/types/src/spa-fallback.d.ts +2 -0
  40. package/types/src/testing/endpoint-access.d.ts +25 -1
  41. package/types/src/testing/isolation.d.ts +1 -1
@@ -4,8 +4,12 @@
4
4
  import { rawRequestTarget } from '@fabrika/auth-core'
5
5
  import { join, resolve, sep } from 'node:path'
6
6
  import type { SpaAssets } from './implement/dispatch.js'
7
+ import { isPageRoute } from './spa-fallback.js'
7
8
 
8
- /** Serves each SPA mount from its build directory under `root`; a path that names no file gets the SPA's `index.html`. */
9
+ /**
10
+ * Serves each SPA mount from its build directory under `root`; a page route that names no file gets the SPA's
11
+ * `index.html` (`isPageRoute`), and a missing asset is 404.
12
+ */
9
13
  export function diskAssets(root: string): SpaAssets {
10
14
  return {
11
15
  async serve(mount, request) {
@@ -15,8 +19,9 @@ export function diskAssets(root: string): SpaAssets {
15
19
  // The path is canonical, so every segment decodes and none is `.` or `..`.
16
20
  const file = join(dir, ...relative.split('/').filter((segment) => segment !== '').map((segment) => decodeURIComponent(segment)))
17
21
  const candidate = file.startsWith(`${dir}${sep}`) ? Bun.file(file) : null
18
- const served = candidate !== null && (await candidate.exists()) ? candidate : Bun.file(join(dir, 'index.html'))
19
- if (!(await served.exists())) {
22
+ const found = candidate !== null && (await candidate.exists()) ? candidate : null
23
+ const served = found ?? (isPageRoute(request) ? Bun.file(join(dir, 'index.html')) : null)
24
+ if (served === null || !(await served.exists())) {
20
25
  return new Response('Not found', { status: 404 })
21
26
  }
22
27
  return new Response(served)
@@ -65,7 +65,7 @@ import {
65
65
  type ServedSite,
66
66
  type SpaAssets,
67
67
  } from './dispatch'
68
- import { installEgressGuard, platformCall, platformFacility } from './egress'
68
+ import { type HostResolver, installEgressGuard, platformCall, platformFacility, publicEgressRules, type SiteEgress, siteEgress } from './egress'
69
69
  import type { CapturedEnvironment } from './environment'
70
70
  import { FailureReports } from './failures'
71
71
  import { applicationPolicy } from './headers'
@@ -124,6 +124,11 @@ export interface BootOptions {
124
124
  * with a job or a webhook does not boot.
125
125
  */
126
126
  readonly workloadTokens?: WorkloadTokenSource
127
+ /**
128
+ * Resolves a host name to its addresses, which the Bun adapter supplies (`node:dns`): a cron job's public egress then
129
+ * refuses a public name of a private address before each hop. A Worker exposes no resolution and passes none.
130
+ */
131
+ readonly resolveHost?: HostResolver
127
132
  /** What posts a report to Operations' ingest; `fetch` by default. */
128
133
  readonly operationsFetch?: (request: Request) => Promise<Response>
129
134
  /** What asks a public procedure's challenge provider to verify a token; `fetch` by default. */
@@ -460,8 +465,8 @@ function boundHandler(
460
465
  }
461
466
 
462
467
  /** The origins a staged-kit site's handler reaches, a kit without tables included; nothing for a bare handler. */
463
- function egressOf(bound: BoundHandler): { readonly egress?: readonly string[] } {
464
- return !bound.site || bound.declaration === null ? {} : { egress: bound.declaration.egress }
468
+ function egressOf(bound: BoundHandler): { readonly egress?: SiteEgress } {
469
+ return !bound.site || bound.declaration === null ? {} : { egress: siteEgress(bound.declaration.egress, null) }
465
470
  }
466
471
 
467
472
  /** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
@@ -899,6 +904,7 @@ function bootOnce(app: ContractImplementation, options: BootOptions, binding: Bi
899
904
  mail,
900
905
  tokens,
901
906
  url,
907
+ publicEgress: publicEgressRules(options.environment.framework, options.resolveHost ?? null),
902
908
  }, problems)
903
909
  for (const id of jobs.declared) {
904
910
  declared.add(id)
@@ -34,6 +34,9 @@ export const CAPABILITY_REFUSALS = {
34
34
  undeclared_binding: 'the site named a webhook it does not declare in binds',
35
35
  undeclared_signs: 'the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows',
36
36
  undeclared_egress: 'the site called an origin it does not declare in egress',
37
+ refused_egress:
38
+ "the site's public egress refused the host: an IP address, a local or internal name, a non-default port, a host of this installation, or a name that resolves to a private address",
39
+ oversized_egress: "a response to the site's public egress was larger than it reads",
37
40
  wrong_binding: 'a webhook binding was handed a capability of another kind or dimension than the webhook is bound to',
38
41
  missing_id: 'the job named an object without an id',
39
42
  wrong_capability: 'an operation was handed a capability of another dimension or action than it needs',
@@ -84,7 +84,7 @@ import {
84
84
  } from './capability'
85
85
  import type { ChallengeIssuer, ChallengeVerifier } from './challenge'
86
86
  import { connectContext, type ImportWire } from './connect'
87
- import { runAsSite } from './egress'
87
+ import { runAsSite, type SiteEgress, siteEgress } from './egress'
88
88
  import type { FailureReports } from './failures'
89
89
  import { ServerFault } from './faults'
90
90
  import { type HeaderPolicy, withHeaderPolicy } from './headers'
@@ -167,7 +167,7 @@ export type ServedEndpoint =
167
167
  /** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
168
168
  readonly site?: ServedSite
169
169
  /** The origins a staged-kit site's `fetch` reaches while its handler runs; absent for a handler `implement` bound. */
170
- readonly egress?: readonly string[]
170
+ readonly egress?: SiteEgress
171
171
  /** Its buckets and time limit; without them (a generated endpoint) no bucket and the request budget. */
172
172
  readonly admission?: ProcedureAdmission
173
173
  /** A POST's `query`, parsed from the URL beside the body: its schema and its shape. */
@@ -1087,7 +1087,7 @@ export class ContractRuntime {
1087
1087
  kick: kicks.kick,
1088
1088
  })
1089
1089
  const args = { input, ctx, ...(query === undefined ? {} : { query }) }
1090
- return await runAsSite(`'${served.row.id}'`, served.site.declaration.egress, async () => await served.handler(args))
1090
+ return await runAsSite(`'${served.row.id}'`, siteEgress(served.site.declaration.egress, null), async () => await served.handler(args))
1091
1091
  })
1092
1092
  } finally {
1093
1093
  frame.end()
@@ -1234,7 +1234,7 @@ export class ContractRuntime {
1234
1234
  if (site === undefined) {
1235
1235
  await served.handler(args)
1236
1236
  } else {
1237
- await runAsSite(`webhook '${served.webhook}'`, site.egress, async () => await served.handler(args))
1237
+ await runAsSite(`webhook '${served.webhook}'`, siteEgress(site.egress, null), async () => await served.handler(args))
1238
1238
  }
1239
1239
  })
1240
1240
  } finally {
@@ -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
+ }
@@ -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
 
@@ -22,7 +22,7 @@ import {
22
22
  runInRequest,
23
23
  type Within,
24
24
  } from '../implement/capability'
25
- import { runAsSite } from '../implement/egress'
25
+ import { type PublicEgressRules, runAsSite, siteEgress } from '../implement/egress'
26
26
  import { failureCode, type FailureReason, failureReason } from '../implement/failures'
27
27
  import { ServerFault } from '../implement/faults'
28
28
  import type { ContractDatabase } from '../implement/implement'
@@ -188,6 +188,8 @@ interface RunnerContext {
188
188
  /** The environment's signed tokens: `ctx.tokens` of a job that declares `signs`. */
189
189
  readonly tokens: SignedTokens | null
190
190
  readonly url: AppUrl
191
+ /** What a job that declares `egress: 'public'` reaches; such a job reaches nothing without it. */
192
+ readonly publicEgress: PublicEgressRules | null
191
193
  }
192
194
 
193
195
  /** Where `fabrika app dev` shows what a handler threw, on the developer's own machine; nothing else sets one. */
@@ -227,6 +229,8 @@ export interface JobBindingInput {
227
229
  readonly tokens?: SignedTokens
228
230
  /** `ctx.url`: absolute links to the application's own procedures; without it every link is refused. */
229
231
  readonly url?: AppUrl
232
+ /** The rules of public egress, which `boot` reads from the environment; without them a public job reaches nothing. */
233
+ readonly publicEgress?: PublicEgressRules
230
234
  }
231
235
 
232
236
  interface BoundFrameworkJob {
@@ -326,6 +330,7 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
326
330
  mail: input.mail,
327
331
  tokens: input.tokens ?? null,
328
332
  url: input.url ?? appUrl(input.artifact, null),
333
+ publicEgress: input.publicEgress ?? null,
329
334
  // A job's kicks run only after the job is bound, so `kick` below is defined by then.
330
335
  kick: (name: string) => kick(name),
331
336
  })
@@ -698,7 +703,7 @@ async function attemptOnce(
698
703
  if (site === null) {
699
704
  await job.handler({ ctx })
700
705
  } else {
701
- await runAsSite(`job '${job.name}'`, site.egress, async () => await job.handler({ ctx }))
706
+ await runAsSite(`job '${job.name}'`, siteEgress(site.egress, context.publicEgress), async () => await job.handler({ ctx }))
702
707
  }
703
708
  kicked.push(...kicks.kicked())
704
709
  }).then(() => null, (error: unknown) => {
@@ -0,0 +1,12 @@
1
+ // Which request an SPA mount answers with its `index.html` when it names no file: a route of the page, not a missing
2
+ // asset. Both asset servers apply it (`diskAssets`, `bindingAssets`), so a missing script stays a 404 and never arrives
3
+ // as an HTML page with a 200.
4
+
5
+ import { rawRequestTarget } from '@fabrika/auth-core'
6
+
7
+ /** A GET or HEAD whose last path segment has no extension, or one that asks for HTML (a navigation to `/notes/v1.2`). */
8
+ export function isPageRoute(request: Request): boolean {
9
+ if (request.method !== 'GET' && request.method !== 'HEAD') return false
10
+ const last = rawRequestTarget(request.url).pathname.split('/').pop() ?? ''
11
+ return !last.includes('.') || (request.headers.get('Accept') ?? '').includes('text/html')
12
+ }