@fabrika/app 0.6.0 → 0.8.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 (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -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 +450 -255
  10. package/skill/references/spa.md +6 -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 +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  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 +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -3,34 +3,37 @@
3
3
 
4
4
  import {
5
5
  type AccessForm,
6
+ type AdmissionRate,
6
7
  assertNoAmbiguity,
7
8
  type AttachmentFormat,
8
9
  type BodyAdmission,
9
10
  BUILTIN_ROLE_KEYS,
10
11
  type Caller,
11
12
  canonicalizeArtifact,
13
+ type CapabilityAccessForm,
14
+ type CapabilityRequirement,
12
15
  type ChallengeProvider,
16
+ CONTRACT_FORMAT,
13
17
  type ContractArtifact,
14
- type CrossOriginForm,
15
18
  type CspExtensions,
16
19
  type CspSource,
17
20
  type DeclaredReach,
18
21
  type DimensionArtifact,
19
22
  directoryReach,
20
23
  displayNameProblem,
24
+ type EndpointCache,
21
25
  type EndpointDirectory,
22
26
  type EndpointRequirement,
23
27
  type EndpointRow,
24
28
  type EndpointSource,
25
29
  type ExportArtifact,
26
- type FileCollectionArtifact,
27
- type FormChallenge,
28
- type FormFileArtifact,
29
- type FormMountArtifact,
30
- type FormRate,
30
+ type FileKindArtifact,
31
+ type FileShape,
32
+ FORM_URLENCODED_TYPE,
31
33
  type ImportArtifact,
32
34
  isLocale,
33
35
  isMessageKind,
36
+ isSchemaOutput,
34
37
  JOB_MAILERS,
35
38
  JOB_SITE_PREFIX,
36
39
  type JobArtifact,
@@ -40,29 +43,35 @@ import {
40
43
  type MailTemplate,
41
44
  mailTemplateProblems,
42
45
  type MailTemplates,
46
+ MAX_CACHE_AGE_SECONDS,
43
47
  MESSAGE_KINDS,
44
48
  type MessageKind,
45
49
  type MountArtifact,
46
50
  type OutputShape,
51
+ type ProcedureArtifact,
52
+ type ProcedureChallenge,
47
53
  type RoleArtifact,
48
54
  type SchemaShape,
49
55
  type ScopeBinding,
56
+ tableActions,
50
57
  type TablePolicyArtifact,
58
+ type TokenPurposeArtifact,
59
+ type WebhookScheme,
51
60
  } from '@fabrika/auth-core'
52
- import type { InboxColumns } from '@fabrika/db/tables'
53
- import { isPolicyOrigin, MAX_CSP_ORIGINS } from '@fabrika/proxy-core/headers'
61
+ import { isPolicyOrigin, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
62
+ import { z } from 'zod/v4'
54
63
  import { contractArtifactSchema } from './artifact-schema'
55
64
  import {
56
65
  type AccessDeclaration,
57
66
  type Builder,
58
67
  BUILDER_FIELDS,
68
+ type CapabilityDeclaration,
59
69
  type ContractDeclaration,
60
70
  type CronDeclaration,
61
71
  type CspDeclaration,
62
72
  DECLARED_FIELDS,
63
73
  type ExportDeclaration,
64
- type FilesDeclaration,
65
- type FormMountDeclaration,
74
+ type FileKindDeclaration,
66
75
  type ImportDeclaration,
67
76
  isProcedure,
68
77
  type LinkAccessDeclaration,
@@ -73,30 +82,40 @@ import {
73
82
  type SpaAccessDeclaration,
74
83
  } from './declaration'
75
84
  import {
85
+ ALTCHA_LIMITS,
86
+ altchaSolvable,
87
+ altchaWorstSolveMs,
76
88
  ATTACHMENT_LIMITS,
89
+ CHALLENGE_ACTION,
90
+ CHALLENGE_FIELD,
91
+ CHALLENGE_ISSUE_RATE,
77
92
  CHALLENGE_ORIGINS,
93
+ CHALLENGE_PREFIX,
94
+ challengeAction,
95
+ challengeIssueBucket,
96
+ challengePath,
97
+ challengeRowId,
78
98
  CONNECT_LIMITS,
79
99
  CONNECT_PREFIX,
80
100
  CONTRACT_DEFAULTS,
101
+ declaredChallengeAction,
81
102
  exportBase,
82
103
  FILE_LIMITS,
83
- fileCollectionPaths,
84
- FILES_PREFIX,
85
- FORM_CHALLENGE_FIELD,
86
- FORM_FILE_LIMITS,
87
- FORM_INPUT_PART,
88
- FORM_LIMITS,
89
104
  FORM_MULTIPART_TYPE,
105
+ JOB_LIMITS,
90
106
  joinPath,
91
107
  JSON_ADMISSION,
92
108
  LINK_API_SEGMENT,
109
+ PROCEDURE_LIMITS,
110
+ procedurePath,
111
+ PUBLIC_MUTATION_RATE,
93
112
  spaPrefixRowId,
94
- WEBHOOK_ACCOUNT_PATTERN,
95
113
  WEBHOOK_BINDING_PARAMETER,
96
- WEBHOOK_SCOPE_FIELD,
97
- WEBHOOKS_PREFIX,
114
+ WEBHOOK_LIMITS,
98
115
  } from './defaults'
99
116
  import { ContractError, Problems } from './problems'
117
+ import { TOKEN_MAX_DAYS } from './token'
118
+ import { hmacSchemeSchema, WEBHOOK_SCHEME_ALLOWED, webhookSchemeSchema } from './webhook-scheme'
100
119
 
101
120
  const APP_ID = /^[a-z0-9][a-z0-9-]*$/
102
121
  const NAME = /^[A-Za-z][A-Za-z0-9_]*$/
@@ -106,7 +125,6 @@ const ACTION = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/
106
125
  const LITERAL_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
107
126
  const MIME_TYPE = /^[a-z]+\/[a-z0-9][a-z0-9.+-]*$/
108
127
  const CRON_FIELD = /^[0-9*/,-]+$/
109
- const TIMEOUT = /^[1-9][0-9]*[smh]$/
110
128
  const DIGEST = /^sha256:[0-9a-f]+$/
111
129
  const FRAMEWORK_PREFIX = '/__fabrika'
112
130
  /** A fixed name a header carries as it is: no path, no quoting, no extension but the format's. */
@@ -114,97 +132,27 @@ const ATTACHMENT_FILENAMES: Readonly<Record<AttachmentFormat, RegExp>> = {
114
132
  csv: /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.csv$/,
115
133
  xlsx: /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.xlsx$/,
116
134
  }
117
- /** The `boundTo` and job `scope` value for the whole application; no dimension may take the name. */
135
+ /** The `boundTo` value for the whole application; no dimension may take the name. */
118
136
  const APP_SCOPE = 'app'
119
137
  const AUDIENCE = 'any consumer user'
120
138
  const IMPORT_FIELDS = ['app', 'export', 'version', 'digest', 'procedures', 'usedBy', 'timeoutMs']
139
+ const TURNSTILE_CHALLENGE_FIELDS = ['provider', 'secret', 'siteKey', 'action']
140
+ const ALTCHA_CHALLENGE_FIELDS = ['provider', 'secret', 'action', 'cost', 'maxAgeMs']
121
141
  /** Tables the framework keeps in the application's schema; no site reaches them unscoped. */
122
142
  const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
143
+ /** The most origins one site calls: a site that reaches more is an integration hub, which a connector import serves. */
144
+ const MAX_EGRESS_ORIGINS = 16
123
145
  const DECLARED_ALLOWED =
124
- "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', then: '<cron job>', mailer: 'installation' | 'own'"
146
+ "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', mailer: 'installation' | 'own', kicks: ['<cron job>'], binds: '<webhook>', signs: ['<token purpose>'], egress: ['https://<host>']"
125
147
  const CSP_FIELDS = ['connectSrc', 'imgSrc', 'frameAncestors', 'scriptSrc', 'frameSrc', 'styleSrc', 'fontSrc']
126
- /** An environment variable an application reads; a form's challenge names its secret by one. */
148
+ /** An environment variable an application reads; a challenge names its secret by one. */
127
149
  const APPLICATION_VARIABLE = /^[A-Z][A-Z0-9_]*$/
128
- /** A form's file field names the inbox column its file's id lands in. */
129
- const FORM_FILE_FIELD = /^[a-z_][a-z0-9_]*$/
130
- /** Turnstile's `action`: the form's name, which the verification expects back. */
131
- const CHALLENGE_ACTION = /^[A-Za-z0-9_-]{1,32}$/
132
-
133
- const UUID: SchemaShape = {
134
- type: 'string',
135
- format: 'uuid',
136
- minLength: null,
137
- maxLength: null,
138
- pattern: null,
139
- startsWith: null,
140
- endsWith: null,
141
- includes: null,
142
- }
143
- const ID_OBJECT: SchemaShape = { type: 'object', fields: { id: UUID } }
144
- const TEXT: SchemaShape = { ...UUID, format: null, minLength: 1 }
145
-
146
- const COUNT: SchemaShape = { type: 'number', int: true, min: 0, exclusiveMin: false, max: null, exclusiveMax: false, multipleOf: null }
147
-
148
- /** The shapes of a collection's generated list query; `src/files/collections.ts` parses with the same schemas. */
149
- export const FILE_LIST_INPUT: SchemaShape = {
150
- type: 'object',
151
- fields: {
152
- owner: TEXT,
153
- after: { type: 'optional', inner: UUID },
154
- limit: { type: 'optional', inner: { ...COUNT, min: 1, max: FILE_LIMITS.listPageMax } },
155
- },
156
- }
157
- /** A link holder's list names no owner: the owner is the link's object. */
158
- export const FILE_LINK_LIST_INPUT: SchemaShape = {
159
- type: 'object',
160
- fields: {
161
- after: { type: 'optional', inner: UUID },
162
- limit: { type: 'optional', inner: { ...COUNT, min: 1, max: FILE_LIMITS.listPageMax } },
163
- },
164
- }
165
- export const FILE_LIST_OUTPUT: SchemaShape = {
166
- type: 'object',
167
- fields: {
168
- items: {
169
- type: 'array',
170
- minItems: null,
171
- maxItems: FILE_LIMITS.listPageMax,
172
- items: {
173
- type: 'object',
174
- fields: {
175
- id: UUID,
176
- name: { type: 'nullable', inner: TEXT },
177
- type: TEXT,
178
- size: COUNT,
179
- status: { type: 'enum', values: ['live', 'quarantined', 'rejected'] },
180
- createdAt: { ...UUID, format: 'datetime' },
181
- },
182
- },
183
- },
184
- next: { type: 'nullable', inner: UUID },
185
- },
186
- }
150
+ /** Turnstile's `action`: the procedure's endpoint id, which the verification expects back. */
187
151
 
188
- /** The shapes of the generated webhook binding endpoints; `@fabrika/app/implement` parses with the same schemas. */
189
- export const WEBHOOK_BIND_INPUT: SchemaShape = {
190
- type: 'object',
191
- fields: { [WEBHOOK_SCOPE_FIELD]: TEXT, account: { ...UUID, format: null, pattern: WEBHOOK_ACCOUNT_PATTERN } },
192
- }
193
- export const WEBHOOK_BIND_OUTPUT: SchemaShape = { type: 'object', fields: { id: UUID, url: TEXT, secret: TEXT } }
194
- export const WEBHOOK_UNBIND_INPUT: SchemaShape = { type: 'object', fields: { [WEBHOOK_SCOPE_FIELD]: TEXT, id: UUID } }
195
- export const WEBHOOK_UNBIND_OUTPUT: SchemaShape = ID_OBJECT
196
-
197
- /**
198
- * Validates a described contract against its table policies and normalizes it; throws `ContractError` with every
199
- * problem. A form's fields are checked against its inbox's columns only where `inboxes` names them.
200
- */
201
- export function buildArtifact(
202
- contract: ContractDeclaration<SchemaShape>,
203
- tables: Readonly<Record<string, TablePolicyArtifact>>,
204
- inboxes: InboxColumns = {},
205
- ): ContractArtifact {
152
+ /** Validates a described contract against its table policies and normalizes it; throws `ContractError` with every problem. */
153
+ export function buildArtifact(contract: ContractDeclaration<SchemaShape>, tables: Readonly<Record<string, TablePolicyArtifact>>): ContractArtifact {
206
154
  const problems = new Problems()
207
- const artifact = canonicalizeArtifact(new ArtifactBuilder(contract, tables, inboxes, problems).build())
155
+ const artifact = canonicalizeArtifact(new ArtifactBuilder(contract, tables, problems).build())
208
156
  problems.throwIfAny()
209
157
  // A check missing above must surface here, not in the proxy or Control that reads the artifact later.
210
158
  const parsed = contractArtifactSchema.safeParse(JSON.parse(JSON.stringify(artifact)))
@@ -217,29 +165,32 @@ export function buildArtifact(
217
165
  class ArtifactBuilder {
218
166
  readonly #contract: ContractDeclaration<SchemaShape>
219
167
  readonly #tables: Readonly<Record<string, TablePolicyArtifact>>
220
- readonly #inboxes: InboxColumns
221
168
  readonly #problems: Problems
222
169
  readonly #endpoints: EndpointRow[] = []
223
170
  readonly #reservedPrefixes: string[] = []
224
171
  readonly #declared: Record<string, DeclaredReach> = {}
172
+ /** Each procedure of the staged kit's rules, by endpoint id. */
173
+ readonly #procedureRules: Record<string, ProcedureArtifact> = {}
225
174
  /** Whether any site is the staged kit's, whose reach is only what it declares; `defineContract`'s sites are not. */
226
175
  #kit = false
227
176
  /** The endpoints that declare `connect`, by the import they name. */
228
177
  readonly #connected = new Map<string, string[]>()
229
- /** The forms that declare a challenge: their provider's fixed origins join the CSP, and their secret stays private. */
230
- readonly #challenges: { readonly form: string; readonly provider: ChallengeProvider; readonly secret: string }[] = []
178
+ /** The procedures that declare a challenge: their provider's fixed origins join the CSP, and their secret stays private. */
179
+ readonly #challenges: { readonly site: string; readonly provider: ChallengeProvider; readonly secret: string; readonly action: string }[] = []
180
+ /** The public procedures served at a path of their own (`http.path`), checked against every mount once all are read. */
181
+ readonly #ownPaths: { readonly where: string; readonly path: string }[] = []
231
182
  /** Each variable a browser config serves, and where the contract names it. */
232
183
  readonly #publicVariables: { readonly where: string; readonly field: string; readonly name: string }[] = []
184
+ /** The file kinds the contract declares, by name: read before the mounts, whose file fields and outputs name them. */
185
+ #fileKinds: Record<string, FileKindArtifact> = {}
233
186
 
234
187
  constructor(
235
188
  contract: ContractDeclaration<SchemaShape>,
236
189
  tables: Readonly<Record<string, TablePolicyArtifact>>,
237
- inboxes: InboxColumns,
238
190
  problems: Problems,
239
191
  ) {
240
192
  this.#contract = contract
241
193
  this.#tables = tables
242
- this.#inboxes = inboxes
243
194
  this.#problems = problems
244
195
  }
245
196
 
@@ -251,6 +202,8 @@ class ArtifactBuilder {
251
202
  const actions = this.#actions()
252
203
  const dimensions = this.#dimensions()
253
204
  const roles = this.#roles()
205
+ const tokens = this.#tokens()
206
+ this.#fileKinds = this.#readFileKinds()
254
207
  const mounts: Record<string, MountArtifact> = {}
255
208
  for (const [name, mount] of Object.entries(contract.mounts)) {
256
209
  if (this.#name(`mount '${name}'`, name)) {
@@ -261,26 +214,35 @@ class ArtifactBuilder {
261
214
  }
262
215
  }
263
216
  this.#checkPublicVariables()
264
- const files = this.#files(dimensions)
217
+ this.#checkOwnPaths()
218
+ for (const row of this.#endpoints) {
219
+ if (row.kind === 'webhook' && row.input !== null && containsFile(row.input)) {
220
+ this.#problem(`mount '${row.id}'`, "takes a file in its input, which only a procedure's top-level field does (file('<kind>'))")
221
+ }
222
+ }
265
223
  const jobs = this.#jobs()
266
224
  const exports = this.#exports()
267
225
  const imports = this.#imports()
268
226
  const csp = this.#csp(contract.csp)
269
227
  const tables = this.#checkTables()
270
- this.#reserve(this.#endpoints.some((row) => row.source.kind === 'files') ? FILES_PREFIX : null)
271
228
  this.#reserve(this.#endpoints.some((row) => row.source.kind === 'export') ? CONNECT_PREFIX : null)
229
+ // An unknown challenge path is a 404, never the root SPA's page.
230
+ this.#reserve(this.#endpoints.some((row) => row.source.kind === 'challenge') ? CHALLENGE_PREFIX : null)
231
+ this.#checkKickCycles()
272
232
  const directories = this.#directories(dimensions)
273
233
  const locale = this.#locale()
274
234
  const displayName = this.#displayName()
275
235
  const mail = this.#mail()
276
236
  const people = this.#people()
277
237
  const artifact: ContractArtifact = {
238
+ format: CONTRACT_FORMAT,
278
239
  app: contract.app,
279
240
  dimensions,
280
241
  actions,
281
242
  roles,
282
243
  mounts,
283
- files,
244
+ files: {},
245
+ ...(Object.keys(this.#fileKinds).length === 0 ? {} : { fileKinds: this.#fileKinds }),
284
246
  jobs,
285
247
  exports,
286
248
  imports,
@@ -294,6 +256,8 @@ class ArtifactBuilder {
294
256
  ...(displayName === null ? {} : { displayName }),
295
257
  ...(mail === null ? {} : { mail }),
296
258
  ...people,
259
+ ...(Object.keys(this.#procedureRules).length === 0 ? {} : { procedures: this.#procedureRules }),
260
+ ...(Object.keys(tokens).length === 0 ? {} : { tokens }),
297
261
  }
298
262
  try {
299
263
  assertNoAmbiguity(artifact)
@@ -498,10 +462,17 @@ class ArtifactBuilder {
498
462
  this.#reserve(mount.prefix)
499
463
  for (const [path, procedure] of procedures(mount.procedures)) {
500
464
  const id = `${name}.${path}`
501
- this.#procedure({ kind: 'mount', name }, id, joinPath(mount.prefix, path), mount.callers, procedure)
502
- this.#declare(`procedure '${id}'`, id, procedure.declared, { kind: 'rpc', readOnly: procedure.type === 'query', access: procedure.access })
465
+ const callers = nonEmpty(mount.callers)
466
+ const actions = this.#procedure({ kind: 'mount', name }, id, procedurePath(mount.prefix, path, procedure.http), callers, procedure)
467
+ this.#declare(`procedure '${id}'`, id, procedure.declared, {
468
+ kind: 'rpc',
469
+ readOnly: procedure.type === 'query',
470
+ access: procedure.access,
471
+ actions,
472
+ })
503
473
  }
504
- return { kind: 'rpc', prefix: mount.prefix, callers: mount.callers }
474
+ const compatibility = this.#compatibility(where, mount.compatibility)
475
+ return { kind: 'rpc', prefix: mount.prefix, callers: [...mount.callers], ...(compatibility === 'backward' ? { compatibility } : {}) }
505
476
  }
506
477
  case 'link':
507
478
  return this.#link(name, mount)
@@ -523,245 +494,167 @@ class ArtifactBuilder {
523
494
  this.#row({ id, source, kind: 'spa', method: 'GET', path, callers: ['human'], requirement, admission: null, input: null, output: null })
524
495
  }
525
496
  }
526
- const publicVariables = this.#spaPublic(where, mount.public)
497
+ const publicVariables = this.#browserConfig(where, mount.browserConfig)
527
498
  return { kind: 'spa', prefix: mount.prefix, dir: mount.dir, ...(publicVariables.length === 0 ? {} : { public: publicVariables }) }
528
499
  }
529
500
  case 'health':
530
501
  this.#prefix(where, mount.path)
531
502
  this.#anonymous(name, 'health', 'GET', mount.path, null, null)
532
503
  return { kind: 'health', path: mount.path }
533
- case 'form': {
534
- this.#prefix(where, mount.path)
535
- this.#objectSchema(`${where} input`, mount.input)
536
- this.#nonEmpty(`${where} appendTo`, mount.appendTo)
537
- const files = mount.files === undefined ? null : this.#formFiles(where, mount, mount.files)
538
- this.#formByteBudget(where, mount, files)
539
- const limit = this.#bodyLimit(where, mount.maxBytes, CONTRACT_DEFAULTS.formMaxBytes)
540
- // A form that takes files is multipart: `maxBytes` bounds its input part, each collection its file. While no
541
- // file is required it also takes the JSON body it took before, so its existing pages keep working.
542
- const optional = files !== null && Object.values(files).every((file) => !file.required)
543
- const admission: BodyAdmission = files === null
544
- ? limit
545
- : { contentTypes: optional ? ['application/json', FORM_MULTIPART_TYPE] : [FORM_MULTIPART_TYPE], maxBytes: limit.maxBytes }
546
- this.#anonymous(name, 'form', 'POST', mount.path, admission, mount.input, this.#crossOrigin(where, mount.crossOrigin, true))
547
- return {
548
- kind: 'form',
549
- path: mount.path,
550
- appendTo: mount.appendTo,
551
- ...this.#formAdmission(name, where, mount),
552
- ...(mount.then === undefined ? {} : { then: this.#kickedJob(where, mount.then) }),
553
- ...(files === null ? {} : { files }),
554
- }
555
- }
556
504
  case 'webhook': {
557
505
  this.#prefix(where, mount.path)
558
506
  this.#objectSchema(`${where} input`, mount.input)
559
507
  const boundTo = this.#scopeBinding(where, 'boundTo', mount.boundTo)
560
- const members = boundTo?.kind === 'dimension' ? this.#contract.dimensions[boundTo.dimension]?.members : undefined
561
- if (boundTo?.kind === 'dimension' && members === undefined) {
562
- this.#problem(where, `is bound to '${boundTo.dimension}', which declares no members: { manage, list } to create bindings with`)
563
- }
564
508
  this.#actionList(where, mount.actions)
565
509
  const path = joinPath(mount.path, `:${WEBHOOK_BINDING_PARAMETER}`)
566
- this.#anonymous(name, 'webhook', 'POST', path, this.#bodyLimit(where, mount.maxBytes, CONTRACT_DEFAULTS.webhookMaxBytes), mount.input)
510
+ const limits = this.#limits(where, mount.limits, WEBHOOK_LIMITS, null, WEBHOOK_LIMITS.maxMaxBytes)
511
+ const scheme = this.#webhookScheme(where, mount.scheme)
512
+ // A verifier that compares `Authorization` with the binding's secret needs the proxy to pass it on.
513
+ const forwards = scheme !== null && typeof scheme === 'object' && !('hmac' in scheme)
514
+ // Slack posts slash commands and Twilio its callbacks as forms; every other provider posts JSON.
515
+ const forms = scheme === 'slack' || scheme === 'twilio'
516
+ this.#anonymous(
517
+ name,
518
+ 'webhook',
519
+ 'POST',
520
+ path,
521
+ { contentTypes: forms ? ['application/json', FORM_URLENCODED_TYPE] : ['application/json'], maxBytes: limits.maxBytes },
522
+ mount.input,
523
+ forwards ? { authorization: 'forward' } : {},
524
+ )
567
525
  this.#declare(where, name, mount.declared, { kind: 'webhook', readOnly: false })
568
- if (boundTo?.kind === 'dimension' && members !== undefined) {
569
- this.#webhookBindings(name, boundTo.dimension, members.manage)
570
- }
571
- return boundTo === null ? null : { kind: 'webhook', path: mount.path, scheme: mount.scheme, boundTo, actions: [...mount.actions] }
526
+ return boundTo === null || scheme === null
527
+ ? null
528
+ : { kind: 'webhook', path: mount.path, scheme, boundTo, actions: [...mount.actions], timeoutMs: limits.timeoutMs }
572
529
  }
573
530
  default:
574
- this.#problem(where, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health, c.form or c.webhook)')
531
+ this.#problem(where, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health or app.webhook)')
575
532
  return null
576
533
  }
577
534
  }
578
535
 
579
- /** The job a form or a mutation kicks: a cron job of this contract. */
580
- #kickedJob(where: string, then: string): string {
536
+ /** The job a mutation, a webhook or a job kicks: a cron job of this contract. */
537
+ #kickedJob(where: string, name: string): string {
581
538
  const jobs = this.#contract.jobs
582
- const job: { readonly kind: string } | undefined = Object.hasOwn(jobs, then) ? jobs[then] : undefined
539
+ const job: { readonly kind: string } | undefined = Object.hasOwn(jobs, name) ? jobs[name] : undefined
583
540
  if (job === undefined) {
584
541
  const names = Object.keys(jobs)
585
542
  this.#problem(
586
543
  where,
587
- `then '${then}' is not a job of the contract (jobs: ${names.length === 0 ? 'none' : names.join(', ')})${suggestion(then, names)}`,
544
+ `kicks '${name}' is not a job of the contract (jobs: ${names.length === 0 ? 'none' : names.join(', ')})${suggestion(name, names)}`,
588
545
  )
589
546
  } else if (job.kind !== 'cron') {
590
- this.#problem(where, `then '${then}' is not a cron job (allowed: a job c.cron declares)`)
591
- }
592
- return then
593
- }
594
-
595
- /** A form's declared rate, honeypot and challenge, each present in the artifact only when declared. */
596
- #formAdmission(name: string, where: string, mount: FormMountDeclaration<SchemaShape>): Pick<FormMountArtifact, 'rate' | 'honeypot' | 'challenge'> {
597
- const fields = mount.input.type === 'object' ? mount.input.fields : {}
598
- const admission: { rate?: FormRate; honeypot?: string; challenge?: FormChallenge } = {}
599
- if (mount.rate !== undefined) {
600
- admission.rate = this.#formRate(`${where} rate`, mount.rate)
547
+ this.#problem(where, `kicks '${name}' is not a cron job (allowed: a job c.cron declares)`)
601
548
  }
602
- if (mount.honeypot !== undefined) {
603
- const field = fields[mount.honeypot]
604
- if (field === undefined) {
605
- this.#problem(where, `honeypot '${mount.honeypot}' is not a field of its input (allowed: a z.string() field the form's page hides)`)
606
- } else if (!isStringField(field)) {
607
- this.#problem(where, `honeypot '${mount.honeypot}' is not a string field (allowed: z.string() or z.optional(z.string()))`)
608
- }
609
- if (mount.honeypot === FORM_CHALLENGE_FIELD) {
610
- this.#problem(where, `honeypot '${mount.honeypot}' is the field the challenge token travels in`)
611
- }
612
- admission.honeypot = mount.honeypot
613
- }
614
- if (mount.challenge !== undefined) {
615
- admission.challenge = this.#formChallenge(name, where, mount.challenge, fields)
616
- }
617
- return admission
549
+ return name
618
550
  }
619
551
 
620
552
  /**
621
- * A form's file fields: at most `FORM_FILE_LIMITS.maxFields`, each a lowercase column name that no input field, the
622
- * honeypot, the challenge token or the input part takes, each landing in a declared collection that scans. An
623
- * anonymous visitor's file reaches the people who read the collection, so the form must declare a challenge too.
553
+ * A public procedure's challenge; `id` is its endpoint id, whose widget action is `challengeAction(id)` unless it
554
+ * declares its own `action`.
624
555
  */
625
- #formFiles(where: string, mount: FormMountDeclaration<SchemaShape>, declared: unknown): Readonly<Record<string, FormFileArtifact>> | null {
626
- const example = "files: { photo: { collection: '<a c.files collection with scan: true>' } }"
627
- if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
628
- this.#problem(where, `files is not an object of file fields (allowed: ${example})`)
629
- return null
630
- }
631
- const entries = Object.entries(declared)
632
- if (entries.length === 0 || entries.length > FORM_FILE_LIMITS.maxFields) {
633
- this.#problem(where, `files declares ${entries.length} file fields (allowed: 1 to ${FORM_FILE_LIMITS.maxFields}, one file each)`)
634
- }
635
- if (mount.challenge === undefined) {
636
- this.#problem(where, "takes files, so it declares a challenge: an anonymous upload needs a solved one (challenge: { provider: 'turnstile', … })")
637
- }
638
- const fields = mount.input.type === 'object' ? mount.input.fields : {}
639
- const files: Record<string, FormFileArtifact> = {}
640
- for (const [field, option] of entries) {
641
- const at = `${where} file '${field}'`
642
- if (!FORM_FILE_FIELD.test(field)) {
643
- this.#problem(at, 'is not a column name (allowed: lowercase snake_case, as its inbox column is named)')
644
- }
645
- if (field === FORM_INPUT_PART || field === FORM_CHALLENGE_FIELD || field === mount.honeypot || Object.hasOwn(fields, field)) {
646
- this.#problem(at, `is also the name of the input part, the challenge token, the honeypot or an input field; name the file its own field`)
647
- }
648
- if (typeof option !== 'object' || option === null || Array.isArray(option)) {
649
- this.#problem(at, `is not { collection, required? } (allowed: ${example})`)
650
- continue
651
- }
652
- this.#keys(at, option, ['collection', 'required'])
653
- const collection: unknown = Reflect.get(option, 'collection')
654
- const required: unknown = Reflect.get(option, 'required')
655
- if (required !== undefined && typeof required !== 'boolean') {
656
- this.#problem(at, `required ${JSON.stringify(required)} is not true or false`)
657
- }
658
- const collections = this.#contract.files
659
- const target: { readonly kind: string; readonly scan?: boolean } | undefined =
660
- typeof collection === 'string' && Object.hasOwn(collections, collection) ? collections[collection] : undefined
661
- if (typeof collection !== 'string' || target === undefined || target.kind !== 'files') {
662
- const names = Object.keys(collections)
663
- this.#problem(
664
- at,
665
- `collection ${JSON.stringify(collection)} is not a file collection of the contract (collections: ${
666
- names.length === 0 ? 'none' : names.join(', ')
667
- })`,
668
- )
669
- continue
670
- }
671
- if (target.scan !== true) {
672
- this.#problem(at, `lands in files '${collection}', which does not scan; an anonymous visitor's file waits for a clean verdict (scan: true)`)
673
- }
674
- files[field] = { collection, required: required === true }
675
- }
676
- return files
677
- }
678
-
679
- /** `bytesPerHour` belongs to a form with files, and fits at least the largest file one of its fields admits. */
680
- #formByteBudget(where: string, mount: FormMountDeclaration<SchemaShape>, files: Readonly<Record<string, FormFileArtifact>> | null): void {
681
- const declared: unknown = typeof mount.rate === 'object' && mount.rate !== null ? Reflect.get(mount.rate, 'bytesPerHour') : undefined
682
- if (declared === undefined) {
683
- return
684
- }
685
- if (files === null) {
686
- this.#problem(where, 'declares rate.bytesPerHour and takes no files (allowed: on a form with files)')
687
- return
688
- }
689
- const largest = Math.max(...Object.values(files).map((file) => this.#contract.files[file.collection]?.maxBytes ?? 0))
690
- if (typeof declared === 'number' && declared < largest) {
691
- this.#problem(where, `rate.bytesPerHour ${declared} is less than the ${largest} bytes one of its files may hold`)
692
- }
693
- }
694
-
695
- #formRate(where: string, rate: object): FormRate {
696
- this.#keys(where, rate, ['perClient', 'perForm', 'bytesPerHour'])
697
- const parsed: { perClient?: number; perForm?: number; bytesPerHour?: number } = {}
698
- const bytes: unknown = Reflect.get(rate, 'bytesPerHour')
699
- if (bytes !== undefined) {
700
- if (typeof bytes !== 'number' || !Number.isSafeInteger(bytes) || bytes < 1 || bytes > FORM_FILE_LIMITS.maxBytesPerHour) {
701
- this.#problem(where, `bytesPerHour ${String(bytes)} is not a whole number of bytes from 1 to ${FORM_FILE_LIMITS.maxBytesPerHour}`)
702
- } else {
703
- parsed.bytesPerHour = bytes
704
- }
705
- }
706
- for (const key of ['perClient', 'perForm'] as const) {
707
- const value: unknown = Reflect.get(rate, key)
708
- if (value === undefined) {
709
- continue
710
- }
711
- if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > FORM_LIMITS.maxPerMinute) {
712
- this.#problem(where, `${key} ${String(value)} is not a whole number of submissions per minute from 1 to ${FORM_LIMITS.maxPerMinute}`)
713
- continue
714
- }
715
- parsed[key] = value
716
- }
717
- return parsed
718
- }
719
-
720
- #formChallenge(name: string, where: string, challenge: object, fields: Readonly<Record<string, SchemaShape>>): FormChallenge | undefined {
721
- this.#keys(`${where} challenge`, challenge, ['provider', 'secret', 'siteKey'])
556
+ #challenge(id: string, where: string, challenge: object, fields: Readonly<Record<string, SchemaShape>>): ProcedureChallenge | undefined {
722
557
  const provider: unknown = Reflect.get(challenge, 'provider')
723
- const secret: unknown = Reflect.get(challenge, 'secret')
724
- const siteKey: unknown = Reflect.get(challenge, 'siteKey')
725
- if (provider !== 'turnstile') {
726
- this.#problem(where, `challenge provider ${JSON.stringify(provider)} is not one the framework verifies (allowed: 'turnstile')`)
558
+ if (provider !== 'turnstile' && provider !== 'altcha') {
559
+ this.#problem(where, `challenge provider ${JSON.stringify(provider)} is not one the framework verifies (allowed: 'turnstile', 'altcha')`)
727
560
  return undefined
728
561
  }
562
+ this.#keys(`${where} challenge`, challenge, provider === 'altcha' ? ALTCHA_CHALLENGE_FIELDS : TURNSTILE_CHALLENGE_FIELDS)
563
+ const secret: unknown = Reflect.get(challenge, 'secret')
564
+ const siteKey: unknown = Reflect.get(challenge, 'siteKey')
565
+ const declaredAction: unknown = Reflect.get(challenge, 'action')
729
566
  if (typeof secret !== 'string' || !APPLICATION_VARIABLE.test(secret) || secret.startsWith('FABRIKA_')) {
567
+ const example = provider === 'altcha' ? 'ALTCHA_HMAC_KEY' : 'TURNSTILE_SECRET'
730
568
  this.#problem(
731
569
  where,
732
570
  `challenge secret ${
733
571
  JSON.stringify(secret)
734
- } is not the name of an application variable (allowed: 'TURNSTILE_SECRET'; never the secret itself, never FABRIKA_*)`,
572
+ } is not the name of an application variable (allowed: '${example}'; never the secret itself, never FABRIKA_*)`,
735
573
  )
736
574
  return undefined
737
575
  }
738
- if (!CHALLENGE_ACTION.test(name)) {
739
- this.#problem(where, `a form with a challenge is named as the widget's action, at most 32 letters, digits, '_' or '-'`)
740
- }
741
- const token = fields[FORM_CHALLENGE_FIELD]
576
+ const action = this.#challengeAction(id, where, declaredAction)
577
+ const token = fields[CHALLENGE_FIELD]
742
578
  if (token === undefined || token.type !== 'string') {
743
- this.#problem(where, `declares a challenge, so its input needs the required field ${FORM_CHALLENGE_FIELD}: z.string(), the widget's token`)
579
+ this.#problem(where, `declares a challenge, so its input needs the required field ${CHALLENGE_FIELD}: z.string(), the widget's token`)
580
+ }
581
+ const sharing = this.#challenges.find((other) => other.action === action)
582
+ if (sharing !== undefined) {
583
+ this.#problem(
584
+ where,
585
+ `its challenge action '${action}' is the action of '${sharing.site}' too, so a token solved for one would pass the other (allowed: challenge.action unique to each procedure)`,
586
+ )
587
+ }
588
+ this.#challenges.push({ site: id, provider, secret, action })
589
+ if (provider === 'altcha') {
590
+ const cost = this.#bounded(
591
+ where,
592
+ 'challenge.cost',
593
+ Reflect.get(challenge, 'cost'),
594
+ ALTCHA_LIMITS.cost,
595
+ ALTCHA_LIMITS.maxCost,
596
+ 'iterations',
597
+ ALTCHA_LIMITS.minCost,
598
+ )
599
+ const maxAgeMs = this.#bounded(
600
+ where,
601
+ 'challenge.maxAgeMs',
602
+ Reflect.get(challenge, 'maxAgeMs'),
603
+ ALTCHA_LIMITS.maxAgeMs,
604
+ ALTCHA_LIMITS.maxMaxAgeMs,
605
+ 'ms',
606
+ ALTCHA_LIMITS.minMaxAgeMs,
607
+ )
608
+ if (!altchaSolvable(cost, maxAgeMs)) {
609
+ this.#problem(
610
+ where,
611
+ `its challenge of cost ${cost} takes a slow phone up to ${
612
+ altchaWorstSolveMs(cost)
613
+ } ms, more than the widget's ${ALTCHA_LIMITS.solveTimeoutMs} ms or half of maxAgeMs ${maxAgeMs} (allowed: a lower cost, or a longer maxAgeMs)`,
614
+ )
615
+ }
616
+ return { provider, secret, action, cost, maxAgeMs }
744
617
  }
