@fabrika/app 0.6.0 → 0.7.0

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 (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +387 -236
  10. package/skill/references/spa.md +5 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
@@ -0,0 +1,84 @@
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.
5
+
6
+ import { AsyncLocalStorage } from 'node:async_hooks'
7
+ import { CapabilityRefusedError } from './capability'
8
+
9
+ interface EgressScope {
10
+ /** The site, as a refusal names it. */
11
+ readonly site: string
12
+ readonly origins: ReadonlySet<string>
13
+ }
14
+
15
+ const scope = new AsyncLocalStorage<EgressScope>()
16
+ const guards = new WeakSet<object>()
17
+
18
+ /** 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)
21
+ }
22
+
23
+ /**
24
+ * `facility` with each of its methods run outside any site's scope: a framework facility a handler calls, whose own
25
+ * requests are the platform's, not the site's.
26
+ */
27
+ export function platformFacility<T extends object>(facility: T): T {
28
+ return new Proxy(facility, {
29
+ get(target, property) {
30
+ const value: unknown = Reflect.get(target, property, target)
31
+ if (typeof value !== 'function') {
32
+ return value
33
+ }
34
+ return (...args: unknown[]): unknown => scope.exit(() => Reflect.apply(value, target, args))
35
+ },
36
+ })
37
+ }
38
+
39
+ /** `work`, outside any site's scope: a framework call that happens to run inside a handler. */
40
+ export function platformCall<T>(work: () => T): T {
41
+ return scope.exit(work)
42
+ }
43
+
44
+ /**
45
+ * Wraps the global `fetch` in the guard, once: a `fetch` that is already a guard stays. A test that replaces `fetch`
46
+ * after boot replaces the guard with it.
47
+ */
48
+ export function installEgressGuard(): void {
49
+ const current = globalThis.fetch
50
+ if (guards.has(current)) {
51
+ return
52
+ }
53
+ const guarded = Object.assign(
54
+ (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
55
+ const running = scope.getStore()
56
+ if (running === undefined) {
57
+ return current(input, init)
58
+ }
59
+ const origin = originOf(input)
60
+ if (origin !== null && running.origins.has(origin)) {
61
+ return current(input, init)
62
+ }
63
+ return Promise.reject(
64
+ new CapabilityRefusedError(
65
+ 'undeclared_egress',
66
+ origin === null
67
+ ? `${running.site} called fetch with a URL that is not absolute`
68
+ : `${running.site} calls ${origin}, which it does not declare (egress: ['${origin}'])`,
69
+ ),
70
+ )
71
+ },
72
+ { preconnect: current.preconnect },
73
+ )
74
+ guards.add(guarded)
75
+ globalThis.fetch = guarded
76
+ }
77
+
78
+ function originOf(input: string | URL | Request): string | null {
79
+ try {
80
+ return new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).origin
81
+ } catch {
82
+ return null
83
+ }
84
+ }
@@ -16,18 +16,28 @@ export const SERVER_FAULTS = {
16
16
  output_refused: 'the handler returned a value its output schema refuses',
17
17
  contract_inconsistent: 'the compiled endpoint misses what its kind needs (a path parameter, a body limit or a capability kind)',
18
18
  webhook_binding_mismatch: "a webhook binding reaches another dimension than the webhook's",
19
+ webhook_unverifiable: 'a webhook verifies a signature over its public URL (Twilio), and the environment has no FABRIKA_APP_ORIGIN',
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",
19
21
  workload_unauthorized: "the workload's key or token does not carry the authority its declaration needs",
20
22
  request_cancelled: 'the request was cancelled or outlived its budget before its transaction committed; nothing it wrote is kept',
21
23
  file_object_missing: "a file's object is missing from storage",
22
24
  file_object_mismatch: "a file's object has another size than its row records",
23
25
  file_key_taken: "a new file's object key already held an object this upload did not write",
24
- file_collection_unknown: 'ctx.files named a collection the contract does not declare',
26
+ file_kind_unknown: 'ctx.files.put or a file field named a file kind the contract does not declare',
27
+ file_put: 'ctx.files.put was called without the options it takes ({ type, name? })',
28
+ file_output_kind: 'the handler answered a file of another kind than its output declares',
25
29
  file_sweep_failed: "the sweep could not delete a removed or orphaned file's object or row; the next run retries it",
26
30
  challenge_unavailable: "a form's challenge provider could not be reached in time or did not answer as it documents",
27
31
  challenge_misconfigured: "a form's challenge provider refused the application's secret",
28
32
  attachment_rows: 'the handler returned more rows than its attachment declares (maxRows)',
29
33
  attachment_cell: 'a cell of the attachment is longer than one cell may hold',
30
34
  attachment_size: 'the encoded attachment is larger than it declares (maxBytes); no body was sent',
35
+ redirect_refused:
36
+ "the handler redirected to a target that is neither its own origin's path, an origin its redirect() lists, nor a URL a verified token of the request carries",
37
+ token_claims_refused: "the claims a site signed are not what the token purpose's claims schema admits",
38
+ token_keys_missing: 'a site signed a token, and the environment holds no token key',
39
+ url_unbuildable:
40
+ 'ctx.url named no procedure of the contract, passed a parameter its HTTP shape does not read, or the environment has no public origin',
31
41
  } as const