745
- this.#challenges.push({ form: name, provider, secret })
746
618
  if (siteKey === undefined || !this.#applicationVariable(where, 'challenge siteKey', siteKey, 'TURNSTILE_SITE_KEY')) {
747
- return { provider, secret }
619
+ return { provider, secret, action }
748
620
  }
749
621
  this.#publicVariables.push({ where, field: 'challenge siteKey', name: siteKey })
750
- return { provider, secret, siteKey }
622
+ return { provider, secret, action, siteKey }
623
+ }
624
+
625
+ /** The widget action a challenge's verification expects back: the declared `action`, else the endpoint id with `_` for `.`. */
626
+ #challengeAction(id: string, where: string, declared: unknown): string {
627
+ if (declared !== undefined) {
628
+ if (typeof declared !== 'string' || !CHALLENGE_ACTION.test(declared)) {
629
+ this.#problem(
630
+ where,
631
+ `challenge.action ${JSON.stringify(declared)} is not a widget action (allowed: at most 32 letters, digits, '_' or '-', such as 'register')`,
632
+ )
633
+ }
634
+ return declaredChallengeAction(id, declared)
635
+ }
636
+ const derived = challengeAction(id)
637
+ if (!CHALLENGE_ACTION.test(derived)) {
638
+ this.#problem(
639
+ where,
640
+ `its challenge action is its endpoint id with '_' for '.', '${derived}', longer than the 32 characters the provider admits (allowed: challenge.action, such as 'register')`,
641
+ )
642
+ }
643
+ return derived
751
644
  }
752
645
 
753
646
  /** The variables an SPA mount serves its pages; `[]` when it declares none. */
754
- #spaPublic(where: string, declared: readonly string[] | undefined): readonly string[] {
647
+ #browserConfig(where: string, declared: readonly string[] | undefined): readonly string[] {
755
648
  if (declared === undefined) {
756
649
  return []
757
650
  }
758
651
  if (declared.length === 0) {
759
- this.#problem(where, "public names no variable (allowed: public: ['SIGNUP_URL'], or no public)")
652
+ this.#problem(where, "browserConfig names no variable (allowed: browserConfig: ['SIGNUP_URL'], or no browserConfig)")
760
653
  }
761
- this.#unique(where, 'public', declared)
762
- const names = declared.filter((name) => this.#applicationVariable(where, 'public', name, 'SIGNUP_URL'))
654
+ this.#unique(where, 'browserConfig', declared)
655
+ const names = declared.filter((name) => this.#applicationVariable(where, 'browserConfig', name, 'SIGNUP_URL'))
763
656
  for (const name of names) {
764
- this.#publicVariables.push({ where, field: 'public', name })
657
+ this.#publicVariables.push({ where, field: 'browserConfig', name })
765
658
  }
766
659
  return names
767
660
  }
@@ -783,29 +676,23 @@ class ArtifactBuilder {
783
676
  for (const variable of this.#publicVariables) {
784
677
  const owner = this.#challenges.find((challenge) => challenge.secret === variable.name)
785
678
  if (owner !== undefined) {
786
- this.#problem(variable.where, `${variable.field} '${variable.name}' is the secret of form '${owner.form}'; a secret is never public`)
679
+ this.#problem(variable.where, `${variable.field} '${variable.name}' is the secret of the challenge of '${owner.site}'; a secret is never public`)
787
680
  }
788
681
  }
789
682
  }
790
683
 
791
- /** A holder of the dimension's `members.manage` there binds a provider account to one value, and unbinds it. */
792
- #webhookBindings(name: string, dimension: string, manage: string): void {
793
- const access: AccessForm = { form: 'in', dimension, from: WEBHOOK_SCOPE_FIELD }
794
- const binding = (operation: 'bind' | 'unbind', input: SchemaShape, output: SchemaShape): EndpointRow => ({
795
- id: `webhooks.${name}.${operation}`,
796
- source: { kind: 'mount', name },
797
- kind: 'mutation',
798
- method: 'POST',
799
- path: joinPath(WEBHOOKS_PREFIX, `webhooks.${name}.${operation}`),
800
- callers: ['human'],
801
- requirement: { kind: 'action', action: manage, access },
802
- admission: JSON_ADMISSION,
803
- input,
804
- output,
805
- })
806
- this.#row(binding('bind', WEBHOOK_BIND_INPUT, WEBHOOK_BIND_OUTPUT))
807
- this.#row(binding('unbind', WEBHOOK_UNBIND_INPUT, WEBHOOK_UNBIND_OUTPUT))
808
- this.#reserve(WEBHOOKS_PREFIX)
684
+ /** The webhook's verifier: a preset, or a framework-owned shape the runtime computes. */
685
+ #webhookScheme(where: string, value: unknown): WebhookScheme | null {
686
+ const parsed = z.safeParse(webhookSchemeSchema, value)
687
+ if (parsed.success) {
688
+ return parsed.data
689
+ }
690
+ // A union names no branch; an `hmac` shape is refused for its own first issue.
691
+ const shape = isRecord(value) && Object.hasOwn(value, 'hmac') ? z.safeParse(hmacSchemeSchema, value['hmac']) : null
692
+ const issue = shape?.success === false ? shape.error.issues[0] : undefined
693
+ const detail = issue === undefined ? '' : ` (hmac${issue.path.map((key) => `.${String(key)}`).join('')}: ${issue.message})`
694
+ this.#problem(where, `scheme ${JSON.stringify(value)} is not a verifier${detail} (allowed: ${WEBHOOK_SCHEME_ALLOWED})`)
695
+ return null
809
696
  }
810
697
 
811
698
  #link(name: string, mount: LinkMountDeclaration<SchemaShape>): MountArtifact {
@@ -840,18 +727,20 @@ class ArtifactBuilder {
840
727
  const id = `${name}.${path}`
841
728
  const requirement = this.#linkRequirement(`procedure '${id}'`, procedure.access, mount)
842
729
  this.#procedureSchemas(`procedure '${id}'`, procedure)
843
- this.#noCrossOrigin(`procedure '${id}'`, procedure.crossOrigin, 'a link holder')
730
+ if (procedure.actions !== undefined) {
731
+ this.#problem(`procedure '${id}'`, 'declares actions, which only a public procedure holds; a link procedure holds its link grant')
732
+ }
733
+ const rules = this.#rules(id, procedure, 'link')
844
734
  this.#declare(`procedure '${id}'`, id, procedure.declared, { kind: 'link', readOnly: procedure.type === 'query' })
845
735
  if (requirement !== null) {
846
736
  this.#row({
847
737
  id,
848
738
  source: { kind: 'mount', name },
849
739
  kind: procedure.type,
850
- method: 'POST',
851
740
  path: joinPath(api, path),
852
741
  callers: ['link'],
853
742
  requirement,
854
- admission: JSON_ADMISSION,
743
+ ...rowWire(rules),
855
744
  input: procedure.input,
856
745
  output: this.#output(`procedure '${id}'`, procedure),
857
746
  })
@@ -868,61 +757,558 @@ class ArtifactBuilder {
868
757
  }
869
758
  }
870
759
 
760
+ /**
761
+ * A procedure's row and its rules; a public procedure's workload actions, which `#declare` records, or `null`.
762
+ * `callers` is `null` on an RPC mount whose groups are all public.
763
+ */
871
764
  #procedure(
872
765
  source: EndpointSource,
873
766
  id: string,
874
767
  path: string,
875
- callers: readonly [Caller, ...Caller[]],
768
+ callers: readonly [Caller, ...Caller[]] | null,
876
769
  procedure: ProcedureDeclaration<SchemaShape>,
877
- ): void {
770
+ ): readonly string[] | null {
878
771
  const where = `procedure '${id}'`
879
772
  this.#procedureSchemas(where, procedure)
773
+ if (isPublicAccess(procedure.access)) {
774
+ if (source.kind === 'mount') {
775
+ return this.#publicProcedure(source, id, path, procedure)
776
+ }
777
+ this.#problem(where, 'is public, which only a procedure of an RPC mount is (allowed: an action and its scope)')
778
+ return null
779
+ }
780
+ if (procedure.actions !== undefined) {
781
+ this.#problem(where, 'declares actions, which only a public procedure holds (its handler runs as its own workload); remove them')
782
+ }
783
+ if (source.kind === 'export' && (procedure.redirect !== undefined || procedure.http !== undefined)) {
784
+ this.#problem(where, 'is an export procedure, which answers JSON to a POST: it declares no http and no redirect()')
785
+ }
786
+ if (source.kind === 'export' && (procedure.output.type === 'file' || (procedure.input !== null && containsFile(procedure.input)))) {
787
+ this.#problem(where, 'is an export procedure, which carries JSON both ways: it takes no file and answers none')
788
+ }
880
789
  const requirement = this.#requirement(where, procedure)
881
- const crossOrigin = source.kind === 'mount'
882
- ? this.#crossOrigin(where, procedure.crossOrigin, false)
883
- : this.#noCrossOrigin(where, procedure.crossOrigin, 'a consuming application, from its server')
790
+ const rules = this.#rules(id, procedure, 'credentialed')
791
+ if (source.kind === 'export' && rules !== null && rules.origins !== 'same-origin') {
792
+ this.#problem(where, "is called by a consuming application, from its server, never cross-origin (allowed: admission.origins: 'same-origin')")
793
+ }
794
+ const crossOrigin = source.kind === 'mount' && rules?.origins === 'listed' ? { crossOrigin: 'environment' as const } : {}
795
+ if (callers === null) {
796
+ this.#problem(
797
+ where,
798
+ "is credentialed, and its group names no callers: a group without callers holds public procedures only (app.group({ callers: ['human'] }, …))",
799
+ )
800
+ return null
801
+ }
884
802
  if (requirement !== null) {
885
803
  this.#row({
886
804
  id,
887
805
  source,
888
806
  kind: procedure.type,
889
- method: 'POST',
890
807
  path,
891
808
  callers,
892
809
  requirement,
893
- admission: JSON_ADMISSION,
810
+ ...rowWire(rules),
894
811
  input: procedure.input,
895
812
  output: this.#output(where, procedure),
896
813
  ...crossOrigin,
897
814
  })
898
815
  }