32
42
 
33
43
  export type ServerFaultReason = keyof typeof SERVER_FAULTS
@@ -10,8 +10,8 @@ export {
10
10
  type EmailSenderCapabilities,
11
11
  mayRetryDelivery,
12
12
  } from '@fabrika/email'
13
- export { FILE_SWEEP_JOB } from '../files/collections'
14
13
  export type { FileCollectionHandle, FileCollectionReader, FileListing, ListedFile, OpenedFile } from '../files/handle'
14
+ export { FILE_SWEEP_JOB } from '../files/kinds'
15
15
  export {
16
16
  FILE_SCAN_JOB,
17
17
  type FileScanner,
@@ -51,7 +51,7 @@ export type {
51
51
  MemberManagement,
52
52
  MemberTarget,
53
53
  } from './membership'
54
- export type { AppPrincipal, LinkHolder, Principal, PrincipalOf, ServicePrincipal, UserPrincipal } from './principal'
54
+ export type { AppPrincipal, LinkHolder, Principal, PrincipalOf, PublicPrincipal, ServicePrincipal, UserPrincipal } from './principal'
55
55
  export { type AppDeps, defineDeps, serve, type ServeOptions } from './serve'
56
56
  export type {
57
57
  AccessContext,
@@ -63,6 +63,7 @@ export type {
63
63
  Handler,
64
64
  HandlerInput,
65
65
  HandlersOf,
66
+ RequestClient,
66
67
  RequestContext,
67
68
  WebhookHandler,
68
69
  } from './types'
@@ -8,6 +8,7 @@ import {
8
8
  type DataAccess,
9
9
  type DataHandles,
10
10
  type DataSite,
11
+ type FileBinding,
11
12
  type InvocationTransaction,
12
13
  type QueryRefusal,
13
14
  QueryRefusedError,
@@ -17,7 +18,6 @@ import {
17
18
  import type { DataKind, UnscopedMode } from '@fabrika/db/tables'
18
19
  import type { SqlMode } from '../contract/context'
19
20
  import { DECLARED_FIELDS } from '../contract/declaration'
20
- import type { FileSite } from '../files/collections'
21
21
  import { CapabilityRefusedError, isActiveRequest, type RequestFrame } from './capability'
22
22
  import { ServerFault } from './faults'
23
23
 
@@ -27,8 +27,20 @@ export interface SiteDeclaration {
27
27
  readonly unscoped: Readonly<Record<string, UnscopedMode>>
28
28
  readonly connect: readonly string[]
29
29
  readonly issues: string | null
30
- readonly then: string | null
31
30
  readonly mailer: JobMailer | null
31
+ /** The cron jobs `ctx.kick` may name. */
32
+ readonly kicks: readonly string[]
33
+ /** The webhook whose bindings `ctx.webhooks` creates and removes. */
34
+ readonly binds: string | null
35
+ /** The token purposes `ctx.tokens.sign` may sign. */
36
+ readonly signs: readonly string[]
37
+ /** The HTTPS origins the global `fetch` reaches while the site's handler runs. */
38
+ readonly egress: readonly string[]
39
+ }
40
+
41
+ /** A public procedure's reach: the texts the verified signed tokens of its request carry, which name the rows it changes. */
42
+ export interface PublicReach {
43
+ readonly carried: ReadonlySet<string>
32
44
  }
33
45
 
34
46
  export type SiteFunction = (args: { readonly input?: unknown; readonly ctx: object }) => unknown
@@ -61,8 +73,11 @@ const NO_DECLARATION: SiteDeclaration = Object.freeze({
61
73
  unscoped: Object.freeze({}),
62
74
  connect: Object.freeze([]),
63
75
  issues: null,
64
- then: null,
65
76
  mailer: null,
77
+ kicks: Object.freeze([]),
78
+ binds: null,
79
+ signs: Object.freeze([]),
80
+ egress: Object.freeze([]),
66
81
  })
67
82
 
68
83
  function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
@@ -131,22 +146,40 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
131
146
  if (issues !== undefined && typeof issues !== 'string') {
132
147
  problems.push(`${where}: issues is not the name of a link mount`)
133
148
  }
134
- const then = ownField(declared, 'then')
135
- if (then !== undefined && typeof then !== 'string') {
136
- problems.push(`${where}: then is not the name of a job`)
137
- }
138
149
  const mailerField = ownField(declared, 'mailer')
139
150
  const mailer = JOB_MAILERS.find((transport) => transport === mailerField) ?? null
140
151
  if (mailerField !== undefined && mailer === null) {
141
152
  problems.push(`${where}: mailer is not 'installation' or 'own'`)
142
153
  }
154
+ const kicksField = ownField(declared, 'kicks')
155
+ const kicks = Array.isArray(kicksField) ? kicksField.filter((name): name is string => typeof name === 'string') : []
156
+ if (kicksField !== undefined && (!Array.isArray(kicksField) || kicks.length !== kicksField.length)) {
157
+ problems.push(`${where}: kicks is not a list of job names`)
158
+ }
159
+ const binds = ownField(declared, 'binds')
160
+ if (binds !== undefined && typeof binds !== 'string') {
161
+ problems.push(`${where}: binds is not the name of a webhook`)
162
+ }
163
+ const signsField = ownField(declared, 'signs')
164
+ const signs = Array.isArray(signsField) ? signsField.filter((purpose): purpose is string => typeof purpose === 'string') : []
165
+ if (signsField !== undefined && (!Array.isArray(signsField) || signs.length !== signsField.length)) {
166
+ problems.push(`${where}: signs is not a list of token purposes`)
167
+ }
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`)
172
+ }
143
173
  return Object.freeze({
144
174
  sql: sql === 'read' || sql === 'write' ? sql : null,
145
175
  unscoped: Object.freeze(unscoped),
146
176
  connect: Object.freeze(connectors),
147
177
  issues: typeof issues === 'string' ? issues : null,
148
- then: typeof then === 'string' ? then : null,
149
178
  mailer,
179
+ kicks: Object.freeze([...new Set(kicks)].sort()),
180
+ binds: typeof binds === 'string' ? binds : null,
181
+ signs: Object.freeze([...new Set(signs)].sort()),
182
+ egress: Object.freeze([...new Set(egress)].sort()),
150
183
  })
151
184
  }
152
185
 
@@ -187,14 +220,17 @@ export function approvedReach(site: string, artifact: ContractArtifact): SiteDec
187
220
  unscoped: Object.freeze({ ...reach.unscoped }),
188
221
  connect: Object.freeze([...reach.connect]),
189
222
  issues: reach.issues,
190
- then: reach.then ?? null,
191
223
  mailer: reach.mailer ?? null,
224
+ kicks: Object.freeze([...(reach.kicks ?? [])]),
225
+ binds: reach.binds ?? null,
226
+ signs: Object.freeze([...(reach.signs ?? [])]),
227
+ egress: Object.freeze([...(reach.egress ?? [])]),
192
228
  })
193
229
  }
194
230
 
195
231
  /**
196
- * A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted; `then` and
197
- * `mailer` only when declared.
232
+ * A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted; each optional
233
+ * field only when declared.
198
234
  */
199
235
  function describeDeclaration(declaration: SiteDeclaration): string {
200
236
  const unscoped = Object.entries(declaration.unscoped).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
@@ -203,8 +239,11 @@ function describeDeclaration(declaration: SiteDeclaration): string {
203
239
  unscoped,
204
240
  connect: [...declaration.connect].sort(),
205
241
  issues: declaration.issues,
206
- ...(declaration.then === null ? {} : { then: declaration.then }),
207
242
  ...(declaration.mailer === null ? {} : { mailer: declaration.mailer }),
243
+ ...(declaration.kicks.length === 0 ? {} : { kicks: declaration.kicks }),
244
+ ...(declaration.binds === null ? {} : { binds: declaration.binds }),
245
+ ...(declaration.signs.length === 0 ? {} : { signs: declaration.signs }),
246
+ ...(declaration.egress.length === 0 ? {} : { egress: declaration.egress }),
208
247
  })
209
248
  }
210
249
 
@@ -235,8 +274,17 @@ export class Invocation {
235
274
  this.#transaction = data.invocation(readOnly)
236
275
  }
237
276
 
238
- /** The handles of the site, all of them running in this invocation's transaction. */
239
- handles(kind: DataKind, declaration: SiteDeclaration, principal: string): DataHandles {
277
+ /**
278
+ * The handles of the site, all of them running in this invocation's transaction. A public procedure's (`reach` its
279
+ * carried texts) insert and read under its actions only, and update or remove only a row a verified token names.
280
+ */
281
+ handles(
282
+ kind: DataKind,
283
+ declaration: SiteDeclaration,
284
+ principal: string,
285
+ reach: PublicReach | 'declared' = 'declared',
286
+ files?: FileBinding,
287
+ ): DataHandles {
240
288
  const transaction = this.#transaction
241
289
  const site: DataSite = {
242
290
  kind,
@@ -244,6 +292,8 @@ export class Invocation {
244
292
  principal,
245
293
  check: boundTo(this.#frame, () => transaction.ended),
246
294
  session: () => transaction.session(),
295
+ ...(reach === 'declared' ? {} : { public: true, carried: reach.carried }),
296
+ ...(files === undefined ? {} : { files }),
247
297
  }
248
298
  return this.#data.handles(site)
249
299
  }
@@ -259,16 +309,16 @@ export class Invocation {
259
309
  })
260
310
  }
261
311
 
262
- /** Where `ctx.files` runs: this invocation's transaction, bound like every handle; a query's only reads. */
263
- fileSite(where: string, kind: DataKind): Omit<FileSite, 'frame'> {
312
+ /** The check of `ctx.files`: bound to this invocation's transaction, like every handle. */
313
+ fileCheck(): () => void {
264
314
  const transaction = this.#transaction
265
- return {
266
- where,
267
- check: boundTo(this.#frame, () => transaction.ended, 'ctx.files'),
268
- session: () => transaction.session(),
269
- readOnly: kind === 'mutation' && !transaction.readOnly ? null : 'a query reads only; remove files in a mutation',
270
- attaches: false,
271
- }
315
+ return boundTo(this.#frame, () => transaction.ended, 'ctx.files')
316
+ }
317
+
318
+ /** Where the framework writes for this site (a webhook binding): this invocation's transaction, bound like every handle. */
319
+ frameworkSite(handle: string): { readonly check: () => void; readonly session: () => Promise<Session> } {
320
+ const transaction = this.#transaction
321
+ return { check: boundTo(this.#frame, () => transaction.ended, handle), session: () => transaction.session() }
272
322
  }
273
323
 
274
324
  /** Commits; a request the caller abandoned meanwhile rolls back instead. */
@@ -297,14 +347,24 @@ export interface AccessCapabilities {
297
347
  readonly app?: unknown
298
348
  }
299
349
 
300
- /** `ctx.db` of an endpoint, by its access form: the API matrix of `docs/reference/application-runtime.md`. */
350
+ /**
351
+ * `ctx.db` of an endpoint, by its access form: the API matrix of `docs/reference/application-runtime.md`. An endpoint of
352
+ * several capabilities binds each, as a job binds what it minted: `ctx.db.on(ref)`, `.in(ref)`, `.over(within)`.
353
+ */
301
354
  export function endpointData(
302
355
  handles: DataHandles,
303
- form: 'on' | 'in' | 'over' | 'creates' | 'app' | 'member',
356
+ form: 'on' | 'in' | 'over' | 'creates' | 'app' | 'member' | 'capabilities',
304
357
  access: AccessCapabilities,
305
358
  ): object {
306
359
  const shared = { reference: handles.reference, ...(handles.unscoped === undefined ? {} : { unscoped: handles.unscoped }) }
307
360
  switch (form) {
361
+ case 'capabilities':
362
+ return Object.freeze({
363
+ on: (ref: unknown) => handles.target(ref, 'on'),
364
+ in: (ref: unknown) => handles.target(ref, 'in'),
365
+ over: (within: unknown) => handles.within(within),
366
+ ...shared,
367
+ })
308
368
  case 'on':
309
369
  return Object.freeze({ target: handles.target(access.target, 'on'), app: handles.app(access.target), ...shared })
310
370
  case 'in':
@@ -321,17 +381,34 @@ export function endpointData(
321
381
  }
322
382
 
323
383
  /** `ctx.db` of a job or a webhook: each capability it minted or received binds the same handles an endpoint gets. */
324
- export function siteData(handles: DataHandles): object {
384
+ export function siteData(handles: DataHandles, app: (action: string) => object): object {
325
385
  return Object.freeze({
326
386
  on: (ref: unknown) => handles.target(ref, 'on'),
327
387
  in: (ref: unknown) => handles.target(ref, 'in'),
328
388
  over: (within: unknown) => handles.within(within),
389
+ app,
329
390
  reference: handles.reference,
330
391
  ...(handles.unscoped === undefined ? {} : { unscoped: handles.unscoped }),
331
392
  })
332
393
  }
333
394
 
334
- /** The handles of a job attempt, outside its fenced transaction (`'job'`, each read on its own) or inside it. */
395
+ /**
396
+ * `ctx.app` and `ctx.db.app` of a webhook bound to a dimension. The types already refuse them; at run time they exist
397
+ * only to refuse by name.
398
+ */
399
+ export function boundWebhookApp(webhook: string): (action: string) => never {
400
+ return () => {
401
+ throw new CapabilityRefusedError(
402
+ 'undeclared_action',
403
+ `webhook '${webhook}' is bound to a dimension and acts on its bound value: use ctx.refs and ctx.db.on(ref)`,
404
+ )
405
+ }
406
+ }
407
+
408
+ /**
409
+ * The handles of a job attempt, outside its fenced transaction (`'job'`, each read on its own) or inside it. `tickMs`,
410
+ * the job's schedule period, is a queue claim's first backoff.
411
+ */
335
412
  export function jobHandles(
336
413
  frame: RequestFrame,
337
414
  data: DataAccess,
@@ -340,8 +417,18 @@ export function jobHandles(
340
417
  kind: 'job' | 'mutation',
341
418
  session: () => Promise<Session>,
342
419
  ended: () => boolean,
420
+ tickMs?: number,
421
+ files?: FileBinding,
343
422
  ): DataHandles {
344
- return data.handles({ kind, unscoped: declaration.unscoped, principal, check: boundTo(frame, ended), session })
423
+ return data.handles({
424
+ kind,
425
+ unscoped: declaration.unscoped,
426
+ principal,
427
+ check: boundTo(frame, ended),
428
+ session,
429
+ ...(tickMs === undefined ? {} : { tickMs }),
430
+ ...(files === undefined ? {} : { files }),
431
+ })
345
432
  }
346
433
 
347
434
  /**
@@ -366,14 +453,27 @@ export function jobSqlOutsideTransaction(job: string): SqlTag {
366
453
  return refusingSql('sql_outside_transaction', `${job} runs ctx.sql inside ctx.transaction((tx) => …), as tx.sql`)
367
454
  }
368
455
 
369
- /** `ctx.files` of a contract that declares no file collection: every name is refused. */
370
- export function noFileCollections(where: string): (collection: string) => never {
371
- return (collection) => {
372
- throw new ServerFault('file_collection_unknown', `${where}: ctx.files('${String(collection)}') names a collection, and the contract declares none`)
373
- }
374
- }
375
-
376
456
  /** `tx.sql` of a job site that declares it: its statements run in the attempt's fenced transaction. */
377
457
  export function jobSql(frame: RequestFrame, data: DataAccess, mode: SqlMode, session: Session, ended: () => boolean): SqlTag {
378
458
  return data.sql({ mode, check: boundTo(frame, ended, 'tx.sql'), session: async () => session, fail: () => session.fail() })
379
459
  }
460
+
461
+ /**
462
+ * `ctx.kick` of one invocation: it names only a declared job, and records it; the dispatcher sends each recorded job once,
463
+ * after the commit, and nothing on a rollback.
464
+ */
465
+ export function kickRecorder(
466
+ site: string,
467
+ declared: readonly string[],
468
+ ): { readonly kick: (job: string) => void; readonly kicked: () => readonly string[] } {
469
+ const kicked = new Set<string>()
470
+ return {
471
+ kick: (job: string) => {
472
+ if (!declared.includes(job)) {
473
+ throw new CapabilityRefusedError('undeclared_kick', `${site} does not declare kicks: ['${String(job)}']`)
474
+ }
475
+ kicked.add(job)
476
+ },
477
+ kicked: () => [...kicked],
478
+ }
479
+ }
@@ -0,0 +1,88 @@
1
+ // Flat scalar parameters: a GET procedure's input and a POST's `query` from the URL, and a native form's urlencoded body.
2
+ // Each parameter becomes the value its field's schema reads: a string as it is, a number, a boolean and `null` only in
3
+ // their canonical spelling, so one value has one URL. A name sent twice is refused; an unknown name is the strict
4
+ // input's to refuse.
5
+
6
+ import type { SchemaShape } from '@fabrika/auth-core'
7
+ import { BadRequestError, HttpError } from '../errors'
8
+
9
+ /** The longest URL a procedure reads its parameters from. */
10
+ export const MAX_URL_LENGTH = 8192
11
+
12
+ /** The parameters of `params` as the object `shape`'s fields read them; `shape` is a flat object, or `null` for none. */
13
+ export function paramsObject(params: URLSearchParams, shape: SchemaShape | null): Record<string, unknown> {
14
+ const fields = shape?.type === 'object' ? shape.fields : {}
15
+ const read: Record<string, unknown> = {}
16
+ for (const [name, value] of params) {
17
+ if (Object.hasOwn(read, name)) {
18
+ throw new BadRequestError(`the parameter '${name}' is sent twice`)
19
+ }
20
+ const field = Object.hasOwn(fields, name) ? fields[name] : undefined
21
+ read[name] = field === undefined ? value : scalarOf(field, value)
22
+ }
23
+ return read
24
+ }
25
+
26
+ /** The URL's parameters of a request, within `MAX_URL_LENGTH`. */
27
+ export function urlParams(request: Request): URLSearchParams {
28
+ if (request.url.length > MAX_URL_LENGTH) {
29
+ throw new HttpError(414, 'url_too_long', `The URL is longer than ${MAX_URL_LENGTH} characters`)
30
+ }
31
+ return new URL(request.url).searchParams
32
+ }
33
+
34
+ /** A parameter's value as `shape` reads it; a value no alternative spells canonically stays text, for the schema to refuse. */
35
+ function scalarOf(shape: SchemaShape, value: string): unknown {
36
+ switch (shape.type) {
37
+ case 'optional':
38
+ return scalarOf(shape.inner, value)
39
+ case 'nullable':
40
+ return value === 'null' && !readsText(shape.inner) ? null : scalarOf(shape.inner, value)
41
+ case 'number':
42
+ return canonicalNumber(value) ?? value
43
+ case 'boolean':
44
+ return value === 'true' ? true : value === 'false' ? false : value
45
+ case 'null':
46
+ return value === 'null' ? null : value
47
+ case 'literal':
48
+ return typeof shape.value === 'string' ? value : scalarOf(literalShape(shape.value), value)
49
+ case 'union':
50
+ for (const option of shape.options) {
51
+ const read = scalarOf(option, value)
52
+ if (read !== value || readsText(option)) return read
53
+ }
54
+ return value
55
+ default:
56
+ return value
57
+ }
58
+ }
59
+
60
+ function literalShape(value: number | boolean | null): SchemaShape {
61
+ if (value === null) return { type: 'null' }
62
+ if (typeof value === 'boolean') return { type: 'boolean' }
63
+ return { type: 'number', int: false, min: null, exclusiveMin: false, max: null, exclusiveMax: false, multipleOf: null }
64
+ }
65
+
66
+ /** Whether the shape reads text as itself, so `null` and `true` stay strings there. */
67
+ function readsText(shape: SchemaShape): boolean {
68
+ switch (shape.type) {
69
+ case 'string':
70
+ case 'enum':
71
+ return true
72
+ case 'literal':
73
+ return typeof shape.value === 'string'
74
+ case 'optional':
75
+ case 'nullable':
76
+ return readsText(shape.inner)
77
+ case 'union':
78
+ return shape.options.some(readsText)
79
+ default:
80
+ return false
81
+ }
82
+ }
83
+
84
+ /** `value` as a number when it is that number's canonical spelling (`1.5`, `-3`, never `01`, `1e3` or `+2`). */
85
+ function canonicalNumber(value: string): number | null {
86
+ const number = Number(value)
87
+ return value !== '' && Number.isFinite(number) && String(number) === value ? number : null
88
+ }
@@ -35,6 +35,13 @@ export interface AppPrincipal<Id extends string = string> {
35
35
 
36
36
  export type Principal = UserPrincipal | ServicePrincipal | LinkHolder | AppPrincipal
37
37
 
38
+ /** Whoever called a public procedure: nobody the platform knows. The handler runs as the endpoint's own workload. */
39
+ export interface PublicPrincipal {
40
+ readonly kind: 'public'
41
+ /** The endpoint id, which is the workload's name: `<mount>.<group>.<procedure>`. */
42
+ readonly endpoint: string
43
+ }
44
+
38
45
  export type PrincipalOf<C> = C extends 'human' ? UserPrincipal
39
46
  : C extends 'machine' ? ServicePrincipal
40
47
  : C extends 'link' ? LinkHolder