816
+ return null
817
+ }
818
+
819
+ /**
820
+ * A procedure anyone calls (`access: { public: true }`): its own row mode, no callers and no requirement, and its
821
+ * handler holding its top-level `actions` as the endpoint's own workload. It answers the default admission of a public
822
+ * procedure unless it declares its own.
823
+ */
824
+ #publicProcedure(source: EndpointSource, id: string, path: string, procedure: ProcedureDeclaration<SchemaShape>): readonly string[] | null {
825
+ const where = `procedure '${id}'`
826
+ if (!hasExactKeys(procedure.access, ['public'])) {
827
+ this.#problem(where, `access ${JSON.stringify(procedure.access)} is not the public form (allowed: { public: true }, with actions beside it)`)
828
+ }
829
+ const actions = this.#workloadActions(where, procedure.actions)
830
+ const rules = this.#rules(id, procedure, 'public')
831
+ if (rules === null) {
832
+ // A procedure of the staged kit always carries `declared`; one without it is no kit procedure.
833
+ this.#problem(where, 'is public, which only a procedure of the staged kit (app.group) is')
834
+ return null
835
+ }
836
+ const origins = rules.origins === 'any'
837
+ ? { crossOrigin: 'any' as const }
838
+ : rules.origins === 'listed'
839
+ ? { crossOrigin: 'environment' as const }
840
+ : {}
841
+ this.#row({
842
+ id,
843
+ source,
844
+ kind: 'public',
845
+ path,
846
+ callers: [],
847
+ requirement: null,
848
+ ...rowWire(rules),
849
+ input: procedure.input,
850
+ output: this.#output(where, procedure),
851
+ ...origins,
852
+ })
853
+ const procedureRules = this.#procedureRules[id]
854
+ if (procedureRules?.challenge?.provider === 'altcha') {
855
+ this.#challengeIssuer(id, procedureRules.challenge.action, procedureRules.rate, origins)
856
+ }
857
+ return actions
858
+ }
859
+
860
+ /**
861
+ * The framework's own `GET` that issues the ALTCHA challenge of public procedure `id`: anonymous like the procedure,
862
+ * from the same origins, with buckets of its own that admit at least as many as the procedure's, and no body.
863
+ */
864
+ #challengeIssuer(id: string, action: string, rate: AdmissionRate, origins: { readonly crossOrigin?: 'environment' | 'any' }): void {
865
+ const issuer = challengeRowId(id)
866
+ this.#row({
867
+ id: issuer,
868
+ source: { kind: 'challenge', name: id },
869
+ kind: 'public',
870
+ method: 'GET',
871
+ path: challengePath(action),
872
+ admission: null,
873
+ callers: [],
874
+ requirement: null,
875
+ input: null,
876
+ output: null,
877
+ ...origins,
878
+ })
879
+ this.#procedureRules[issuer] = {
880
+ rate: {
881
+ perClient: challengeIssueBucket(rate.perClient, CHALLENGE_ISSUE_RATE.perClient),
882
+ perCaller: null,
883
+ perEndpoint: challengeIssueBucket(rate.perEndpoint, CHALLENGE_ISSUE_RATE.perEndpoint),
884
+ bytesPerHour: null,
885
+ },
886
+ challenge: null,
887
+ timeoutMs: PROCEDURE_LIMITS.timeoutMs,
888
+ }
889
+ }
890
+
891
+ /** A public procedure's `actions`: at least one action of the contract, each once. */
892
+ #workloadActions(where: string, value: unknown): readonly string[] {
893
+ if (!Array.isArray(value) || value.length === 0 || !value.every((action) => typeof action === 'string')) {
894
+ this.#problem(where, "is public, so it declares what its handler holds: actions: ['<action>'], at least one")
895
+ return []
896
+ }
897
+ const actions: string[] = value.filter((action) => typeof action === 'string')
898
+ this.#unique(where, 'actions', actions)
899
+ for (const action of actions) {
900
+ this.#knownAction(where, action)
901
+ }
902
+ return actions
903
+ }
904
+
905
+ /**
906
+ * A procedure's rules of the staged kit, every default applied: `limits` within its ceilings, and `admission` merged
907
+ * into the default of the procedure's kind (a declared bucket replaces the default, `null` turns it off). Recorded under
908
+ * the endpoint id; `null` for a `defineContract` procedure, which declares none and keeps its row as it was.
909
+ */
910
+ #rules(id: string, procedure: ProcedureDeclaration<SchemaShape, unknown>, kind: 'public' | 'credentialed' | 'link'): ProcedureRules | null {
911
+ const where = `procedure '${id}'`
912
+ if (procedure.declared === undefined) {
913
+ for (const field of ['admission', 'limits', 'http'] as const) {
914
+ if (procedure[field] !== undefined) {
915
+ this.#problem(where, `declares ${field}, which only a procedure of the staged kit declares`)
916
+ }
917
+ }
918
+ return null
919
+ }
920
+ const files = this.#inputFiles(where, procedure)
921
+ const limits = this.#limits(where, procedure.limits, PROCEDURE_LIMITS, files)
922
+ const admission = this.#admission(id, procedure, kind, files)
923
+ const http = this.#http(where, procedure, kind, files)
924
+ this.#ownPath(where, procedure, kind)
925
+ this.#procedureRules[id] = {
926
+ rate: admission.rate,
927
+ challenge: admission.challenge,
928
+ timeoutMs: limits.timeoutMs,
929
+ ...(http.query === null ? {} : { query: http.query }),
930
+ ...(http.method === 'GET' && procedure.type === 'mutation' ? { changesStateOnGet: true } : {}),
931
+ }
932
+ return {
933
+ maxBytes: limits.maxBytes,
934
+ origins: admission.origins,
935
+ method: http.method,
936
+ body: http.body,
937
+ ...(files === null ? {} : { filesOptional: files.optional }),
938
+ ...(http.cache === undefined ? {} : { cache: http.cache }),
939
+ }
940
+ }
941
+
942
+ /**
943
+ * A procedure's file fields: each a top-level field of its input that holds a file of a declared kind (`file(k)`),
944
+ * an optional one, or an array of them bounded by `.max(n)`; a mutation's only, `maxFiles` files together. `null`
945
+ * when it takes none.
946
+ */
947
+ #inputFiles(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>): InputFiles | null {
948
+ const input = procedure.input
949
+ if (input === null || input.type !== 'object') {
950
+ return null
951
+ }
952
+ const fields: { readonly name: string; readonly kind: string; readonly count: number; readonly required: boolean }[] = []
953
+ for (const [name, shape] of Object.entries(input.fields)) {
954
+ const field = fileField(shape)
955
+ if (field === 'unbounded') {
956
+ this.#problem(where, `input field '${name}' is an array of files without a bound (allowed: z.array(file('<kind>')).max(n))`)
957
+ } else if (field !== null) {
958
+ fields.push({ name, ...field })
959
+ } else if (containsFile(shape)) {
960
+ this.#problem(
961
+ where,
962
+ `input field '${name}' holds a file below the top level (allowed: ${name}: file('<kind>'), z.optional(file('<kind>')) or z.array(file('<kind>')).max(n))`,
963
+ )
964
+ }
965
+ }
966
+ if (fields.length === 0) {
967
+ return null
968
+ }
969
+ if (procedure.type !== 'mutation') {
970
+ this.#problem(where, 'takes a file, which only a mutation does: a file is written, and a query writes nothing')
971
+ }
972
+ const count = fields.reduce((sum, field) => sum + field.count, 0)
973
+ if (count > PROCEDURE_LIMITS.maxFiles) {
974
+ this.#problem(where, `takes up to ${count} files, more than the ${PROCEDURE_LIMITS.maxFiles} one request may carry`)
975
+ }
976
+ let bytes = 0
977
+ let largest = 0
978
+ let scanning = true
979
+ for (const field of fields) {
980
+ const kind = this.#fileKind(`${where} input field '${field.name}'`, field.kind)
981
+ if (kind !== null) {
982
+ bytes += field.count * kind.maxBytes
983
+ largest = Math.max(largest, kind.maxBytes)
984
+ scanning &&= kind.scan === true
985
+ }
986
+ }
987
+ return { bytes, largest, scanning, optional: fields.every((field) => !field.required) }
988
+ }
989
+
990
+ /**
991
+ * A procedure's HTTP shape (`http: { method?, query?, body? }`): `POST` with a JSON body by default. A query may be
992
+ * `GET`, its input then read from flat scalar URL parameters; so may a public mutation whose input holds a signed
993
+ * token, which answers a redirect or a schema. A POST may read a flat `query` from the URL beside its body, and take a
994
+ * native form's `urlencoded` body of flat scalar fields. `cache` waits for caching, a later step of this release.
995
+ */
996
+ #http(
997
+ where: string,
998
+ procedure: ProcedureDeclaration<SchemaShape, unknown>,
999
+ kind: 'public' | 'credentialed' | 'link',
1000
+ files: InputFiles | null = null,
1001
+ ): { readonly method: 'GET' | 'POST'; readonly body: ProcedureBody | null; readonly query: SchemaShape | null; readonly cache?: EndpointCache } {
1002
+ const fallback = { method: 'POST' as const, body: files === null ? ('json' as const) : ('multipart' as const), query: null }
1003
+ const value = procedure.http
1004
+ // `http.path` says where the procedure is served, not how: declared alone, it keeps every default of the wire.
1005
+ const wire = isRecord(value) && Object.keys(value).every((key) => key === 'path') ? undefined : value
1006
+ // A query that answers a file is a GET, so `<img src>` and `<a download>` reach it.
1007
+ if (wire === undefined && procedure.type === 'query' && procedure.output.type === 'file') {
1008
+ return this.#http(where, { ...procedure, http: { method: 'GET' } }, kind, files)
1009
+ }
1010
+ if (wire === undefined) {
1011
+ return fallback
1012
+ }
1013
+ const allowed = "http: { method?: 'GET' | 'POST', query?: z.strictObject({ … }), body?: 'json' | 'urlencoded', path?: '/register' }"
1014
+ if (!isRecord(value)) {
1015
+ this.#problem(where, `http is not an object (allowed: ${allowed})`)
1016
+ return fallback
1017
+ }
1018
+ this.#keys(`${where} http`, value, ['method', 'query', 'body', 'cache', 'path'])
1019
+ const method = value['method'] ?? (value['cache'] !== undefined && procedure.type === 'query' ? 'GET' : 'POST')
1020
+ if (method !== 'GET' && method !== 'POST') {
1021
+ this.#problem(where, `http.method ${JSON.stringify(method)} is not a method (allowed: 'GET' or 'POST')`)
1022
+ return fallback
1023
+ }
1024
+ if (files !== null && value['body'] !== undefined) {
1025
+ this.#problem(where, 'takes files, so its body is multipart/form-data, which the input derives: it declares no http.body')
1026
+ return fallback
1027
+ }
1028
+ const body = value['body'] ?? (method === 'GET' ? null : files === null ? 'json' : 'multipart')
1029
+ const flat = (shape: SchemaShape | null) => shape === null || (shape.type === 'object' && Object.values(shape.fields).every(isParameter))
1030
+ if (method === 'GET') {
1031
+ if (procedure.type === 'mutation') {
1032
+ this.#getMutation(where, procedure, kind)
1033
+ }
1034
+ if (value['query'] !== undefined || value['body'] !== undefined) {
1035
+ this.#problem(where, 'is GET, which reads its input from the URL: it declares no http.query and no http.body')
1036
+ }
1037
+ if (!flat(procedure.input)) {
1038
+ this.#problem(where, 'is GET, so its input is flat scalar fields the URL carries (allowed: strings, numbers, booleans, enums, literals, null)')
1039
+ }
1040
+ const cache = this.#cache(where, value['cache'], kind === 'public' && procedure.type === 'query')
1041
+ return { method, body: null, query: null, ...(cache === null ? {} : { cache }) }
1042
+ }
1043
+ if (value['cache'] !== undefined) {
1044
+ this.#problem(where, 'declares http.cache, which only a public GET query does: every other answer is no-store')
1045
+ }
1046
+ if (body === 'multipart' && files !== null) {
1047
+ const query = value['query']
1048
+ if (query !== undefined && (!isSchemaShape(query) || query.type !== 'object' || !flat(query))) {
1049
+ this.#problem(where, 'http.query is not a strict object of flat scalar fields (allowed: z.strictObject({ t: z.string() }))')
1050
+ return { method, body, query: null }
1051
+ }
1052
+ return { method, body, query: query === undefined ? null : query }
1053
+ }
1054
+ if (body !== 'json' && body !== 'urlencoded') {
1055
+ this.#problem(where, `http.body ${JSON.stringify(body)} is not a body codec (allowed: 'json' or 'urlencoded')`)
1056
+ return fallback
1057
+ }
1058
+ if (body === 'urlencoded' && !flat(procedure.input)) {
1059
+ this.#problem(where, "takes a urlencoded body, so its input is flat scalar fields (allowed: http.body: 'json' for anything else)")
1060
+ }
1061
+ const query = value['query']
1062
+ if (query === undefined) {
1063
+ return { method, body, query: null }
1064
+ }
1065
+ if (!isSchemaShape(query) || query.type !== 'object' || !flat(query)) {
1066
+ this.#problem(where, 'http.query is not a strict object of flat scalar fields (allowed: z.strictObject({ t: z.string() }))')
1067
+ return { method, body, query: null }
1068
+ }
1069
+ this.#tokenFields(`${where} http.query`, query, true)
1070
+ return { method, body, query }
899
1071
  }
900
1072
 
901
1073
  /**
902
- * A procedure's or a form's `crossOrigin` (ADR-0022 "Cross-origin endpoints"): `true` admits the origins the
903
- * environment lists, `'*'` (a form only, `any`) every origin. Absent stays absent, so the row keeps its digest.
1074
+ * `http.path`: a public procedure's own literal path instead of `<prefix>/<name>`, for a caller outside the application
1075
+ * that keeps a URL (a 0.5 form's). Recorded for `#checkOwnPaths`, which needs every mount.
904
1076
  */
905
- #crossOrigin(where: string, value: unknown, anyOrigin: boolean): { readonly crossOrigin?: CrossOriginForm } {
1077
+ #ownPath(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>, kind: 'public' | 'credentialed' | 'link'): void {
1078
+ const path = isRecord(procedure.http) ? procedure.http['path'] : undefined
1079
+ if (path === undefined) {
1080
+ return
1081
+ }
1082
+ if (kind !== 'public') {
1083
+ this.#problem(
1084
+ where,
1085
+ 'declares http.path, which only a public procedure does: its caller is outside the application, and every other caller uses the typed client',
1086
+ )
1087
+ } else if (typeof path !== 'string' || !LITERAL_PATH.test(path)) {
1088
+ this.#problem(
1089
+ where,
1090
+ `http.path ${JSON.stringify(path)} is not a literal path (allowed: '/register', segments of letters, digits and '._~-', no trailing '/')`,
1091
+ )
1092
+ } else if (path === FRAMEWORK_PREFIX || path.startsWith(`${FRAMEWORK_PREFIX}/`)) {
1093
+ this.#problem(where, `http.path '${path}' is under ${FRAMEWORK_PREFIX}, which the framework reserves`)
1094
+ } else {
1095
+ this.#ownPaths.push({ where, path })
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * An own path lies outside every mount's prefix but the root SPA's, whose `/*` answers only what no endpoint claims:
1101
+ * a public row never takes a path that a mount, credentialed or not, serves under its own prefix. Two endpoints on one
1102
+ * path are `assertNoAmbiguity`'s.
1103
+ */
1104
+ #checkOwnPaths(): void {
1105
+ for (const own of this.#ownPaths) {
1106
+ for (const [name, mount] of Object.entries(this.#contract.mounts)) {
1107
+ const prefix = mountPath(mount)
1108
+ if (prefix !== '/' && (own.path === prefix || own.path.startsWith(`${prefix}/`))) {
1109
+ this.#problem(
1110
+ own.where,
1111
+ `http.path '${own.path}' lies under mount '${name}' ('${prefix}'), which serves its paths itself (allowed: a path outside every mount's prefix, such as '/register')`,
1112
+ )
1113
+ }
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * `http.cache` of a public GET query: `{ maxAgeSeconds }` (1 s to a year) or `'immutable'` (a year, for an input that
1120
+ * names an immutable value, such as a file id). `null` when it declares none or after a problem.
1121
+ */
1122
+ #cache(where: string, value: unknown, cacheable: boolean): EndpointCache | null {
906
1123
  if (value === undefined) {
907
- return {}
1124
+ return null
1125
+ }
1126
+ if (!cacheable) {
1127
+ this.#problem(where, 'declares http.cache, which only a public GET query does: every other answer is no-store')
1128
+ return null
908
1129
  }
909
- if (value === true) {
910
- return { crossOrigin: 'environment' }
1130
+ if (value === 'immutable') {
1131
+ return value
1132
+ }
1133
+ const allowed = `allowed: http.cache: { maxAgeSeconds: 1 to ${MAX_CACHE_AGE_SECONDS} } or 'immutable'`
1134
+ if (!isRecord(value)) {
1135
+ this.#problem(where, `http.cache is neither a lifetime nor 'immutable' (${allowed})`)
1136
+ return null
911
1137
  }
912
- if (anyOrigin && value === '*') {
913
- return { crossOrigin: 'any' }
1138
+ this.#keys(`${where} http.cache`, value, ['maxAgeSeconds'])
1139
+ const seconds = value['maxAgeSeconds']
1140
+ if (typeof seconds !== 'number' || !Number.isSafeInteger(seconds) || seconds < 1 || seconds > MAX_CACHE_AGE_SECONDS) {
1141
+ this.#problem(where, `http.cache.maxAgeSeconds ${JSON.stringify(seconds)} is not a lifetime (${allowed})`)
1142
+ return null
914
1143
  }
915
- const allowed = anyOrigin ? "crossOrigin: true for the environment's listed origins, or crossOrigin: '*' for any" : 'crossOrigin: true'
916
- this.#problem(where, `crossOrigin ${JSON.stringify(value)} is not a cross-origin form (allowed: ${allowed})`)
917
- return {}
1144
+ return { maxAgeSeconds: seconds }
918
1145
  }
919
1146
 
920
- /** A procedure no page on another origin may call: `caller` is who calls it instead. */
921
- #noCrossOrigin(where: string, value: unknown, caller: string): { readonly crossOrigin?: CrossOriginForm } {
922
- if (value !== undefined) {
923
- this.#problem(where, `is called by ${caller}, never cross-origin (only an RPC procedure of c.rpc and a form take crossOrigin)`)
1147
+ /**
1148
+ * A mutation answers `GET` only when it is public and a verified signed token in its input names what it changes: a
1149
+ * link scanner may follow it, and a session's top-level navigation would carry the cookie. It answers a redirect or a
1150
+ * schema.
1151
+ */
1152
+ #getMutation(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>, kind: 'public' | 'credentialed' | 'link'): void {
1153
+ if (kind !== 'public') {
1154
+ this.#problem(
1155
+ where,
1156
+ "is a GET mutation, which only a public procedure is: a session's top-level navigation carries its cookie (allowed: POST, or access: { public: true } with a signedToken)",
1157
+ )
1158
+ return
1159
+ }
1160
+ const fields = procedure.input?.type === 'object' ? Object.values(procedure.input.fields) : []
1161
+ if (!fields.some((field) => field.type === 'token')) {
1162
+ this.#problem(where, "is a GET mutation, so its input holds a required signedToken('<purpose>') that names what it changes")
1163
+ }
1164
+ if (procedure.attachment !== undefined || procedure.output.type === 'file') {
1165
+ this.#problem(where, 'is a GET mutation, which answers redirect() or a schema, never a file')
924
1166
  }
925
- return {}
1167
+ }
1168
+
1169
+ /**
1170
+ * A procedure's or a webhook's `limits`, each bound within `ceiling` (a procedure's by default); `defaults` for what it
1171
+ * leaves out. A procedure with file fields admits by default its JSON input's bytes and each of its files at its
1172
+ * kind's `maxBytes`, at most `maxFileBodyBytes` in all.
1173
+ */
1174
+ #limits(
1175
+ where: string,
1176
+ value: unknown,
1177
+ base: { readonly maxBytes: number; readonly timeoutMs: number },
1178
+ files: InputFiles | null = null,
1179
+ ceiling: number = files === null ? PROCEDURE_LIMITS.maxMaxBytes : PROCEDURE_LIMITS.maxFileBodyBytes,
1180
+ ): { readonly maxBytes: number; readonly timeoutMs: number } {
1181
+ const defaults = { maxBytes: base.maxBytes + (files?.bytes ?? 0), timeoutMs: base.timeoutMs }
1182
+ if (defaults.maxBytes > ceiling && (!isRecord(value) || value['maxBytes'] === undefined)) {
1183
+ this.#problem(
1184
+ where,
1185
+ `takes files of up to ${
1186
+ files?.bytes ?? 0
1187
+ } bytes together, more than the ${ceiling} bytes a request may carry: take fewer or smaller files, or declare limits.maxBytes`,
1188
+ )
1189
+ }
1190
+ if (value === undefined) {
1191
+ return { ...defaults, maxBytes: Math.min(defaults.maxBytes, ceiling) }
1192
+ }
1193
+ if (!isRecord(value)) {
1194
+ this.#problem(where, 'limits is not an object (allowed: limits: { maxBytes?, timeoutMs? })')
1195
+ return defaults
1196
+ }
1197
+ this.#keys(`${where} limits`, value, ['maxBytes', 'timeoutMs'])
1198
+ return {
1199
+ maxBytes: this.#bounded(where, 'limits.maxBytes', value['maxBytes'], Math.min(defaults.maxBytes, ceiling), ceiling, 'bytes'),
1200
+ timeoutMs: this.#bounded(where, 'limits.timeoutMs', value['timeoutMs'], defaults.timeoutMs, PROCEDURE_LIMITS.maxTimeoutMs, 'ms'),
1201
+ }
1202
+ }
1203
+
1204
+ /** A declared whole number from 1 to `ceiling`, or `fallback` when it is absent or refused. */
1205
+ /** A declared whole number from `floor` (1 unless named) to `ceiling`, or `fallback` when it is not declared. */
1206
+ #bounded(where: string, field: string, value: unknown, fallback: number, ceiling: number, unit: string, floor = 1): number {
1207
+ if (value === undefined) {
1208
+ return fallback
1209
+ }
1210
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < floor || value > ceiling) {
1211
+ this.#problem(where, `${field} ${JSON.stringify(value)} is not a whole number of ${unit} from ${floor} to ${ceiling}`)
1212
+ return fallback
1213
+ }
1214
+ return value
1215
+ }
1216
+
1217
+ #admission(
1218
+ id: string,
1219
+ procedure: ProcedureDeclaration<SchemaShape, unknown>,
1220
+ kind: 'public' | 'credentialed' | 'link',
1221
+ files: InputFiles | null = null,
1222
+ ): { readonly origins: AdmissionOrigins; readonly rate: AdmissionRate; readonly challenge: ProcedureChallenge | null } {
1223
+ const where = `procedure '${id}'`
1224
+ const admitted = this.#declaredAdmission(id, procedure, kind, files)
1225
+ if (kind === 'public' && files !== null) {
1226
+ const missing = [
1227
+ ...(admitted.rate.bytesPerHour === null ? ['admission.rate.bytesPerHour'] : []),
1228
+ ...(admitted.challenge === null ? ['admission.challenge'] : []),
1229
+ ]
1230
+ if (missing.length > 0) {
1231
+ this.#problem(where, `is public and takes files, so it declares ${missing.join(' and ')}: an anonymous upload is budgeted and challenged`)
1232
+ }
1233
+ if (!files.scanning) {
1234
+ this.#problem(
1235
+ where,
1236
+ "is public and takes files, so each of its file kinds scans (scan: true): an anonymous visitor's file waits for a clean verdict",
1237
+ )
1238
+ }
1239
+ }
1240
+ return admitted
1241
+ }
1242
+
1243
+ #declaredAdmission(
1244
+ id: string,
1245
+ procedure: ProcedureDeclaration<SchemaShape, unknown>,
1246
+ kind: 'public' | 'credentialed' | 'link',
1247
+ files: InputFiles | null,
1248
+ ): { readonly origins: AdmissionOrigins; readonly rate: AdmissionRate; readonly challenge: ProcedureChallenge | null } {
1249
+ const where = `procedure '${id}'`
1250
+ const rate: Record<keyof AdmissionRate, number | null> = kind === 'public' && procedure.type === 'mutation'
1251
+ ? { perClient: PUBLIC_MUTATION_RATE.perClient, perCaller: null, perEndpoint: PUBLIC_MUTATION_RATE.perEndpoint, bytesPerHour: null }
1252
+ : { perClient: null, perCaller: null, perEndpoint: null, bytesPerHour: null }
1253
+ const declared = procedure.admission
1254
+ if (declared === undefined) {
1255
+ return { origins: 'same-origin', rate, challenge: null }
1256
+ }
1257
+ const allowed =
1258
+ "admission: { origins?: 'same-origin' | 'listed' | 'any', rate?: { perClient?, perCaller?, perEndpoint?, bytesPerHour? }, challenge? }"
1259
+ if (!isRecord(declared)) {
1260
+ this.#problem(where, `admission is not an object (allowed: ${allowed})`)
1261
+ return { origins: 'same-origin', rate, challenge: null }
1262
+ }
1263
+ this.#keys(`${where} admission`, declared, ['origins', 'rate', 'challenge'])
1264
+ const origins = this.#origins(where, declared['origins'], kind)
1265
+ const declaredRate = declared['rate']
1266
+ if (declaredRate !== undefined && !isRecord(declaredRate)) {
1267
+ this.#problem(where, `admission.rate is not an object of buckets (allowed: ${allowed})`)
1268
+ }
1269
+ for (const [bucket, value] of Object.entries(isRecord(declaredRate) ? declaredRate : {})) {
1270
+ if (!Object.hasOwn(rate, bucket)) {
1271
+ this.#problem(where, `admission.rate.${bucket} is not a bucket (allowed: perClient, perCaller, perEndpoint, bytesPerHour)`)
1272
+ continue
1273
+ }
1274
+ const field = bucket === 'perClient' || bucket === 'perCaller' || bucket === 'perEndpoint' || bucket === 'bytesPerHour' ? bucket : null
1275
+ if (field === null) continue
1276
+ if (field === 'perClient' && kind !== 'public') {
1277
+ this.#problem(where, 'admission.rate.perClient counts by the client address, which only a public procedure does (allowed: perCaller)')
1278
+ } else if (field === 'perCaller' && kind === 'public') {
1279
+ this.#problem(where, 'admission.rate.perCaller counts by the caller, and a public procedure has none (allowed: perClient)')
1280
+ } else if (field === 'bytesPerHour' && files === null) {
1281
+ this.#problem(where, 'admission.rate.bytesPerHour bounds the bytes of file fields, and the input declares none')
1282
+ } else if (field === 'bytesPerHour' && typeof value === 'number' && value < (files?.largest ?? 0)) {
1283
+ this.#problem(where, `admission.rate.bytesPerHour ${value} is less than the ${files?.largest ?? 0} bytes one of its files may hold`)
1284
+ }
1285
+ const [ceiling, unit] = field === 'bytesPerHour'
1286
+ ? [PROCEDURE_LIMITS.maxBytesPerHour, 'bytes per hour']
1287
+ : [PROCEDURE_LIMITS.maxPerMinute, 'calls per minute']
1288
+ rate[field] = value === null ? null : this.#bounded(where, `admission.rate.${field}`, value, rate[field] ?? 1, ceiling, unit)
1289
+ }
1290
+ const challenge = declared['challenge']
1291
+ if (challenge === undefined) {
1292
+ return { origins, rate, challenge: null }
1293
+ }
1294
+ if (kind !== 'public' || !isRecord(challenge)) {
1295
+ this.#problem(where, kind === 'public' ? 'admission.challenge is not an object' : 'admission.challenge belongs to a public procedure only')
1296
+ return { origins, rate, challenge: null }
1297
+ }
1298
+ const fields = procedure.input?.type === 'object' ? procedure.input.fields : {}
1299
+ return { origins, rate, challenge: this.#challenge(id, where, challenge, fields) ?? null }
1300
+ }
1301
+
1302
+ #origins(where: string, value: unknown, kind: 'public' | 'credentialed' | 'link'): AdmissionOrigins {
1303
+ if (value === undefined || value === 'same-origin') {
1304
+ return 'same-origin'
1305
+ }
1306
+ if ((value === 'listed' && kind !== 'link') || (value === 'any' && kind === 'public')) {
1307
+ return value
1308
+ }
1309
+ const allowed = kind === 'public' ? "'same-origin', 'listed' or 'any'" : kind === 'link' ? "'same-origin'" : "'same-origin' or 'listed'"
1310
+ this.#problem(where, `admission.origins ${JSON.stringify(value)} is not an origin rule here (allowed: ${allowed})`)
1311
+ return 'same-origin'
926
1312
  }
927
1313
 
928
1314
  /**
@@ -930,6 +1316,16 @@ class ArtifactBuilder {
930
1316
  * scalar cell, the name is fixed and safe in a header, and each limit is within `ATTACHMENT_LIMITS`.
931
1317
  */
932
1318
  #output(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>): OutputShape {
1319
+ if (procedure.redirect !== undefined) {
1320
+ return this.#redirect(where, procedure.redirect.origins)
1321
+ }
1322
+ if (procedure.output.type === 'file') {
1323
+ this.#fileKind(`${where} output`, procedure.output.kind)
1324
+ return procedure.output
1325
+ }
1326
+ if (containsFile(procedure.output)) {
1327
+ this.#problem(where, "answers JSON with a file inside, which JSON cannot carry (allowed: output: file('<kind>'), or the file's reference fields)")
1328
+ }
933
1329
  const attachment = procedure.attachment
934
1330
  if (attachment === undefined) {
935
1331
  return procedure.output
@@ -963,6 +1359,17 @@ class ArtifactBuilder {
963
1359
  return { type: 'attachment', format, filename: attachment.filename, maxBytes, maxRows, columns }
964
1360
  }
965
1361
 
1362
+ /** A redirect's listed origins: exact `https://` origins, each once; the application's own needs none. */
1363
+ #redirect(where: string, origins: readonly string[]): OutputShape {
1364
+ this.#unique(where, 'redirect origins', origins)
1365
+ for (const origin of origins) {
1366
+ if (!isPolicyOrigin(origin)) {
1367
+ this.#problem(where, `redirect origin '${origin}' is not an exact https origin (allowed: 'https://shop.example')`)
1368
+ }
1369
+ }
1370
+ return { type: 'redirect', origins: [...origins] }
1371
+ }
1372
+
966
1373
  #attachmentLimit(where: string, field: string, value: number, ceiling: number): number {
967
1374
  if (!Number.isInteger(value) || value < 1 || value > ceiling) {
968
1375
  this.#problem(where, `${field} ${value} is not a whole number from 1 to ${ceiling}`)
@@ -982,12 +1389,12 @@ class ArtifactBuilder {
982
1389
 
983
1390
  #anonymous(
984
1391
  name: string,
985
- kind: 'health' | 'form' | 'webhook' | 'public-spa',
1392
+ kind: 'health' | 'webhook',
986
1393
  method: 'GET' | 'POST',
987
1394
  path: string,
988
1395
  admission: BodyAdmission | null,
989
1396
  input: SchemaShape | null,
990
- crossOrigin: { readonly crossOrigin?: CrossOriginForm } = {},
1397
+ extra: Pick<EndpointRow, 'authorization'> = {},
991
1398
  ): void {
992
1399
  this.#row({
993
1400
  id: name,
@@ -1000,12 +1407,99 @@ class ArtifactBuilder {
1000
1407
  admission,
1001
1408
  input,
1002
1409
  output: null,
1003
- ...crossOrigin,
1410
+ ...extra,
1004
1411
  })
1005
1412
  }
1006
1413
 
1007
- #row(row: EndpointRow): void {
1008
- this.#endpoints.push(row)
1414
+ /** A row that answers a stored file carries the file-response profile, which its digest covers. */
1415
+ #row(row: EndpointRow): void {
1416
+ const where = `endpoint '${row.id}'`
1417
+ // A token travels only as a top-level field of a procedure's own input, never into an export's consumer.
1418
+ const carries = (row.kind === 'query' || row.kind === 'mutation' || row.kind === 'public') && row.source.kind === 'mount'
1419
+ if (row.input !== null) {
1420
+ this.#tokenFields(`${where} input`, row.input, carries)
1421
+ }
1422
+ if (row.output !== null && isSchemaOutput(row.output)) {
1423
+ this.#tokenFields(`${where} output`, row.output, false)
1424
+ }
1425
+ this.#endpoints.push(row.output?.type === 'file' ? { ...row, response: 'file' } : row)
1426
+ }
1427
+
1428
+ /**
1429
+ * Every signed token field of `shape`: a declared purpose, and, where `topLevel` allows it, a field of the object
1430
+ * itself (optional or not); anywhere else a token is refused, since the framework verifies only those.
1431
+ */
1432
+ #tokenFields(where: string, shape: SchemaShape, topLevel: boolean): void {
1433
+ const visit = (at: SchemaShape, top: boolean): void => {
1434
+ switch (at.type) {
1435
+ case 'token':
1436
+ if (!top) {
1437
+ this.#problem(
1438
+ where,
1439
+ `carries signedToken('${at.purpose}') where the framework does not verify it (allowed: a top-level field of a procedure's input or http.query)`,
1440
+ )
1441
+ }
1442
+ this.#knownPurpose(where, at.purpose)
1443
+ return
1444
+ case 'object':
1445
+ for (const field of Object.values(at.fields)) visit(field, top && at === shape)
1446
+ return
1447
+ case 'optional':
1448
+ visit(at.inner, top)
1449
+ return
1450
+ case 'nullable':
1451
+ case 'array':
1452
+ visit(at.type === 'array' ? at.items : at.inner, false)
1453
+ return
1454
+ case 'tuple':
1455
+ for (const item of [...at.items, ...(at.rest === null ? [] : [at.rest])]) visit(item, false)
1456
+ return
1457
+ case 'record':
1458
+ visit(at.value, false)
1459
+ return
1460
+ case 'union':
1461
+ for (const option of at.options) visit(option, false)
1462
+ return
1463
+ default:
1464
+ return
1465
+ }
1466
+ }
1467
+ visit(shape, topLevel)
1468
+ }
1469
+
1470
+ #knownPurpose(where: string, purpose: string): void {
1471
+ const tokens = this.#contract.tokens ?? {}
1472
+ if (!Object.hasOwn(tokens, purpose)) {
1473
+ this.#problem(
1474
+ where,
1475
+ `signedToken('${purpose}') names no token purpose of defineApp's options${
1476
+ suggestion(purpose, Object.keys(tokens))
1477
+ } (allowed: tokens: { ${purpose}: token(Claims, { maxDays }) })`,
1478
+ )
1479
+ }
1480
+ }
1481
+
1482
+ /**
1483
+ * The token purposes: each a name, its claims a strict object that carries no token, its `maxDays` a whole number of
1484
+ * days up to `TOKEN_MAX_DAYS`.
1485
+ */
1486
+ #tokens(): Record<string, TokenPurposeArtifact> {
1487
+ const tokens: Record<string, TokenPurposeArtifact> = {}
1488
+ for (const [purpose, declared] of Object.entries(this.#contract.tokens ?? {})) {
1489
+ const where = `token '${purpose}'`
1490
+ if (!this.#name(where, purpose)) {
1491
+ continue
1492
+ }
1493
+ if (!isRecord(declared) || !hasExactKeys(declared, ['claims', 'maxDays'])) {
1494
+ this.#problem(where, 'is not built with token(Claims, { maxDays })')
1495
+ continue
1496
+ }
1497
+ this.#objectSchema(`${where} claims`, declared.claims)
1498
+ this.#tokenFields(`${where} claims`, declared.claims, false)
1499
+ const maxDays = this.#bounded(where, 'maxDays', declared.maxDays, 1, TOKEN_MAX_DAYS, 'days')
1500
+ tokens[purpose] = { claims: declared.claims, maxDays }
1501
+ }
1502
+ return tokens
1009
1503
  }
1010
1504
 
1011
1505
  #reserve(prefix: string | null): void {
@@ -1018,6 +1512,9 @@ class ArtifactBuilder {
1018
1512
 
1019
1513
  #requirement(where: string, procedure: ProcedureDeclaration<SchemaShape>): EndpointRequirement | null {
1020
1514
  const access: AccessDeclaration = procedure.access
1515
+ if ('capabilities' in access) {
1516
+ return this.#capabilities(where, procedure, access)
1517
+ }
1021
1518
  const form = accessForm(access)
1022
1519
  if (form === null) {
1023
1520
  this.#problem(
@@ -1056,6 +1553,42 @@ class ArtifactBuilder {
1056
1553
  return { kind: 'action', action: form.action, access: form.access }
1057
1554
  }
1058
1555
 
1556
+ /**
1557
+ * Several capabilities, each an action `over` a dimension or `on` or `in` the value an input field names: at least two,
1558
+ * by names the handler reads (`ctx.capabilities.<name>`). Several fields may name one value.
1559
+ */
1560
+ #capabilities(
1561
+ where: string,
1562
+ procedure: ProcedureDeclaration<SchemaShape>,
1563
+ access: { readonly capabilities: Readonly<Record<string, CapabilityDeclaration>> },
1564
+ ): EndpointRequirement | null {
1565
+ const allowed = '{ capabilities: { <name>: { action, over } | { action, in, from } | { action, on, from }, … } }, at least two'
1566
+ if (!hasExactKeys(access, ['capabilities']) || !isRecord(access.capabilities) || Object.keys(access.capabilities).length < 2) {
1567
+ this.#problem(where, `access ${JSON.stringify(access)} is not several capabilities (allowed: ${allowed})`)
1568
+ return null
1569
+ }
1570
+ const capabilities: Record<string, CapabilityRequirement> = {}
1571
+ for (const [name, declared] of Object.entries(access.capabilities)) {
1572
+ const at = `${where} capability '${name}'`
1573
+ const form = accessForm(declared)
1574
+ if (!this.#name(at, name) || form === null || form.form !== 'action' || !isCapabilityForm(form.access)) {
1575
+ if (form !== null) this.#problem(at, `is not one capability (allowed: { action, over }, { action, in, from }, { action, on, from })`)
1576
+ else {this.#problem(
1577
+ at,
1578
+ `${JSON.stringify(declared)} is not one access form (allowed: { action, over }, { action, in, from }, { action, on, from })`,
1579
+ )}
1580
+ continue
1581
+ }
1582
+ this.#knownAction(at, form.action)
1583
+ this.#knownDimension(at, form.access.dimension)
1584
+ if (form.access.form !== 'over') {
1585
+ this.#fromField(at, procedure.input, form.access.from)
1586
+ }
1587
+ capabilities[name] = { action: form.action, access: form.access }
1588
+ }
1589
+ return { kind: 'capabilities', capabilities }
1590
+ }
1591
+
1059
1592
  #linkRequirement(where: string, access: LinkAccessDeclaration, mount: LinkMountDeclaration<SchemaShape>): EndpointRequirement | null {
1060
1593
  if (!hasExactKeys(access, ['action'])) {
1061
1594
  this.#problem(where, `access ${JSON.stringify(access)} is not a link access (allowed: { action: <one of the link's grants> })`)
@@ -1112,218 +1645,77 @@ class ArtifactBuilder {
1112
1645
 
1113
1646
  // ── Sections ────────────────────────────────────────────────────────────────────────────────
1114
1647
 
1115
- #files(dimensions: Record<string, DimensionArtifact>): Record<string, FileCollectionArtifact> {
1116
- const files: Record<string, FileCollectionArtifact> = {}
1117
- for (const [name, collection] of Object.entries(this.#contract.files)) {
1118
- const where = `files '${name}'`
1119
- if (!this.#name(where, name) || !this.#kind(where, collection, 'files', 'c.files({ … })')) {
1120
- continue
1121
- }
1122
- const artifact = this.#fileCollection(where, collection)
1123
- const owner = this.#tables[collection.owner]
1124
- if (owner?.scope.mode !== 'dimension') {
1125
- this.#problem(where, `its owner '${collection.owner}' is not a table with a dimension scope in src/db/tables.ts`)
1126
- continue
1127
- }
1128
- const dimension = `file:${name}`
1129
- dimensions[dimension] = { in: owner.scope.dimension, creator: null, members: null }
1130
- files[name] = artifact
1131
- const source: EndpointSource = { kind: 'files', name }
1132
- const paths = fileCollectionPaths(FILES_PREFIX, name)
1133
- const [firstType, ...otherTypes] = collection.types
1134
- if (firstType !== undefined) {
1135
- this.#row({
1136
- id: `files.${name}.upload`,
1137
- source,
1138
- kind: 'upload',
1139
- method: 'POST',
1140
- path: `${paths.upload}/:owner`,
1141
- callers: ['human'],
1142
- requirement: { kind: 'action', action: collection.write, access: { form: 'on', dimension: owner.scope.dimension, from: 'owner' } },
1143
- admission: { contentTypes: [firstType, ...otherTypes], maxBytes: collection.maxBytes },
1144
- input: null,
1145
- output: ID_OBJECT,
1146
- })
1147
- }
1148
- this.#row({
1149
- id: `files.${name}.download`,
1150
- source,
1151
- kind: 'download',
1152
- method: 'GET',
1153
- path: `${paths.download}/:fileId`,
1154
- callers: ['human'],
1155
- requirement: { kind: 'action', action: collection.read, access: { form: 'on', dimension, from: 'fileId' } },
1156
- admission: null,
1157
- input: null,
1158
- output: null,
1159
- })
1160
- this.#row({
1161
- id: `files.${name}.list`,
1162
- source,
1163
- kind: 'query',
1164
- method: 'POST',
1165
- path: paths.list,
1166
- callers: ['human'],
1167
- requirement: { kind: 'action', action: collection.read, access: { form: 'on', dimension: owner.scope.dimension, from: 'owner' } },
1168
- admission: JSON_ADMISSION,
1169
- input: FILE_LIST_INPUT,
1170
- output: FILE_LIST_OUTPUT,
1171
- })
1172
- this.#row({
1173
- id: `files.${name}.remove`,
1174
- source,
1175
- kind: 'mutation',
1176
- method: 'POST',
1177
- path: paths.remove,
1178
- callers: ['human'],
1179
- requirement: { kind: 'action', action: collection.delete, access: { form: 'on', dimension, from: 'id' } },
1180
- admission: JSON_ADMISSION,
1181
- input: ID_OBJECT,
1182
- output: ID_OBJECT,
1183
- })
1184
- if (artifact.link !== false) {
1185
- this.#linkFiles(where, name, collection, owner.scope.dimension, artifact.link)
1186
- }
1187
- }
1188
- return files
1189
- }
1190
-
1191
- /**
1192
- * The rows a link holder reaches a collection by (upload, list, download): under the API prefix of every link mount
1193
- * bound to the owner's dimension (`link: true`) or of each one `link` names, caller `link`, the owner being the
1194
- * link's object (no parameter names it). A serving mount grants the collection's write and read actions, so a key of
1195
- * it can do what the rows require; a mount `link` does not name serves no row and need not grant them.
1196
- */
1197
- #linkFiles(where: string, name: string, collection: FilesDeclaration, ownerDimension: string, link: true | readonly string[]): void {
1198
- const bound = Object.entries(this.#contract.mounts).flatMap(([mountName, mount]) =>
1199
- mount.kind === 'link' && mount.dimension === ownerDimension ? [{ mountName, mount }] : []
1200
- )
1201
- const boundForm = `c.link(prefix, { dimension: '${ownerDimension}', … })`
1202
- if (link === true && bound.length === 0) {
1203
- this.#problem(where, `sets link: true, and no link mount is bound to '${ownerDimension}' (allowed: ${boundForm})`)
1204
- }
1205
- const mounts = link === true ? bound : link.flatMap((mountName) => {
1206
- const found = bound.find((candidate) => candidate.mountName === mountName)
1207
- if (found === undefined) {
1208
- const allowed = bound.length === 0 ? boundForm : bound.map((candidate) => candidate.mountName).join(', ')
1209
- this.#problem(where, `link names '${mountName}', which is no link mount bound to '${ownerDimension}' (allowed: ${allowed})`)
1210
- }
1211
- return found === undefined ? [] : [found]
1212
- })
1213
- const [firstType, ...otherTypes] = collection.types
1214
- for (const { mountName, mount } of mounts) {
1215
- const missing = [collection.write, collection.read].filter((action, index, all) => all.indexOf(action) === index && !mount.grants.includes(action))
1216
- if (missing.length > 0) {
1217
- const subject = link === true ? `sets link: true, and link mount '${mountName}'` : `link names mount '${mountName}', which`
1218
- this.#problem(
1219
- where,
1220
- `${subject} grants no ${missing.map((action) => `'${action}'`).join(' or ')} (allowed: grants: [${
1221
- [...mount.grants, ...missing].map((action) => `'${action}'`).join(', ')
1222
- }])`,
1223
- )
1224
- continue
1225
- }
1226
- const source: EndpointSource = { kind: 'mount', name: mountName }
1227
- const paths = fileCollectionPaths(joinPath(mount.prefix, LINK_API_SEGMENT), name)
1228
- const ofTheLink = (action: string): EndpointRequirement => ({
1229
- kind: 'action',
1230
- action,
1231
- access: { form: 'on', dimension: ownerDimension, from: null },
1232
- })
1233
- if (firstType !== undefined) {
1234
- this.#row({
1235
- id: `${mountName}.files.${name}.upload`,
1236
- source,
1237
- kind: 'upload',
1238
- method: 'POST',
1239
- path: paths.upload,
1240
- callers: ['link'],
1241
- requirement: ofTheLink(collection.write),
1242
- admission: { contentTypes: [firstType, ...otherTypes], maxBytes: collection.maxBytes },
1243
- input: null,
1244
- output: ID_OBJECT,
1245
- })
1246
- }
1247
- this.#row({
1248
- id: `${mountName}.files.${name}.list`,
1249
- source,
1250
- kind: 'query',
1251
- method: 'POST',
1252
- path: paths.list,
1253
- callers: ['link'],
1254
- requirement: ofTheLink(collection.read),
1255
- admission: JSON_ADMISSION,
1256
- input: FILE_LINK_LIST_INPUT,
1257
- output: FILE_LIST_OUTPUT,
1258
- })
1259
- this.#row({
1260
- id: `${mountName}.files.${name}.download`,
1261
- source,
1262
- kind: 'download',
1263
- method: 'GET',
1264
- path: `${paths.download}/:fileId`,
1265
- callers: ['link'],
1266
- requirement: ofTheLink(collection.read),
1267
- admission: null,
1268
- input: null,
1269
- output: null,
1270
- })
1271
- }
1272
- }
1273
-
1274
- #fileCollection(where: string, collection: FilesDeclaration): FileCollectionArtifact {
1275
- this.#options(where, 'files', collection.unknownOptions)
1276
- this.#knownAction(`${where} read`, collection.read)
1277
- this.#knownAction(`${where} write`, collection.write)
1278
- this.#knownAction(`${where} delete`, collection.delete)
1279
- this.#positiveInteger(`${where} maxBytes`, collection.maxBytes)
1280
- if (collection.maxPerOwner !== undefined) {
1281
- this.#positiveInteger(`${where} maxPerOwner`, collection.maxPerOwner)
1282
- }
1283
- this.#unique(where, 'types', collection.types)
1284
- const link = this.#fileLink(where, collection.link)
1285
- for (const type of collection.types) {
1648
+ /** The media types and the scan of a file kind: exact types, and a scan that can vouch for each file. */
1649
+ #fileTypes(where: string, declared: Pick<FileKindDeclaration, 'types' | 'maxBytes' | 'scan'>): void {
1650
+ this.#unique(where, 'types', declared.types)
1651
+ for (const type of declared.types) {
1286
1652
  if (!MIME_TYPE.test(type)) {
1287
1653
  this.#problem(where, `'${type}' is not an exact media type (allowed: 'image/png'; no wildcards or parameters)`)
1288
1654
  }
1289
1655
  }
1290
- if (collection.types.length === 0) {
1656
+ if (declared.types.length === 0) {
1291
1657
  this.#problem(where, 'admits no media type (allowed: types: [<exact media types>])')
1292
1658
  }
1293
- if (collection.scan !== undefined && typeof collection.scan !== 'boolean') {
1659
+ if (declared.scan !== undefined && typeof declared.scan !== 'boolean') {
1294
1660
  this.#problem(where, 'scan is true or false')
1295
1661
  }
1296
- for (const type of collection.scan === true ? collection.types.filter((candidate) => FILE_LIMITS.unscannedTypes.includes(candidate)) : []) {
1662
+ for (const type of declared.scan === true ? declared.types.filter((candidate) => FILE_LIMITS.unscannedTypes.includes(candidate)) : []) {
1297
1663
  this.#problem(
1298
1664
  where,
1299
1665
  `scans its files and admits '${type}', an archive the framework does not open, so no scan can vouch for its contents (allowed: application/zip, which the framework checks entry by entry)`,
1300
1666
  )
1301
1667
  }
1302
- if (collection.scan === true && collection.maxBytes > FILE_LIMITS.maxScannedBytes) {
1668
+ if (declared.scan === true && declared.maxBytes > FILE_LIMITS.maxScannedBytes) {
1303
1669
  this.#problem(
1304
1670
  where,
1305
- `scans its files and admits ${collection.maxBytes} bytes per file; the scanner gives a clean verdict to at most ${FILE_LIMITS.maxScannedBytes} (allowed: maxBytes: ${FILE_LIMITS.maxScannedBytes} or less)`,
1671
+ `scans its files and admits ${declared.maxBytes} bytes per file; the scanner gives a clean verdict to at most ${FILE_LIMITS.maxScannedBytes} (allowed: maxBytes: ${FILE_LIMITS.maxScannedBytes} or less)`,
1306
1672
  )
1307
1673
  }
1308
- return {
1309
- owner: collection.owner,
1310
- read: collection.read,
1311
- write: collection.write,
1312
- delete: collection.delete,
1313
- maxBytes: collection.maxBytes,
1314
- types: [...collection.types],
1315
- maxPerOwner: collection.maxPerOwner ?? CONTRACT_DEFAULTS.fileMaxPerOwner,
1316
- link,
1317
- ...(collection.scan === true ? { scan: true } : {}),
1674
+ }
1675
+
1676
+ /** The file kinds (`c.files({ types, maxBytes, scan? })`), before the mounts that name them. */
1677
+ #readFileKinds(): Record<string, FileKindArtifact> {
1678
+ const kinds: Record<string, FileKindArtifact> = {}
1679
+ for (const [name, declared] of Object.entries(this.#contract.files)) {
1680
+ const where = `files '${name}'`
1681
+ if (!this.#name(where, name) || !this.#kind(where, declared, 'file-kind', 'c.files({ types, maxBytes, scan? })')) {
1682
+ continue
1683
+ }
1684
+ if (declared.unknownOptions?.includes('owner') === true) {
1685
+ this.#problem(
1686
+ where,
1687
+ "is a file collection (owner, read, write, delete), which no longer exists: declare the kind c.files({ types, maxBytes, scan? }) and a table with a file column of it (files: { <column>: '<kind>' })",
1688
+ )
1689
+ continue
1690
+ }
1691
+ this.#options(where, 'fileKind', declared.unknownOptions)
1692
+ this.#positiveInteger(`${where} maxBytes`, declared.maxBytes)
1693
+ if (declared.maxBytes > PROCEDURE_LIMITS.maxFileBodyBytes) {
1694
+ this.#problem(
1695
+ where,
1696
+ `admits ${declared.maxBytes} bytes per file, more than the ${PROCEDURE_LIMITS.maxFileBodyBytes} bytes a request may carry (allowed: maxBytes: ${PROCEDURE_LIMITS.maxFileBodyBytes} or less)`,
1697
+ )
1698
+ }
1699
+ this.#fileTypes(where, declared)
1700
+ kinds[name] = { types: [...declared.types], maxBytes: declared.maxBytes, ...(declared.scan === true ? { scan: true } : {}) }
1318
1701
  }
1702
+ return kinds
1319
1703
  }
1320
1704
 
1321
- /** `link` as the artifact holds it: a boolean, or the link mounts it names, each once. */
1322
- #fileLink(where: string, link: FilesDeclaration['link']): boolean | readonly string[] {
1323
- if (link === undefined || typeof link === 'boolean') return link ?? CONTRACT_DEFAULTS.fileLink
1324
- if (link.length === 0) this.#problem(where, "link names no mount (allowed: link: true, or link: ['<link mount>'])")
1325
- this.#unique(where, 'link', link)
1326
- return [...link]
1705
+ /** A file kind the contract declares, by the name a file field, a file output or a file column gives. */
1706
+ #fileKind(where: string, kind: string): FileKindArtifact | null {
1707
+ const found = Object.hasOwn(this.#fileKinds, kind) ? this.#fileKinds[kind] : undefined
1708
+ if (found === undefined) {
1709
+ const declared = Object.keys(this.#fileKinds)
1710
+ this.#problem(
1711
+ where,
1712
+ `names the file kind '${kind}', which the contract does not declare (allowed: ${
1713
+ declared.length === 0 ? 'files: { <kind>: c.files({ types, maxBytes }) }' : declared.join(', ')
1714
+ })`,
1715
+ )
1716
+ return null
1717
+ }
1718
+ return found
1327
1719
  }
1328
1720
 
1329
1721
  #jobs(): Record<string, JobArtifact> {
@@ -1333,35 +1725,46 @@ class ArtifactBuilder {
1333
1725
  if (!this.#name(where, name) || !this.#kind(where, job, 'cron', "c.cron('<schedule>', { … })")) {
1334
1726
  continue
1335
1727
  }
1336
- const scope = this.#job(where, job)
1728
+ this.#job(where, job)
1337
1729
  this.#declare(where, `${JOB_SITE_PREFIX}${name}`, job.declared, { kind: 'job', readOnly: false, actions: job.actions })
1338
- if (scope !== null) {
1339
- jobs[name] = {
1340
- schedule: job.schedule,
1341
- actions: [...job.actions],
1342
- scope,
1343
- timeout: job.timeout ?? null,
1344
- retries: job.retries ?? CONTRACT_DEFAULTS.jobRetries,
1345
- }
1730
+ jobs[name] = {
1731
+ schedule: job.schedule,
1732
+ actions: [...job.actions],
1733
+ scope: { kind: 'app' },
1734
+ timeoutMs: this.#jobLimits(where, job.limits),
1735
+ retries: job.retries ?? CONTRACT_DEFAULTS.jobRetries,
1346
1736
  }
1347
1737
  }
1348
1738
  return jobs
1349
1739
  }
1350
1740
 
1351
- #job(where: string, job: CronDeclaration): ScopeBinding | null {
1741
+ /** A job's `limits`: its attempt's time only, since a job reads no body. */
1742
+ #jobLimits(where: string, value: unknown): number {
1743
+ if (value === undefined) {
1744
+ return JOB_LIMITS.timeoutMs
1745
+ }
1746
+ if (!isRecord(value)) {
1747
+ this.#problem(where, 'limits is not an object (allowed: limits: { timeoutMs? })')
1748
+ return JOB_LIMITS.timeoutMs
1749
+ }
1750
+ if (value['maxBytes'] !== undefined) {
1751
+ this.#problem(where, 'declares limits.maxBytes, and a job reads no body (allowed: limits: { timeoutMs? })')
1752
+ }
1753
+ this.#keys(`${where} limits`, value, ['maxBytes', 'timeoutMs'])
1754
+ return this.#bounded(where, 'limits.timeoutMs', value['timeoutMs'], JOB_LIMITS.timeoutMs, JOB_LIMITS.maxTimeoutMs, 'ms')
1755
+ }
1756
+
1757
+ /** A job acts for the whole application: it holds its actions globally. */
1758
+ #job(where: string, job: CronDeclaration): void {
1352
1759
  this.#options(where, 'cron', job.unknownOptions)
1353
1760
  const fields = job.schedule.split(' ')
1354
1761
  if (fields.length !== 5 || !fields.every((field) => CRON_FIELD.test(field))) {
1355
1762
  this.#problem(where, `schedule '${job.schedule}' is not five cron fields separated by single spaces (allowed: '0 3 * * *')`)
1356
1763
  }
1357
1764
  this.#actionList(where, job.actions)
1358
- if (job.timeout !== undefined && !TIMEOUT.test(job.timeout)) {
1359
- this.#problem(where, `timeout '${job.timeout}' is not a duration (allowed: '30s', '10m', '1h')`)
1360
- }
1361
1765
  if (job.retries !== undefined && (!Number.isInteger(job.retries) || job.retries < 0)) {
1362
1766
  this.#problem(where, `retries ${job.retries} is not a whole number of at least 0`)
1363
1767
  }
1364
- return this.#scopeBinding(where, 'scope', job.scope)
1365
1768
  }
1366
1769
 
1367
1770
  #exports(): Record<string, ExportArtifact> {
@@ -1437,11 +1840,15 @@ class ArtifactBuilder {
1437
1840
  // `#exports` refused the app form already: a description needs one dimension.
1438
1841
  return null
1439
1842
  }
1843
+ if (first.kind === 'capabilities') {
1844
+ // `#exports` refused several capabilities already: a consumer's key holds one action.
1845
+ return null
1846
+ }
1440
1847
  const description: EndpointRequirement = first.kind === 'member' || dimension === null
1441
1848
  ? first
1442
1849
  : { kind: 'action', action: first.action, access: { form: 'over', dimension } }
1443
1850
  const same = requirements.every((requirement) =>
1444
- requirement !== null && (requirement.kind === 'member'
1851
+ requirement !== null && requirement.kind !== 'capabilities' && (requirement.kind === 'member'
1445
1852
  ? description.kind === 'member'
1446
1853
  : description.kind === 'action' && requirement.action === description.action && dimensionOf(requirement) === dimension)
1447
1854
  )
@@ -1564,17 +1971,30 @@ class ArtifactBuilder {
1564
1971
  ...(frameSrc.length === 0 ? {} : { frameSrc }),
1565
1972
  ...(styleSrc.length === 0 ? {} : { styleSrc }),
1566
1973
  ...(fontSrc.length === 0 ? {} : { fontSrc }),
1974
+ ...this.#challengeWorkers(),
1975
+ }
1976
+ }
1977
+
1978
+ /** `blob:` for workers, when a challenge provider's widget starts its own; the contract never declares it. */
1979
+ #challengeWorkers(): Pick<CspExtensions, 'workerSrc'> {
1980
+ const providers = [...new Set(this.#challenges.map((challenge) => challenge.provider))].sort()
1981
+ const needing = providers.filter((provider) => CHALLENGE_ORIGINS[provider].workerSrc.length > 0)
1982
+ if (needing.length === 0) {
1983
+ return {}
1567
1984
  }
1985
+ const sites = this.#challenges.filter((challenge) => needing.includes(challenge.provider)).map((challenge) => `'${challenge.site}'`).sort()
1986
+ const purpose = `the workers of the ${needing.join(' and ')} challenge of ${sites.join(', ')}`
1987
+ return { workerSrc: [{ origin: WORKER_BLOB_SOURCE, purpose }] }
1568
1988
  }
1569
1989
 
1570
- /** The declared sources, plus each fixed origin a form's challenge provider loads that the contract does not name itself. */
1990
+ /** The declared sources, plus each fixed origin a challenge provider loads that the contract does not name itself. */
1571
1991
  #withChallengeOrigins(directive: 'scriptSrc' | 'frameSrc', declared: readonly CspSource[]): CspSource[] {
1572
1992
  const sources = [...declared]
1573
1993
  for (const provider of new Set(this.#challenges.map((challenge) => challenge.provider))) {
1574
- const forms = this.#challenges.filter((challenge) => challenge.provider === provider).map((challenge) => challenge.form).sort()
1994
+ const sites = this.#challenges.filter((challenge) => challenge.provider === provider).map((challenge) => challenge.site).sort()
1575
1995
  for (const origin of CHALLENGE_ORIGINS[provider][directive]) {
1576
1996
  if (!sources.some((source) => source.origin === origin)) {
1577
- sources.push({ origin, purpose: `the ${provider} challenge of ${forms.map((form) => `'${form}'`).join(', ')}` })
1997
+ sources.push({ origin, purpose: `the ${provider} challenge of ${sites.map((site) => `'${site}'`).join(', ')}` })
1578
1998
  }
1579
1999
  }
1580
2000
  }
@@ -1596,6 +2016,15 @@ class ArtifactBuilder {
1596
2016
  return sources.map((source) => ({ origin: source.origin, purpose: source.purpose }))
1597
2017
  }
1598
2018
 
2019
+ /** An RPC mount's promise to its callers; `unchecked` when it makes none. */
2020
+ #compatibility(where: string, value: unknown): 'backward' | 'unchecked' {
2021
+ if (value === undefined || value === 'unchecked' || value === 'backward') {
2022
+ return value ?? 'unchecked'
2023
+ }
2024
+ this.#problem(where, `compatibility ${JSON.stringify(value)} is not a promise (allowed: compatibility: 'backward' or 'unchecked')`)
2025
+ return 'unchecked'
2026
+ }
2027
+
1599
2028
  // ── Declared reach ──────────────────────────────────────────────────────────────────────────
1600
2029
 
1601
2030
  /**
@@ -1616,8 +2045,19 @@ class ArtifactBuilder {
1616
2045
  this.#problem(where, `'${key}' is not a declared field (allowed: ${DECLARED_ALLOWED})`)
1617
2046
  }
1618
2047
  }
1619
- const then = this.#then(where, value['then'], place)
2048
+ const workload = place.kind === 'rpc' ? place.actions : null
2049
+ if (workload !== null) {
2050
+ for (const field of ['sql', 'unscoped', 'connect', 'issues', 'binds'] as const) {
2051
+ if (value[field] !== undefined) {
2052
+ this.#problem(where, `is public and declares ${field}: a public procedure reaches its actions only (allowed: kicks, signs)`)
2053
+ }
2054
+ }
2055
+ }
1620
2056
  const mailer = this.#mailer(where, value['mailer'], place)
2057
+ const kicks = this.#kicks(where, value['kicks'], place)
2058
+ const binds = this.#binds(where, value['binds'], place)
2059
+ const signs = this.#signs(where, value['signs'], place)
2060
+ const egress = this.#egress(where, value['egress'], place)
1621
2061
  const reach: DeclaredReach = {
1622
2062
  sql: this.#sqlMode(where, value['sql'], place.readOnly),
1623
2063
  unscoped: this.#unscoped(where, value['unscoped'], place.readOnly),
@@ -1627,8 +2067,12 @@ class ArtifactBuilder {
1627
2067
  : place.kind === 'job'
1628
2068
  ? this.#jobIssues(where, value['issues'], place.actions)
1629
2069
  : null,
1630
- ...(then === undefined ? {} : { then }),
1631
2070
  ...(mailer === undefined ? {} : { mailer }),
2071
+ ...(workload === null || workload.length === 0 ? {} : { actions: workload }),
2072
+ ...(kicks.length === 0 ? {} : { kicks }),
2073
+ ...(binds === undefined ? {} : { binds }),
2074
+ ...(signs.length === 0 ? {} : { signs }),
2075
+ ...(egress.length === 0 ? {} : { egress }),
1632
2076
  }
1633
2077
  if (place.kind !== 'rpc') {
1634
2078
  this.#rpcOnly(where, 'connect', value['connect'])
@@ -1637,8 +2081,9 @@ class ArtifactBuilder {
1637
2081
  this.#problem(where, 'declares issues, which only an endpoint of an RPC mount or a cron job declares')
1638
2082
  }
1639
2083
  if (
1640
- reach.sql !== null || Object.keys(reach.unscoped).length > 0 || reach.connect.length > 0 || reach.issues !== null || reach.then !== undefined
1641
- || reach.mailer !== undefined
2084
+ reach.sql !== null || Object.keys(reach.unscoped).length > 0 || reach.connect.length > 0 || reach.issues !== null
2085
+ || reach.mailer !== undefined || reach.actions !== undefined || reach.kicks !== undefined || reach.binds !== undefined
2086
+ || reach.signs !== undefined || reach.egress !== undefined
1642
2087
  ) {
1643
2088
  this.#declared[site] = reach
1644
2089
  }
@@ -1686,8 +2131,6 @@ class ArtifactBuilder {
1686
2131
  where,
1687
2132
  `declares unscoped '${table}': 'write', and no site writes a reference table: its rows come from a migration (allowed: 'read')`,
1688
2133
  )
1689
- } else if (mode === 'write' && scope === 'inbox') {
1690
- this.#problem(where, `declares unscoped '${table}': 'write', and only its form writes a form's inbox (allowed: 'read')`)
1691
2134
  }
1692
2135
  unscoped[table] = mode
1693
2136
  }
@@ -1767,24 +2210,152 @@ class ArtifactBuilder {
1767
2210
  return name
1768
2211
  }
1769
2212
 
1770
- /** The cron job a mutation kicks once its transaction committed; `undefined` when it declares none or after a problem. */
1771
- #then(where: string, value: unknown, place: DeclaringSite): string | undefined {
2213
+ /**
2214
+ * The webhook whose bindings a credentialed mutation of an RPC mount creates and removes: a webhook of this contract.
2215
+ * `undefined` when it declares none or after a problem.
2216
+ */
2217
+ #binds(where: string, value: unknown, place: DeclaringSite): string | undefined {
1772
2218
  if (value === undefined) {
1773
2219
  return undefined
1774
2220
  }
1775
- if (place.kind !== 'rpc' && place.kind !== 'link') {
1776
- this.#problem(where, 'declares then, which only a mutation of an RPC or link mount declares')
2221
+ if (place.kind !== 'rpc' || place.readOnly || place.actions !== null) {
2222
+ // A public procedure is refused with the other reach it may not declare.
2223
+ if (place.kind !== 'rpc' || place.actions === null) {
2224
+ this.#problem(where, 'declares binds, which only a credentialed mutation of an RPC mount declares')
2225
+ }
1777
2226
  return undefined
1778
2227
  }
1779
- if (place.readOnly) {
1780
- this.#problem(where, 'declares then, and it runs read-only (allowed: then on a mutation)')
2228
+ const mounts = this.#contract.mounts
2229
+ const webhook = typeof value === 'string' && Object.hasOwn(mounts, value) ? mounts[value] : undefined
2230
+ if (typeof value !== 'string' || webhook?.kind !== 'webhook') {
2231
+ const names = Object.entries(mounts).filter(([, mount]) => mount.kind === 'webhook').map(([name]) => name)
2232
+ this.#problem(
2233
+ where,
2234
+ `binds ${JSON.stringify(value)} is not a webhook of the contract (webhooks: ${names.length === 0 ? 'none' : names.join(', ')})${
2235
+ typeof value === 'string' ? suggestion(value, names) : ''
2236
+ }`,
2237
+ )
1781
2238
  return undefined
1782
2239
  }
1783
- if (typeof value !== 'string') {
1784
- this.#problem(where, "then is not a job's name (allowed: then: '<cron job>')")
1785
- return undefined
2240
+ return value
2241
+ }
2242
+
2243
+ /**
2244
+ * The token purposes a site signs (`ctx.tokens.sign`): a procedure of an RPC mount, a webhook or a job, never an export
2245
+ * or a link procedure. Each names a purpose of `defineApp`'s options, once; `[]` when it declares none or after a problem.
2246
+ */
2247
+ #signs(where: string, value: unknown, place: DeclaringSite): string[] {
2248
+ if (value === undefined) {
2249
+ return []
2250
+ }
2251
+ if (place.kind === 'export' || place.kind === 'link') {
2252
+ this.#problem(
2253
+ where,
2254
+ `declares signs, which ${
2255
+ place.kind === 'export' ? 'an export procedure' : 'a link procedure'
2256
+ } never does: a procedure of an RPC mount, a webhook or a job signs`,
2257
+ )
2258
+ return []
2259
+ }
2260
+ if (!Array.isArray(value) || value.length === 0 || !value.every((purpose) => typeof purpose === 'string')) {
2261
+ this.#problem(where, "signs is not a list of token purposes (allowed: signs: ['<token purpose>'])")
2262
+ return []
2263
+ }
2264
+ const purposes: string[] = value.filter((purpose) => typeof purpose === 'string')
2265
+ this.#unique(where, 'signs', purposes)
2266
+ for (const purpose of purposes) {
2267
+ this.#knownPurpose(where, purpose)
2268
+ }
2269
+ return [...new Set(purposes)].sort()
2270
+ }
2271
+
2272
+ /**
2273
+ * The HTTPS origins a site's code calls: a credentialed procedure of an RPC mount, a webhook or a job, never a public,
2274
+ * export or link procedure. Each is an exact origin with no path or wildcard, once; `[]` when it declares none or after
2275
+ * a problem.
2276
+ */
2277
+ #egress(where: string, value: unknown, place: DeclaringSite): string[] {
2278
+ if (value === undefined) {
2279
+ return []
2280
+ }
2281
+ const refusedBy = place.kind === 'export'
2282
+ ? 'an export procedure'
2283
+ : place.kind === 'link'
2284
+ ? 'a link procedure'
2285
+ : place.kind === 'rpc' && place.actions !== null
2286
+ ? 'a public procedure'
2287
+ : null
2288
+ if (refusedBy !== null) {
2289
+ this.#problem(where, `declares egress, which ${refusedBy} never does: a credentialed procedure, a webhook or a job calls out`)
2290
+ return []
2291
+ }
2292
+ if (!Array.isArray(value) || value.length === 0 || !value.every((origin) => typeof origin === 'string')) {
2293
+ this.#problem(where, "egress is not a list of origins (allowed: egress: ['https://api.example.com'])")
2294
+ return []
2295
+ }
2296
+ const origins: string[] = value.filter((origin) => typeof origin === 'string')
2297
+ if (origins.length > MAX_EGRESS_ORIGINS) {
2298
+ this.#problem(where, `${origins.length} egress origins exceed the limit of ${MAX_EGRESS_ORIGINS}`)
2299
+ }
2300
+ for (const origin of origins) {
2301
+ if (!isPolicyOrigin(origin)) {
2302
+ this.#problem(where, `egress '${origin}' is not an https origin (allowed: 'https://api.example.com', no path or wildcard)`)
2303
+ }
2304
+ }
2305
+ this.#unique(where, 'egress', origins)
2306
+ return [...new Set(origins)].sort()
2307
+ }
2308
+
2309
+ /**
2310
+ * The cron jobs a site may kick (`ctx.kick`): a mutation of an RPC or link mount, a webhook or a job, never a site
2311
+ * that runs read-only. Each names a cron job of the contract, once; `[]` when it declares none or after a problem.
2312
+ */
2313
+ #kicks(where: string, value: unknown, place: DeclaringSite): string[] {
2314
+ if (value === undefined) {
2315
+ return []
2316
+ }
2317
+ if (place.kind === 'export' || place.readOnly) {
2318
+ this.#problem(where, 'declares kicks, and it runs read-only: only a mutation, a webhook or a job kicks a job')
2319
+ return []
2320
+ }
2321
+ if (!Array.isArray(value) || value.length === 0 || !value.every((name) => typeof name === 'string')) {
2322
+ this.#problem(where, "kicks is not a list of cron jobs (allowed: kicks: ['<cron job>'])")
2323
+ return []
2324
+ }
2325
+ const names: string[] = value.filter((name) => typeof name === 'string')
2326
+ this.#unique(where, 'kicks', names)
2327
+ for (const name of names) {
2328
+ this.#kickedJob(where, name)
2329
+ }
2330
+ return [...new Set(names)]
2331
+ }
2332
+
2333
+ /**
2334
+ * The kick edges between jobs are acyclic: a job never kicks itself, directly or around a cycle, so one run never
2335
+ * starts an unbounded chain of runs.
2336
+ */
2337
+ #checkKickCycles(): void {
2338
+ const edges = new Map<string, readonly string[]>()
2339
+ for (const [site, reach] of Object.entries(this.#declared)) {
2340
+ if (site.startsWith(JOB_SITE_PREFIX) && reach.kicks !== undefined) {
2341
+ edges.set(site.slice(JOB_SITE_PREFIX.length), reach.kicks)
2342
+ }
2343
+ }
2344
+ const done = new Set<string>()
2345
+ const visit = (job: string, path: readonly string[]): void => {
2346
+ if (path.includes(job)) {
2347
+ this.#problem(`job '${job}'`, `kicks itself around the cycle ${[...path.slice(path.indexOf(job)), job].join(' → ')}`)
2348
+ return
2349
+ }
2350
+ if (done.has(job)) return
2351
+ for (const next of edges.get(job) ?? []) {
2352
+ visit(next, [...path, job])
2353
+ }
2354
+ done.add(job)
2355
+ }
2356
+ for (const job of [...edges.keys()].sort()) {
2357
+ visit(job, [])
1786
2358
  }
1787
- return this.#kickedJob(where, value)
1788
2359
  }
1789
2360
 
1790
2361
  /** The mail transport a cron job reaches; `undefined` when it declares none or after a problem. */
@@ -1814,15 +2385,12 @@ class ArtifactBuilder {
1814
2385
 
1815
2386
  #checkTables(): Record<string, TablePolicyArtifact> {
1816
2387
  const owners = new Map<string, string>()
1817
- const memberActions = new Set(Object.values(this.#contract.roles).flatMap((role) => role.member === undefined ? [] : role.grants))
1818
2388
  const tables: Record<string, TablePolicyArtifact> = {}
1819
2389
  for (const [name, table] of Object.entries(this.#tables)) {
1820
2390
  const where = `table '${name}'`
1821
2391
  const { read, insert, update, delete: remove, appRead = [], appCount = [] } = table.actions
1822
- for (const action of [read, insert, update, remove, ...appRead, ...appCount]) {
1823
- if (action !== null) {
1824
- this.#knownAction(where, action)
1825
- }
2392
+ for (const action of [read, insert, update, remove].flatMap(tableActions).concat(appRead, appCount)) {
2393
+ this.#knownAction(where, action)
1826
2394
  }
1827
2395
  const scope = table.scope
1828
2396
  if (scope.mode === 'dimension') {
@@ -1845,47 +2413,17 @@ class ArtifactBuilder {
1845
2413
  this.#problem(where, `its parent '${scope.parent}' is not a table with a dimension scope`)
1846
2414
  }
1847
2415
  } else if (scope.mode === 'inbox') {
1848
- const form = this.#contract.mounts[scope.form]
1849
- if (form?.kind !== 'form' || form.appendTo !== name) {
1850
- this.#problem(where, `is the inbox of '${scope.form}', which is not a c.form mount that appends to '${name}'`)
1851
- }
1852
- if (memberActions.has(read)) {
1853
- this.#problem(where, `its read action '${read}' is granted by a membership role (allowed: an action only application-level roles grant)`)
1854
- }
1855
- }
1856
- tables[name] = table
1857
- }
1858
- for (const [name, mount] of Object.entries(this.#contract.mounts)) {
1859
- if (mount.kind === 'form') {
1860
- const inbox = this.#tables[mount.appendTo]?.scope
1861
- if (inbox?.mode !== 'inbox' || inbox.form !== name) {
1862
- this.#problem(`mount '${name}'`, `appends to '${mount.appendTo}', which is not an inbox table of this form (scope: { inbox: '${name}', … })`)
1863
- } else {
1864
- this.#inboxFields(name, mount)
1865
- }
1866
- }
1867
- }
1868
- return tables
1869
- }
1870
-
1871
- /** Each input field of a form is a column of its inbox, of the same name; the honeypot and the challenge token are not. */
1872
- #inboxFields(name: string, mount: FormMountDeclaration<SchemaShape>): void {
1873
- const columns = Object.hasOwn(this.#inboxes, mount.appendTo) ? this.#inboxes[mount.appendTo] : undefined
1874
- if (columns === undefined || mount.input.type !== 'object') {
1875
- return
1876
- }
1877
- const fileFields = typeof mount.files === 'object' && mount.files !== null ? Object.keys(mount.files) : []
1878
- for (const field of [...Object.keys(mount.input.fields), ...fileFields]) {
1879
- const exempt = field === mount.honeypot || (mount.challenge !== undefined && field === FORM_CHALLENGE_FIELD)
1880
- if (!exempt && !columns.includes(field)) {
1881
2416
  this.#problem(
1882
- `mount '${name}'`,
1883
- `the ${fileFields.includes(field) ? 'file' : 'input'} field '${field}' is not a column of its inbox ${mount.appendTo}${
1884
- suggestion(field, columns)
1885
- } (allowed: a field named like a column: ${columns.join(', ')})`,
2417
+ where,
2418
+ `is a form inbox, and no form fills one any more: make it an application table (scope: { app: '<why>' }) a public procedure inserts into`,
1886
2419
  )
1887
2420
  }
2421
+ for (const [column, kind] of Object.entries(table.files ?? {})) {
2422
+ this.#fileKind(`${where} files.${column}`, kind)
2423
+ }
2424
+ tables[name] = table
1888
2425
  }
2426
+ return tables
1889
2427
  }
1890
2428
 
1891
2429
  // ── Shared checks ───────────────────────────────────────────────────────────────────────────
@@ -2009,14 +2547,139 @@ class ArtifactBuilder {
2009
2547
 
2010
2548
  /** Where a declaration stands: what kind of site, whether it runs read-only, and an RPC endpoint's access form. */
2011
2549
  type DeclaringSite =
2012
- | { readonly kind: 'rpc'; readonly readOnly: boolean; readonly access: AccessDeclaration }
2550
+ /** `actions` are a public procedure's workload actions; `null` for a credentialed procedure. */
2551
+ | { readonly kind: 'rpc'; readonly readOnly: boolean; readonly access: AccessDeclaration; readonly actions: readonly string[] | null }
2013
2552
  | { readonly kind: 'job'; readonly readOnly: boolean; readonly actions: readonly string[] }
2014
2553
  | { readonly kind: 'link' | 'export' | 'webhook'; readonly readOnly: boolean }
2015
2554
 
2555
+ /** The prefix a mount serves under, or a health or webhook mount's own path. */
2556
+ function mountPath(mount: MountDeclaration<SchemaShape>): string {
2557
+ return mount.kind === 'health' || mount.kind === 'webhook' ? mount.path : mount.prefix
2558
+ }
2559
+
2016
2560
  const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === 'object' && value !== null && !Array.isArray(value)
2017
2561
 
2562
+ /** What a procedure's row takes from its rules: the body limit and the origin rule. */
2563
+ /** A procedure's body codec: `multipart` is derived from its file fields, never declared. */
2564
+ type ProcedureBody = 'json' | 'urlencoded' | 'multipart'
2565
+
2566
+ interface ProcedureRules {
2567
+ readonly method: 'GET' | 'POST'
2568
+ /** `null` for a GET, which takes no body. */
2569
+ readonly body: ProcedureBody | null
2570
+ readonly maxBytes: number
2571
+ readonly origins: AdmissionOrigins
2572
+ /** A multipart procedure whose every file field may be left empty also takes a JSON body. */
2573
+ readonly filesOptional?: boolean
2574
+ /** A public GET query's `http.cache`. */
2575
+ readonly cache?: EndpointCache
2576
+ }
2577
+
2578
+ /** What a procedure's file fields add to its body: their bytes together, the largest one, and whether every kind scans. */
2579
+ interface InputFiles {
2580
+ readonly bytes: number
2581
+ readonly largest: number
2582
+ readonly scanning: boolean
2583
+ readonly optional: boolean
2584
+ }
2585
+
2586
+ /** A top-level input field that holds files: `file(k)`, optional, or an array bounded by `.max(n)`. */
2587
+ function fileField(shape: SchemaShape): { readonly kind: string; readonly count: number; readonly required: boolean } | 'unbounded' | null {
2588
+ if (shape.type === 'file') {
2589
+ return { kind: shape.kind, count: 1, required: true }
2590
+ }
2591
+ if (shape.type === 'optional') {
2592
+ const inner = fileField(shape.inner)
2593
+ return inner === null || inner === 'unbounded' ? inner : { ...inner, required: false }
2594
+ }
2595
+ if (shape.type === 'array' && shape.items.type === 'file') {
2596
+ return shape.maxItems === null ? 'unbounded' : { kind: shape.items.kind, count: shape.maxItems, required: (shape.minItems ?? 0) > 0 }
2597
+ }
2598
+ return null
2599
+ }
2600
+
2601
+ /** Whether a schema holds a file anywhere. */
2602
+ export function containsFile(shape: SchemaShape): boolean {
2603
+ switch (shape.type) {
2604
+ case 'file':
2605
+ return true
2606
+ case 'array':
2607
+ return containsFile(shape.items)
2608
+ case 'tuple':
2609
+ return shape.items.some(containsFile) || (shape.rest !== null && containsFile(shape.rest))
2610
+ case 'record':
2611
+ return containsFile(shape.value)
2612
+ case 'object':
2613
+ return Object.values(shape.fields).some(containsFile)
2614
+ case 'optional':
2615
+ case 'nullable':
2616
+ return containsFile(shape.inner)
2617
+ case 'union':
2618
+ return shape.options.some(containsFile)
2619
+ default:
2620
+ return false
2621
+ }
2622
+ }
2623
+
2624
+ /** The file fields of an input, by name: the parts a multipart body carries beside its JSON. */
2625
+ export function fileFieldsOf(input: SchemaShape | null): ReadonlyMap<string, FileShape & { readonly many: boolean; readonly max: number }> {
2626
+ const fields = new Map<string, FileShape & { readonly many: boolean; readonly max: number }>()
2627
+ if (input?.type !== 'object') {
2628
+ return fields
2629
+ }
2630
+ for (const [name, shape] of Object.entries(input.fields)) {
2631
+ const inner = shape.type === 'optional' ? shape.inner : shape
2632
+ if (inner.type === 'file') {
2633
+ fields.set(name, { type: 'file', kind: inner.kind, many: false, max: 1 })
2634
+ } else if (inner.type === 'array' && inner.items.type === 'file') {
2635
+ fields.set(name, { type: 'file', kind: inner.items.kind, many: true, max: inner.maxItems ?? 0 })
2636
+ }
2637
+ }
2638
+ return fields
2639
+ }
2640
+
2641
+ type AdmissionOrigins = 'same-origin' | 'listed' | 'any'
2642
+
2643
+ /** The method and body admission of a procedure's row: POST with a JSON body, unlimited, for a `defineContract` one. */
2644
+ function rowWire(rules: ProcedureRules | null): Pick<EndpointRow, 'method' | 'admission' | 'cache'> {
2645
+ if (rules === null) {
2646
+ return { method: 'POST', admission: JSON_ADMISSION }
2647
+ }
2648
+ if (rules.body === null) {
2649
+ return { method: rules.method, admission: null, ...(rules.cache === undefined ? {} : { cache: rules.cache }) }
2650
+ }
2651
+ if (rules.body === 'multipart') {
2652
+ const contentTypes: readonly [string, ...string[]] = rules.filesOptional === true
2653
+ ? ['application/json', FORM_MULTIPART_TYPE]
2654
+ : [FORM_MULTIPART_TYPE]
2655
+ return { method: rules.method, admission: { contentTypes, maxBytes: rules.maxBytes } }
2656
+ }
2657
+ return {
2658
+ method: rules.method,
2659
+ admission: { contentTypes: [rules.body === 'urlencoded' ? FORM_URLENCODED_TYPE : 'application/json'], maxBytes: rules.maxBytes },
2660
+ }
2661
+ }
2662
+
2663
+ /** A described schema: what `describeContract` and the reader leave where a contract wrote one. */
2664
+ function isSchemaShape(value: unknown): value is SchemaShape {
2665
+ return isRecord(value) && typeof value['type'] === 'string'
2666
+ }
2667
+
2668
+ function nonEmpty<T>(list: readonly T[]): readonly [T, ...T[]] | null {
2669
+ const [first, ...rest] = list
2670
+ return first === undefined ? null : [first, ...rest]
2671
+ }
2672
+
2673
+ function isPublicAccess(access: unknown): boolean {
2674
+ return isRecord(access) && access['public'] === true
2675
+ }
2676
+
2018
2677
  type ResolvedAccess = { readonly form: 'member' } | { readonly form: 'action'; readonly action: string; readonly access: AccessForm }
2019
2678
 
2679
+ function isCapabilityForm(access: AccessForm): access is CapabilityAccessForm {
2680
+ return access.form === 'over' || access.form === 'in' || (access.form === 'on' && access.from !== null)
2681
+ }
2682
+
2020
2683
  /** The one access form the declaration spells, with exactly its keys; a mixture of two forms is none. */
2021
2684
  function accessForm(access: AccessDeclaration): ResolvedAccess | null {
2022
2685
  if ('member' in access) {
@@ -2046,6 +2709,11 @@ function accessForm(access: AccessDeclaration): ResolvedAccess | null {
2046
2709
  return null
2047
2710
  }
2048
2711
 
2712
+ /** A value one URL or urlencoded parameter carries: a scalar, or a signed token, possibly absent. */
2713
+ function isParameter(shape: SchemaShape): boolean {
2714
+ return isCell(shape) || shape.type === 'token' || (shape.type === 'optional' && shape.inner.type === 'token')
2715
+ }
2716
+
2049
2717
  /** A value one CSV field holds: a scalar, possibly absent. */
2050
2718
  function isCell(shape: SchemaShape): boolean {
2051
2719
  switch (shape.type) {
@@ -2081,11 +2749,6 @@ function isRequiredString(shape: SchemaShape): boolean {
2081
2749
  }
2082
2750
  }
2083
2751
 
2084
- /** `z.string()` or `z.optional(z.string())`: a field a page can leave empty, such as a honeypot. */
2085
- function isStringField(shape: SchemaShape): boolean {
2086
- return shape.type === 'string' || (shape.type === 'optional' && shape.inner.type === 'string')
2087
- }
2088
-
2089
2752
  function hasExactKeys(value: object, keys: readonly string[]): boolean {
2090
2753
  const own = Object.keys(value)
2091
2754
  return own.length === keys.length && keys.every((key) => own.includes(key))