@fabrika/app 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +387 -236
  10. package/skill/references/spa.md +5 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
@@ -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
61
  import { isPolicyOrigin, MAX_CSP_ORIGINS } 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,
@@ -74,29 +83,30 @@ import {
74
83
  } from './declaration'
75
84
  import {
76
85
  ATTACHMENT_LIMITS,
86
+ CHALLENGE_ACTION,
87
+ CHALLENGE_FIELD,
77
88
  CHALLENGE_ORIGINS,
89
+ challengeAction,
78
90
  CONNECT_LIMITS,
79
91
  CONNECT_PREFIX,
80
92
  CONTRACT_DEFAULTS,
81
93
  exportBase,
82
94
  FILE_LIMITS,
83
- fileCollectionPaths,
84
- FILES_PREFIX,
85
- FORM_CHALLENGE_FIELD,
86
- FORM_FILE_LIMITS,
87
- FORM_INPUT_PART,
88
- FORM_LIMITS,
89
95
  FORM_MULTIPART_TYPE,
96
+ JOB_LIMITS,
90
97
  joinPath,
91
98
  JSON_ADMISSION,
92
99
  LINK_API_SEGMENT,
100
+ PROCEDURE_LIMITS,
101
+ procedurePath,
102
+ PUBLIC_MUTATION_RATE,
93
103
  spaPrefixRowId,
94
- WEBHOOK_ACCOUNT_PATTERN,
95
104
  WEBHOOK_BINDING_PARAMETER,
96
- WEBHOOK_SCOPE_FIELD,
97
- WEBHOOKS_PREFIX,
105
+ WEBHOOK_LIMITS,
98
106
  } from './defaults'
99
107
  import { ContractError, Problems } from './problems'
108
+ import { TOKEN_MAX_DAYS } from './token'
109
+ import { hmacSchemeSchema, WEBHOOK_SCHEME_ALLOWED, webhookSchemeSchema } from './webhook-scheme'
100
110
 
101
111
  const APP_ID = /^[a-z0-9][a-z0-9-]*$/
102
112
  const NAME = /^[A-Za-z][A-Za-z0-9_]*$/
@@ -106,7 +116,6 @@ const ACTION = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/
106
116
  const LITERAL_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
107
117
  const MIME_TYPE = /^[a-z]+\/[a-z0-9][a-z0-9.+-]*$/
108
118
  const CRON_FIELD = /^[0-9*/,-]+$/
109
- const TIMEOUT = /^[1-9][0-9]*[smh]$/
110
119
  const DIGEST = /^sha256:[0-9a-f]+$/
111
120
  const FRAMEWORK_PREFIX = '/__fabrika'
112
121
  /** A fixed name a header carries as it is: no path, no quoting, no extension but the format's. */
@@ -114,97 +123,25 @@ const ATTACHMENT_FILENAMES: Readonly<Record<AttachmentFormat, RegExp>> = {
114
123
  csv: /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.csv$/,
115
124
  xlsx: /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.xlsx$/,
116
125
  }
117
- /** The `boundTo` and job `scope` value for the whole application; no dimension may take the name. */
126
+ /** The `boundTo` value for the whole application; no dimension may take the name. */
118
127
  const APP_SCOPE = 'app'
119
128
  const AUDIENCE = 'any consumer user'
120
129
  const IMPORT_FIELDS = ['app', 'export', 'version', 'digest', 'procedures', 'usedBy', 'timeoutMs']
121
130
  /** Tables the framework keeps in the application's schema; no site reaches them unscoped. */
122
131
  const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
132
+ /** The most origins one site calls: a site that reaches more is an integration hub, which a connector import serves. */
133
+ const MAX_EGRESS_ORIGINS = 16
123
134
  const DECLARED_ALLOWED =
124
- "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', then: '<cron job>', mailer: 'installation' | 'own'"
135
+ "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
136
  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. */
137
+ /** An environment variable an application reads; a challenge names its secret by one. */
127
138
  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
- }
139
+ /** Turnstile's `action`: the procedure's endpoint id, which the verification expects back. */
187
140
 
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 {
141
+ /** Validates a described contract against its table policies and normalizes it; throws `ContractError` with every problem. */
142
+ export function buildArtifact(contract: ContractDeclaration<SchemaShape>, tables: Readonly<Record<string, TablePolicyArtifact>>): ContractArtifact {
206
143
  const problems = new Problems()
207
- const artifact = canonicalizeArtifact(new ArtifactBuilder(contract, tables, inboxes, problems).build())
144
+ const artifact = canonicalizeArtifact(new ArtifactBuilder(contract, tables, problems).build())
208
145
  problems.throwIfAny()
209
146
  // A check missing above must surface here, not in the proxy or Control that reads the artifact later.
210
147
  const parsed = contractArtifactSchema.safeParse(JSON.parse(JSON.stringify(artifact)))
@@ -217,29 +154,32 @@ export function buildArtifact(
217
154
  class ArtifactBuilder {
218
155
  readonly #contract: ContractDeclaration<SchemaShape>
219
156
  readonly #tables: Readonly<Record<string, TablePolicyArtifact>>
220
- readonly #inboxes: InboxColumns
221
157
  readonly #problems: Problems
222
158
  readonly #endpoints: EndpointRow[] = []
223
159
  readonly #reservedPrefixes: string[] = []
224
160
  readonly #declared: Record<string, DeclaredReach> = {}
161
+ /** Each procedure of the staged kit's rules, by endpoint id. */
162
+ readonly #procedureRules: Record<string, ProcedureArtifact> = {}
225
163
  /** Whether any site is the staged kit's, whose reach is only what it declares; `defineContract`'s sites are not. */
226
164
  #kit = false
227
165
  /** The endpoints that declare `connect`, by the import they name. */
228
166
  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 }[] = []
167
+ /** The procedures that declare a challenge: their provider's fixed origins join the CSP, and their secret stays private. */
168
+ readonly #challenges: { readonly site: string; readonly provider: ChallengeProvider; readonly secret: string; readonly action: string }[] = []
169
+ /** The public procedures served at a path of their own (`http.path`), checked against every mount once all are read. */
170
+ readonly #ownPaths: { readonly where: string; readonly path: string }[] = []
231
171
  /** Each variable a browser config serves, and where the contract names it. */
232
172
  readonly #publicVariables: { readonly where: string; readonly field: string; readonly name: string }[] = []
173
+ /** The file kinds the contract declares, by name: read before the mounts, whose file fields and outputs name them. */
174
+ #fileKinds: Record<string, FileKindArtifact> = {}
233
175
 
234
176
  constructor(
235
177
  contract: ContractDeclaration<SchemaShape>,
236
178
  tables: Readonly<Record<string, TablePolicyArtifact>>,
237
- inboxes: InboxColumns,
238
179
  problems: Problems,
239
180
  ) {
240
181
  this.#contract = contract
241
182
  this.#tables = tables
242
- this.#inboxes = inboxes
243
183
  this.#problems = problems
244
184
  }
245
185
 
@@ -251,6 +191,8 @@ class ArtifactBuilder {
251
191
  const actions = this.#actions()
252
192
  const dimensions = this.#dimensions()
253
193
  const roles = this.#roles()
194
+ const tokens = this.#tokens()
195
+ this.#fileKinds = this.#readFileKinds()
254
196
  const mounts: Record<string, MountArtifact> = {}
255
197
  for (const [name, mount] of Object.entries(contract.mounts)) {
256
198
  if (this.#name(`mount '${name}'`, name)) {
@@ -261,26 +203,33 @@ class ArtifactBuilder {
261
203
  }
262
204
  }
263
205
  this.#checkPublicVariables()
264
- const files = this.#files(dimensions)
206
+ this.#checkOwnPaths()
207
+ for (const row of this.#endpoints) {
208
+ if (row.kind === 'webhook' && row.input !== null && containsFile(row.input)) {
209
+ this.#problem(`mount '${row.id}'`, "takes a file in its input, which only a procedure's top-level field does (file('<kind>'))")
210
+ }
211
+ }
265
212
  const jobs = this.#jobs()
266
213
  const exports = this.#exports()
267
214
  const imports = this.#imports()
268
215
  const csp = this.#csp(contract.csp)
269
216
  const tables = this.#checkTables()
270
- this.#reserve(this.#endpoints.some((row) => row.source.kind === 'files') ? FILES_PREFIX : null)
271
217
  this.#reserve(this.#endpoints.some((row) => row.source.kind === 'export') ? CONNECT_PREFIX : null)
218
+ this.#checkKickCycles()
272
219
  const directories = this.#directories(dimensions)
273
220
  const locale = this.#locale()
274
221
  const displayName = this.#displayName()
275
222
  const mail = this.#mail()
276
223
  const people = this.#people()
277
224
  const artifact: ContractArtifact = {
225
+ format: CONTRACT_FORMAT,
278
226
  app: contract.app,
279
227
  dimensions,
280
228
  actions,
281
229
  roles,
282
230
  mounts,
283
- files,
231
+ files: {},
232
+ ...(Object.keys(this.#fileKinds).length === 0 ? {} : { fileKinds: this.#fileKinds }),
284
233
  jobs,
285
234
  exports,
286
235
  imports,
@@ -294,6 +243,8 @@ class ArtifactBuilder {
294
243
  ...(displayName === null ? {} : { displayName }),
295
244
  ...(mail === null ? {} : { mail }),
296
245
  ...people,
246
+ ...(Object.keys(this.#procedureRules).length === 0 ? {} : { procedures: this.#procedureRules }),
247
+ ...(Object.keys(tokens).length === 0 ? {} : { tokens }),
297
248
  }
298
249
  try {
299
250
  assertNoAmbiguity(artifact)
@@ -498,10 +449,17 @@ class ArtifactBuilder {
498
449
  this.#reserve(mount.prefix)
499
450
  for (const [path, procedure] of procedures(mount.procedures)) {
500
451
  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 })
452
+ const callers = nonEmpty(mount.callers)
453
+ const actions = this.#procedure({ kind: 'mount', name }, id, procedurePath(mount.prefix, path, procedure.http), callers, procedure)
454
+ this.#declare(`procedure '${id}'`, id, procedure.declared, {
455
+ kind: 'rpc',
456
+ readOnly: procedure.type === 'query',
457
+ access: procedure.access,
458
+ actions,
459
+ })
503
460
  }
504
- return { kind: 'rpc', prefix: mount.prefix, callers: mount.callers }
461
+ const compatibility = this.#compatibility(where, mount.compatibility)
462
+ return { kind: 'rpc', prefix: mount.prefix, callers: [...mount.callers], ...(compatibility === 'backward' ? { compatibility } : {}) }
505
463
  }
506
464
  case 'link':
507
465
  return this.#link(name, mount)
@@ -523,205 +481,71 @@ class ArtifactBuilder {
523
481
  this.#row({ id, source, kind: 'spa', method: 'GET', path, callers: ['human'], requirement, admission: null, input: null, output: null })
524
482
  }
525
483
  }
526
- const publicVariables = this.#spaPublic(where, mount.public)
484
+ const publicVariables = this.#browserConfig(where, mount.browserConfig)
527
485
  return { kind: 'spa', prefix: mount.prefix, dir: mount.dir, ...(publicVariables.length === 0 ? {} : { public: publicVariables }) }
528
486
  }
529
487
  case 'health':
530
488
  this.#prefix(where, mount.path)
531
489
  this.#anonymous(name, 'health', 'GET', mount.path, null, null)
532
490
  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
491
  case 'webhook': {
557
492
  this.#prefix(where, mount.path)
558
493
  this.#objectSchema(`${where} input`, mount.input)
559
494
  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
495
  this.#actionList(where, mount.actions)
565
496
  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)
497
+ const limits = this.#limits(where, mount.limits, WEBHOOK_LIMITS, null, WEBHOOK_LIMITS.maxMaxBytes)
498
+ const scheme = this.#webhookScheme(where, mount.scheme)
499
+ // A verifier that compares `Authorization` with the binding's secret needs the proxy to pass it on.
500
+ const forwards = scheme !== null && typeof scheme === 'object' && !('hmac' in scheme)
501
+ // Slack posts slash commands and Twilio its callbacks as forms; every other provider posts JSON.
502
+ const forms = scheme === 'slack' || scheme === 'twilio'
503
+ this.#anonymous(
504
+ name,
505
+ 'webhook',
506
+ 'POST',
507
+ path,
508
+ { contentTypes: forms ? ['application/json', FORM_URLENCODED_TYPE] : ['application/json'], maxBytes: limits.maxBytes },
509
+ mount.input,
510
+ forwards ? { authorization: 'forward' } : {},
511
+ )
567
512
  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] }
513
+ return boundTo === null || scheme === null
514
+ ? null
515
+ : { kind: 'webhook', path: mount.path, scheme, boundTo, actions: [...mount.actions], timeoutMs: limits.timeoutMs }
572
516
  }
573
517
  default:
574
- this.#problem(where, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health, c.form or c.webhook)')
518
+ this.#problem(where, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health or app.webhook)')
575
519
  return null
576
520
  }
577
521
  }
578
522
 
579
- /** The job a form or a mutation kicks: a cron job of this contract. */
580
- #kickedJob(where: string, then: string): string {
523
+ /** The job a mutation, a webhook or a job kicks: a cron job of this contract. */
524
+ #kickedJob(where: string, name: string): string {
581
525
  const jobs = this.#contract.jobs
582
- const job: { readonly kind: string } | undefined = Object.hasOwn(jobs, then) ? jobs[then] : undefined
526
+ const job: { readonly kind: string } | undefined = Object.hasOwn(jobs, name) ? jobs[name] : undefined
583
527
  if (job === undefined) {
584
528
  const names = Object.keys(jobs)
585
529
  this.#problem(
586
530
  where,
587
- `then '${then}' is not a job of the contract (jobs: ${names.length === 0 ? 'none' : names.join(', ')})${suggestion(then, names)}`,
531
+ `kicks '${name}' is not a job of the contract (jobs: ${names.length === 0 ? 'none' : names.join(', ')})${suggestion(name, names)}`,
588
532
  )
589
533
  } else if (job.kind !== 'cron') {
590
- this.#problem(where, `then '${then}' is not a cron job (allowed: a job c.cron declares)`)
534
+ this.#problem(where, `kicks '${name}' is not a cron job (allowed: a job c.cron declares)`)
591
535
  }
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)
601
- }
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
536
+ return name
618
537
  }
619
538
 
620
539
  /**
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.
540
+ * A public procedure's challenge; `id` is its endpoint id, whose widget action is `challengeAction(id)` unless it
541
+ * declares its own `action`.
624
542
  */
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'])
543
+ #challenge(id: string, where: string, challenge: object, fields: Readonly<Record<string, SchemaShape>>): ProcedureChallenge | undefined {
544
+ this.#keys(`${where} challenge`, challenge, ['provider', 'secret', 'siteKey', 'action'])
722
545
  const provider: unknown = Reflect.get(challenge, 'provider')
723
546
  const secret: unknown = Reflect.get(challenge, 'secret')
724
547
  const siteKey: unknown = Reflect.get(challenge, 'siteKey')
548
+ const declaredAction: unknown = Reflect.get(challenge, 'action')
725
549
  if (provider !== 'turnstile') {
726
550
  this.#problem(where, `challenge provider ${JSON.stringify(provider)} is not one the framework verifies (allowed: 'turnstile')`)
727
551
  return undefined
@@ -735,33 +559,59 @@ class ArtifactBuilder {
735
559
  )
736
560
  return undefined
737
561
  }
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]
562
+ const action = this.#challengeAction(id, where, declaredAction)
563
+ const token = fields[CHALLENGE_FIELD]
742
564
  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`)
565
+ this.#problem(where, `declares a challenge, so its input needs the required field ${CHALLENGE_FIELD}: z.string(), the widget's token`)
744
566
  }
745
- this.#challenges.push({ form: name, provider, secret })
567
+ const sharing = this.#challenges.find((other) => other.action === action)
568
+ if (sharing !== undefined) {
569
+ this.#problem(
570
+ where,
571
+ `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)`,
572
+ )
573
+ }
574
+ this.#challenges.push({ site: id, provider, secret, action })
746
575
  if (siteKey === undefined || !this.#applicationVariable(where, 'challenge siteKey', siteKey, 'TURNSTILE_SITE_KEY')) {
747
- return { provider, secret }
576
+ return { provider, secret, action }
748
577
  }
749
578
  this.#publicVariables.push({ where, field: 'challenge siteKey', name: siteKey })
750
- return { provider, secret, siteKey }
579
+ return { provider, secret, action, siteKey }
580
+ }
581
+
582
+ /** The widget action a challenge's verification expects back: the declared `action`, else the endpoint id with `_` for `.`. */
583
+ #challengeAction(id: string, where: string, declared: unknown): string {
584
+ if (declared !== undefined) {
585
+ if (typeof declared !== 'string' || !CHALLENGE_ACTION.test(declared)) {
586
+ this.#problem(
587
+ where,
588
+ `challenge.action ${JSON.stringify(declared)} is not a widget action (allowed: at most 32 letters, digits, '_' or '-', such as 'register')`,
589
+ )
590
+ }
591
+ return typeof declared === 'string' ? declared : challengeAction(id)
592
+ }
593
+ const derived = challengeAction(id)
594
+ if (!CHALLENGE_ACTION.test(derived)) {
595
+ this.#problem(
596
+ where,
597
+ `its challenge action is its endpoint id with '_' for '.', '${derived}', longer than the 32 characters the provider admits (allowed: challenge.action, such as 'register')`,
598
+ )
599
+ }
600
+ return derived
751
601
  }
752
602
 
753
603
  /** The variables an SPA mount serves its pages; `[]` when it declares none. */
754
- #spaPublic(where: string, declared: readonly string[] | undefined): readonly string[] {
604
+ #browserConfig(where: string, declared: readonly string[] | undefined): readonly string[] {
755
605
  if (declared === undefined) {
756
606
  return []
757
607
  }
758
608
  if (declared.length === 0) {
759
- this.#problem(where, "public names no variable (allowed: public: ['SIGNUP_URL'], or no public)")
609
+ this.#problem(where, "browserConfig names no variable (allowed: browserConfig: ['SIGNUP_URL'], or no browserConfig)")
760
610
  }
761
- this.#unique(where, 'public', declared)
762
- const names = declared.filter((name) => this.#applicationVariable(where, 'public', name, 'SIGNUP_URL'))
611
+ this.#unique(where, 'browserConfig', declared)
612
+ const names = declared.filter((name) => this.#applicationVariable(where, 'browserConfig', name, 'SIGNUP_URL'))
763
613
  for (const name of names) {
764
- this.#publicVariables.push({ where, field: 'public', name })
614
+ this.#publicVariables.push({ where, field: 'browserConfig', name })
765
615
  }
766
616
  return names
767
617
  }
@@ -783,29 +633,23 @@ class ArtifactBuilder {
783
633
  for (const variable of this.#publicVariables) {
784
634
  const owner = this.#challenges.find((challenge) => challenge.secret === variable.name)
785
635
  if (owner !== undefined) {
786
- this.#problem(variable.where, `${variable.field} '${variable.name}' is the secret of form '${owner.form}'; a secret is never public`)
636
+ this.#problem(variable.where, `${variable.field} '${variable.name}' is the secret of the challenge of '${owner.site}'; a secret is never public`)
787
637
  }
788
638
  }
789
639
  }
790
640
 
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)
641
+ /** The webhook's verifier: a preset, or a framework-owned shape the runtime computes. */
642
+ #webhookScheme(where: string, value: unknown): WebhookScheme | null {
643
+ const parsed = z.safeParse(webhookSchemeSchema, value)
644
+ if (parsed.success) {
645
+ return parsed.data
646
+ }
647
+ // A union names no branch; an `hmac` shape is refused for its own first issue.
648
+ const shape = isRecord(value) && Object.hasOwn(value, 'hmac') ? z.safeParse(hmacSchemeSchema, value['hmac']) : null
649
+ const issue = shape?.success === false ? shape.error.issues[0] : undefined
650
+ const detail = issue === undefined ? '' : ` (hmac${issue.path.map((key) => `.${String(key)}`).join('')}: ${issue.message})`
651
+ this.#problem(where, `scheme ${JSON.stringify(value)} is not a verifier${detail} (allowed: ${WEBHOOK_SCHEME_ALLOWED})`)
652
+ return null
809
653
  }
810
654
 
811
655
  #link(name: string, mount: LinkMountDeclaration<SchemaShape>): MountArtifact {
@@ -840,18 +684,20 @@ class ArtifactBuilder {
840
684
  const id = `${name}.${path}`
841
685
  const requirement = this.#linkRequirement(`procedure '${id}'`, procedure.access, mount)
842
686
  this.#procedureSchemas(`procedure '${id}'`, procedure)
843
- this.#noCrossOrigin(`procedure '${id}'`, procedure.crossOrigin, 'a link holder')
687
+ if (procedure.actions !== undefined) {
688
+ this.#problem(`procedure '${id}'`, 'declares actions, which only a public procedure holds; a link procedure holds its link grant')
689
+ }
690
+ const rules = this.#rules(id, procedure, 'link')
844
691
  this.#declare(`procedure '${id}'`, id, procedure.declared, { kind: 'link', readOnly: procedure.type === 'query' })
845
692
  if (requirement !== null) {
846
693
  this.#row({
847
694
  id,
848
695
  source: { kind: 'mount', name },
849
696
  kind: procedure.type,
850
- method: 'POST',
851
697
  path: joinPath(api, path),
852
698
  callers: ['link'],
853
699
  requirement,
854
- admission: JSON_ADMISSION,
700
+ ...rowWire(rules),
855
701
  input: procedure.input,
856
702
  output: this.#output(`procedure '${id}'`, procedure),
857
703
  })
@@ -868,61 +714,522 @@ class ArtifactBuilder {
868
714
  }
869
715
  }
870
716
 
717
+ /**
718
+ * A procedure's row and its rules; a public procedure's workload actions, which `#declare` records, or `null`.
719
+ * `callers` is `null` on an RPC mount whose groups are all public.
720
+ */
871
721
  #procedure(
872
722
  source: EndpointSource,
873
723
  id: string,
874
724
  path: string,
875
- callers: readonly [Caller, ...Caller[]],
725
+ callers: readonly [Caller, ...Caller[]] | null,
876
726
  procedure: ProcedureDeclaration<SchemaShape>,
877
- ): void {
727
+ ): readonly string[] | null {
878
728
  const where = `procedure '${id}'`
879
729
  this.#procedureSchemas(where, procedure)
730
+ if (isPublicAccess(procedure.access)) {
731
+ if (source.kind === 'mount') {
732
+ return this.#publicProcedure(source, id, path, procedure)
733
+ }
734
+ this.#problem(where, 'is public, which only a procedure of an RPC mount is (allowed: an action and its scope)')
735
+ return null
736
+ }
737
+ if (procedure.actions !== undefined) {
738
+ this.#problem(where, 'declares actions, which only a public procedure holds (its handler runs as its own workload); remove them')
739
+ }
740
+ if (source.kind === 'export' && (procedure.redirect !== undefined || procedure.http !== undefined)) {
741
+ this.#problem(where, 'is an export procedure, which answers JSON to a POST: it declares no http and no redirect()')
742
+ }
743
+ if (source.kind === 'export' && (procedure.output.type === 'file' || (procedure.input !== null && containsFile(procedure.input)))) {
744
+ this.#problem(where, 'is an export procedure, which carries JSON both ways: it takes no file and answers none')
745
+ }
880
746
  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')
747
+ const rules = this.#rules(id, procedure, 'credentialed')
748
+ if (source.kind === 'export' && rules !== null && rules.origins !== 'same-origin') {
749
+ this.#problem(where, "is called by a consuming application, from its server, never cross-origin (allowed: admission.origins: 'same-origin')")
750
+ }
751
+ const crossOrigin = source.kind === 'mount' && rules?.origins === 'listed' ? { crossOrigin: 'environment' as const } : {}
752
+ if (callers === null) {
753
+ this.#problem(
754
+ where,
755
+ "is credentialed, and its group names no callers: a group without callers holds public procedures only (app.group({ callers: ['human'] }, …))",
756
+ )
757
+ return null
758
+ }
884
759
  if (requirement !== null) {
885
760
  this.#row({
886
761
  id,
887
762
  source,
888
763
  kind: procedure.type,
889
- method: 'POST',
890
764
  path,
891
765
  callers,
892
766
  requirement,
893
- admission: JSON_ADMISSION,
767
+ ...rowWire(rules),
894
768
  input: procedure.input,
895
769
  output: this.#output(where, procedure),
896
770
  ...crossOrigin,
897
771
  })
898
772
  }
773
+ return null
774
+ }
775
+
776
+ /**
777
+ * A procedure anyone calls (`access: { public: true }`): its own row mode, no callers and no requirement, and its
778
+ * handler holding its top-level `actions` as the endpoint's own workload. It answers the default admission of a public
779
+ * procedure unless it declares its own.
780
+ */
781
+ #publicProcedure(source: EndpointSource, id: string, path: string, procedure: ProcedureDeclaration<SchemaShape>): readonly string[] | null {
782
+ const where = `procedure '${id}'`
783
+ if (!hasExactKeys(procedure.access, ['public'])) {
784
+ this.#problem(where, `access ${JSON.stringify(procedure.access)} is not the public form (allowed: { public: true }, with actions beside it)`)
785
+ }
786
+ const actions = this.#workloadActions(where, procedure.actions)
787
+ const rules = this.#rules(id, procedure, 'public')
788
+ if (rules === null) {
789
+ // A procedure of the staged kit always carries `declared`; one without it is no kit procedure.
790
+ this.#problem(where, 'is public, which only a procedure of the staged kit (app.group) is')
791
+ return null
792
+ }
793
+ const origins = rules.origins === 'any'
794
+ ? { crossOrigin: 'any' as const }
795
+ : rules.origins === 'listed'
796
+ ? { crossOrigin: 'environment' as const }
797
+ : {}
798
+ this.#row({
799
+ id,
800
+ source,
801
+ kind: 'public',
802
+ path,
803
+ callers: [],
804
+ requirement: null,
805
+ ...rowWire(rules),
806
+ input: procedure.input,
807
+ output: this.#output(where, procedure),
808
+ ...origins,
809
+ })
810
+ return actions
811
+ }
812
+
813
+ /** A public procedure's `actions`: at least one action of the contract, each once. */
814
+ #workloadActions(where: string, value: unknown): readonly string[] {
815
+ if (!Array.isArray(value) || value.length === 0 || !value.every((action) => typeof action === 'string')) {
816
+ this.#problem(where, "is public, so it declares what its handler holds: actions: ['<action>'], at least one")
817
+ return []
818
+ }
819
+ const actions: string[] = value.filter((action) => typeof action === 'string')
820
+ this.#unique(where, 'actions', actions)
821
+ for (const action of actions) {
822
+ this.#knownAction(where, action)
823
+ }
824
+ return actions
825
+ }
826
+
827
+ /**
828
+ * A procedure's rules of the staged kit, every default applied: `limits` within its ceilings, and `admission` merged
829
+ * into the default of the procedure's kind (a declared bucket replaces the default, `null` turns it off). Recorded under
830
+ * the endpoint id; `null` for a `defineContract` procedure, which declares none and keeps its row as it was.
831
+ */
832
+ #rules(id: string, procedure: ProcedureDeclaration<SchemaShape, unknown>, kind: 'public' | 'credentialed' | 'link'): ProcedureRules | null {
833
+ const where = `procedure '${id}'`
834
+ if (procedure.declared === undefined) {
835
+ for (const field of ['admission', 'limits', 'http'] as const) {
836
+ if (procedure[field] !== undefined) {
837
+ this.#problem(where, `declares ${field}, which only a procedure of the staged kit declares`)
838
+ }
839
+ }
840
+ return null
841
+ }
842
+ const files = this.#inputFiles(where, procedure)
843
+ const limits = this.#limits(where, procedure.limits, PROCEDURE_LIMITS, files)
844
+ const admission = this.#admission(id, procedure, kind, files)
845
+ const http = this.#http(where, procedure, kind, files)
846
+ this.#ownPath(where, procedure, kind)
847
+ this.#procedureRules[id] = {
848
+ rate: admission.rate,
849
+ challenge: admission.challenge,
850
+ timeoutMs: limits.timeoutMs,
851
+ ...(http.query === null ? {} : { query: http.query }),
852
+ ...(http.method === 'GET' && procedure.type === 'mutation' ? { changesStateOnGet: true } : {}),
853
+ }
854
+ return {
855
+ maxBytes: limits.maxBytes,
856
+ origins: admission.origins,
857
+ method: http.method,
858
+ body: http.body,
859
+ ...(files === null ? {} : { filesOptional: files.optional }),
860
+ ...(http.cache === undefined ? {} : { cache: http.cache }),
861
+ }
899
862
  }
900
863
 
901
864
  /**
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.
865
+ * A procedure's file fields: each a top-level field of its input that holds a file of a declared kind (`file(k)`),
866
+ * an optional one, or an array of them bounded by `.max(n)`; a mutation's only, `maxFiles` files together. `null`
867
+ * when it takes none.
904
868
  */
905
- #crossOrigin(where: string, value: unknown, anyOrigin: boolean): { readonly crossOrigin?: CrossOriginForm } {
869
+ #inputFiles(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>): InputFiles | null {
870
+ const input = procedure.input
871
+ if (input === null || input.type !== 'object') {
872
+ return null
873
+ }
874
+ const fields: { readonly name: string; readonly kind: string; readonly count: number; readonly required: boolean }[] = []
875
+ for (const [name, shape] of Object.entries(input.fields)) {
876
+ const field = fileField(shape)
877
+ if (field === 'unbounded') {
878
+ this.#problem(where, `input field '${name}' is an array of files without a bound (allowed: z.array(file('<kind>')).max(n))`)
879
+ } else if (field !== null) {
880
+ fields.push({ name, ...field })
881
+ } else if (containsFile(shape)) {
882
+ this.#problem(
883
+ where,
884
+ `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))`,
885
+ )
886
+ }
887
+ }
888
+ if (fields.length === 0) {
889
+ return null
890
+ }
891
+ if (procedure.type !== 'mutation') {
892
+ this.#problem(where, 'takes a file, which only a mutation does: a file is written, and a query writes nothing')
893
+ }
894
+ const count = fields.reduce((sum, field) => sum + field.count, 0)
895
+ if (count > PROCEDURE_LIMITS.maxFiles) {
896
+ this.#problem(where, `takes up to ${count} files, more than the ${PROCEDURE_LIMITS.maxFiles} one request may carry`)
897
+ }
898
+ let bytes = 0
899
+ let largest = 0
900
+ let scanning = true
901
+ for (const field of fields) {
902
+ const kind = this.#fileKind(`${where} input field '${field.name}'`, field.kind)
903
+ if (kind !== null) {
904
+ bytes += field.count * kind.maxBytes
905
+ largest = Math.max(largest, kind.maxBytes)
906
+ scanning &&= kind.scan === true
907
+ }
908
+ }
909
+ return { bytes, largest, scanning, optional: fields.every((field) => !field.required) }
910
+ }
911
+
912
+ /**
913
+ * A procedure's HTTP shape (`http: { method?, query?, body? }`): `POST` with a JSON body by default. A query may be
914
+ * `GET`, its input then read from flat scalar URL parameters; so may a public mutation whose input holds a signed
915
+ * token, which answers a redirect or a schema. A POST may read a flat `query` from the URL beside its body, and take a
916
+ * native form's `urlencoded` body of flat scalar fields. `cache` waits for caching, a later step of this release.
917
+ */
918
+ #http(
919
+ where: string,
920
+ procedure: ProcedureDeclaration<SchemaShape, unknown>,
921
+ kind: 'public' | 'credentialed' | 'link',
922
+ files: InputFiles | null = null,
923
+ ): { readonly method: 'GET' | 'POST'; readonly body: ProcedureBody | null; readonly query: SchemaShape | null; readonly cache?: EndpointCache } {
924
+ const fallback = { method: 'POST' as const, body: files === null ? ('json' as const) : ('multipart' as const), query: null }
925
+ const value = procedure.http
926
+ // `http.path` says where the procedure is served, not how: declared alone, it keeps every default of the wire.
927
+ const wire = isRecord(value) && Object.keys(value).every((key) => key === 'path') ? undefined : value
928
+ // A query that answers a file is a GET, so `<img src>` and `<a download>` reach it.
929
+ if (wire === undefined && procedure.type === 'query' && procedure.output.type === 'file') {
930
+ return this.#http(where, { ...procedure, http: { method: 'GET' } }, kind, files)
931
+ }
932
+ if (wire === undefined) {
933
+ return fallback
934
+ }
935
+ const allowed = "http: { method?: 'GET' | 'POST', query?: z.strictObject({ … }), body?: 'json' | 'urlencoded', path?: '/register' }"
936
+ if (!isRecord(value)) {
937
+ this.#problem(where, `http is not an object (allowed: ${allowed})`)
938
+ return fallback
939
+ }
940
+ this.#keys(`${where} http`, value, ['method', 'query', 'body', 'cache', 'path'])
941
+ const method = value['method'] ?? (value['cache'] !== undefined && procedure.type === 'query' ? 'GET' : 'POST')
942
+ if (method !== 'GET' && method !== 'POST') {
943
+ this.#problem(where, `http.method ${JSON.stringify(method)} is not a method (allowed: 'GET' or 'POST')`)
944
+ return fallback
945
+ }
946
+ if (files !== null && value['body'] !== undefined) {
947
+ this.#problem(where, 'takes files, so its body is multipart/form-data, which the input derives: it declares no http.body')
948
+ return fallback
949
+ }
950
+ const body = value['body'] ?? (method === 'GET' ? null : files === null ? 'json' : 'multipart')
951
+ const flat = (shape: SchemaShape | null) => shape === null || (shape.type === 'object' && Object.values(shape.fields).every(isParameter))
952
+ if (method === 'GET') {
953
+ if (procedure.type === 'mutation') {
954
+ this.#getMutation(where, procedure, kind)
955
+ }
956
+ if (value['query'] !== undefined || value['body'] !== undefined) {
957
+ this.#problem(where, 'is GET, which reads its input from the URL: it declares no http.query and no http.body')
958
+ }
959
+ if (!flat(procedure.input)) {
960
+ this.#problem(where, 'is GET, so its input is flat scalar fields the URL carries (allowed: strings, numbers, booleans, enums, literals, null)')
961
+ }
962
+ const cache = this.#cache(where, value['cache'], kind === 'public' && procedure.type === 'query')
963
+ return { method, body: null, query: null, ...(cache === null ? {} : { cache }) }
964
+ }
965
+ if (value['cache'] !== undefined) {
966
+ this.#problem(where, 'declares http.cache, which only a public GET query does: every other answer is no-store')
967
+ }
968
+ if (body === 'multipart' && files !== null) {
969
+ const query = value['query']
970
+ if (query !== undefined && (!isSchemaShape(query) || query.type !== 'object' || !flat(query))) {
971
+ this.#problem(where, 'http.query is not a strict object of flat scalar fields (allowed: z.strictObject({ t: z.string() }))')
972
+ return { method, body, query: null }
973
+ }
974
+ return { method, body, query: query === undefined ? null : query }
975
+ }
976
+ if (body !== 'json' && body !== 'urlencoded') {
977
+ this.#problem(where, `http.body ${JSON.stringify(body)} is not a body codec (allowed: 'json' or 'urlencoded')`)
978
+ return fallback
979
+ }
980
+ if (body === 'urlencoded' && !flat(procedure.input)) {
981
+ this.#problem(where, "takes a urlencoded body, so its input is flat scalar fields (allowed: http.body: 'json' for anything else)")
982
+ }
983
+ const query = value['query']
984
+ if (query === undefined) {
985
+ return { method, body, query: null }
986
+ }
987
+ if (!isSchemaShape(query) || query.type !== 'object' || !flat(query)) {
988
+ this.#problem(where, 'http.query is not a strict object of flat scalar fields (allowed: z.strictObject({ t: z.string() }))')
989
+ return { method, body, query: null }
990
+ }
991
+ this.#tokenFields(`${where} http.query`, query, true)
992
+ return { method, body, query }
993
+ }
994
+
995
+ /**
996
+ * `http.path`: a public procedure's own literal path instead of `<prefix>/<name>`, for a caller outside the application
997
+ * that keeps a URL (a 0.5 form's). Recorded for `#checkOwnPaths`, which needs every mount.
998
+ */
999
+ #ownPath(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>, kind: 'public' | 'credentialed' | 'link'): void {
1000
+ const path = isRecord(procedure.http) ? procedure.http['path'] : undefined
1001
+ if (path === undefined) {
1002
+ return
1003
+ }
1004
+ if (kind !== 'public') {
1005
+ this.#problem(
1006
+ where,
1007
+ 'declares http.path, which only a public procedure does: its caller is outside the application, and every other caller uses the typed client',
1008
+ )
1009
+ } else if (typeof path !== 'string' || !LITERAL_PATH.test(path)) {
1010
+ this.#problem(
1011
+ where,
1012
+ `http.path ${JSON.stringify(path)} is not a literal path (allowed: '/register', segments of letters, digits and '._~-', no trailing '/')`,
1013
+ )
1014
+ } else if (path === FRAMEWORK_PREFIX || path.startsWith(`${FRAMEWORK_PREFIX}/`)) {
1015
+ this.#problem(where, `http.path '${path}' is under ${FRAMEWORK_PREFIX}, which the framework reserves`)
1016
+ } else {
1017
+ this.#ownPaths.push({ where, path })
1018
+ }
1019
+ }
1020
+
1021
+ /**
1022
+ * An own path lies outside every mount's prefix but the root SPA's, whose `/*` answers only what no endpoint claims:
1023
+ * a public row never takes a path that a mount, credentialed or not, serves under its own prefix. Two endpoints on one
1024
+ * path are `assertNoAmbiguity`'s.
1025
+ */
1026
+ #checkOwnPaths(): void {
1027
+ for (const own of this.#ownPaths) {
1028
+ for (const [name, mount] of Object.entries(this.#contract.mounts)) {
1029
+ const prefix = mountPath(mount)
1030
+ if (prefix !== '/' && (own.path === prefix || own.path.startsWith(`${prefix}/`))) {
1031
+ this.#problem(
1032
+ own.where,
1033
+ `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')`,
1034
+ )
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * `http.cache` of a public GET query: `{ maxAgeSeconds }` (1 s to a year) or `'immutable'` (a year, for an input that
1042
+ * names an immutable value, such as a file id). `null` when it declares none or after a problem.
1043
+ */
1044
+ #cache(where: string, value: unknown, cacheable: boolean): EndpointCache | null {
906
1045
  if (value === undefined) {
907
- return {}
1046
+ return null
1047
+ }
1048
+ if (!cacheable) {
1049
+ this.#problem(where, 'declares http.cache, which only a public GET query does: every other answer is no-store')
1050
+ return null
1051
+ }
1052
+ if (value === 'immutable') {
1053
+ return value
908
1054
  }
909
- if (value === true) {
910
- return { crossOrigin: 'environment' }
1055
+ const allowed = `allowed: http.cache: { maxAgeSeconds: 1 to ${MAX_CACHE_AGE_SECONDS} } or 'immutable'`
1056
+ if (!isRecord(value)) {
1057
+ this.#problem(where, `http.cache is neither a lifetime nor 'immutable' (${allowed})`)
1058
+ return null
911
1059
  }
912
- if (anyOrigin && value === '*') {
913
- return { crossOrigin: 'any' }
1060
+ this.#keys(`${where} http.cache`, value, ['maxAgeSeconds'])
1061
+ const seconds = value['maxAgeSeconds']
1062
+ if (typeof seconds !== 'number' || !Number.isSafeInteger(seconds) || seconds < 1 || seconds > MAX_CACHE_AGE_SECONDS) {
1063
+ this.#problem(where, `http.cache.maxAgeSeconds ${JSON.stringify(seconds)} is not a lifetime (${allowed})`)
1064
+ return null
914
1065
  }
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 {}
1066
+ return { maxAgeSeconds: seconds }
918
1067
  }
919
1068
 
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)`)
1069
+ /**
1070
+ * A mutation answers `GET` only when it is public and a verified signed token in its input names what it changes: a
1071
+ * link scanner may follow it, and a session's top-level navigation would carry the cookie. It answers a redirect or a
1072
+ * schema.
1073
+ */
1074
+ #getMutation(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>, kind: 'public' | 'credentialed' | 'link'): void {
1075
+ if (kind !== 'public') {
1076
+ this.#problem(
1077
+ where,
1078
+ "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)",
1079
+ )
1080
+ return
1081
+ }
1082
+ const fields = procedure.input?.type === 'object' ? Object.values(procedure.input.fields) : []
1083
+ if (!fields.some((field) => field.type === 'token')) {
1084
+ this.#problem(where, "is a GET mutation, so its input holds a required signedToken('<purpose>') that names what it changes")
1085
+ }
1086
+ if (procedure.attachment !== undefined || procedure.output.type === 'file') {
1087
+ this.#problem(where, 'is a GET mutation, which answers redirect() or a schema, never a file')
924
1088
  }
925
- return {}
1089
+ }
1090
+
1091
+ /**
1092
+ * A procedure's or a webhook's `limits`, each bound within `ceiling` (a procedure's by default); `defaults` for what it
1093
+ * leaves out. A procedure with file fields admits by default its JSON input's bytes and each of its files at its
1094
+ * kind's `maxBytes`, at most `maxFileBodyBytes` in all.
1095
+ */
1096
+ #limits(
1097
+ where: string,
1098
+ value: unknown,
1099
+ base: { readonly maxBytes: number; readonly timeoutMs: number },
1100
+ files: InputFiles | null = null,
1101
+ ceiling: number = files === null ? PROCEDURE_LIMITS.maxMaxBytes : PROCEDURE_LIMITS.maxFileBodyBytes,
1102
+ ): { readonly maxBytes: number; readonly timeoutMs: number } {
1103
+ const defaults = { maxBytes: base.maxBytes + (files?.bytes ?? 0), timeoutMs: base.timeoutMs }
1104
+ if (defaults.maxBytes > ceiling && (!isRecord(value) || value['maxBytes'] === undefined)) {
1105
+ this.#problem(
1106
+ where,
1107
+ `takes files of up to ${
1108
+ files?.bytes ?? 0
1109
+ } bytes together, more than the ${ceiling} bytes a request may carry: take fewer or smaller files, or declare limits.maxBytes`,
1110
+ )
1111
+ }
1112
+ if (value === undefined) {
1113
+ return { ...defaults, maxBytes: Math.min(defaults.maxBytes, ceiling) }
1114
+ }
1115
+ if (!isRecord(value)) {
1116
+ this.#problem(where, 'limits is not an object (allowed: limits: { maxBytes?, timeoutMs? })')
1117
+ return defaults
1118
+ }
1119
+ this.#keys(`${where} limits`, value, ['maxBytes', 'timeoutMs'])
1120
+ return {
1121
+ maxBytes: this.#bounded(where, 'limits.maxBytes', value['maxBytes'], Math.min(defaults.maxBytes, ceiling), ceiling, 'bytes'),
1122
+ timeoutMs: this.#bounded(where, 'limits.timeoutMs', value['timeoutMs'], defaults.timeoutMs, PROCEDURE_LIMITS.maxTimeoutMs, 'ms'),
1123
+ }
1124
+ }
1125
+
1126
+ /** A declared whole number from 1 to `ceiling`, or `fallback` when it is absent or refused. */
1127
+ #bounded(where: string, field: string, value: unknown, fallback: number, ceiling: number, unit: string): number {
1128
+ if (value === undefined) {
1129
+ return fallback
1130
+ }
1131
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > ceiling) {
1132
+ this.#problem(where, `${field} ${JSON.stringify(value)} is not a whole number of ${unit} from 1 to ${ceiling}`)
1133
+ return fallback
1134
+ }
1135
+ return value
1136
+ }
1137
+
1138
+ #admission(
1139
+ id: string,
1140
+ procedure: ProcedureDeclaration<SchemaShape, unknown>,
1141
+ kind: 'public' | 'credentialed' | 'link',
1142
+ files: InputFiles | null = null,
1143
+ ): { readonly origins: AdmissionOrigins; readonly rate: AdmissionRate; readonly challenge: ProcedureChallenge | null } {
1144
+ const where = `procedure '${id}'`
1145
+ const admitted = this.#declaredAdmission(id, procedure, kind, files)
1146
+ if (kind === 'public' && files !== null) {
1147
+ const missing = [
1148
+ ...(admitted.rate.bytesPerHour === null ? ['admission.rate.bytesPerHour'] : []),
1149
+ ...(admitted.challenge === null ? ['admission.challenge'] : []),
1150
+ ]
1151
+ if (missing.length > 0) {
1152
+ this.#problem(where, `is public and takes files, so it declares ${missing.join(' and ')}: an anonymous upload is budgeted and challenged`)
1153
+ }
1154
+ if (!files.scanning) {
1155
+ this.#problem(
1156
+ where,
1157
+ "is public and takes files, so each of its file kinds scans (scan: true): an anonymous visitor's file waits for a clean verdict",
1158
+ )
1159
+ }
1160
+ }
1161
+ return admitted
1162
+ }
1163
+
1164
+ #declaredAdmission(
1165
+ id: string,
1166
+ procedure: ProcedureDeclaration<SchemaShape, unknown>,
1167
+ kind: 'public' | 'credentialed' | 'link',
1168
+ files: InputFiles | null,
1169
+ ): { readonly origins: AdmissionOrigins; readonly rate: AdmissionRate; readonly challenge: ProcedureChallenge | null } {
1170
+ const where = `procedure '${id}'`
1171
+ const rate: Record<keyof AdmissionRate, number | null> = kind === 'public' && procedure.type === 'mutation'
1172
+ ? { perClient: PUBLIC_MUTATION_RATE.perClient, perCaller: null, perEndpoint: PUBLIC_MUTATION_RATE.perEndpoint, bytesPerHour: null }
1173
+ : { perClient: null, perCaller: null, perEndpoint: null, bytesPerHour: null }
1174
+ const declared = procedure.admission
1175
+ if (declared === undefined) {
1176
+ return { origins: 'same-origin', rate, challenge: null }
1177
+ }
1178
+ const allowed =
1179
+ "admission: { origins?: 'same-origin' | 'listed' | 'any', rate?: { perClient?, perCaller?, perEndpoint?, bytesPerHour? }, challenge? }"
1180
+ if (!isRecord(declared)) {
1181
+ this.#problem(where, `admission is not an object (allowed: ${allowed})`)
1182
+ return { origins: 'same-origin', rate, challenge: null }
1183
+ }
1184
+ this.#keys(`${where} admission`, declared, ['origins', 'rate', 'challenge'])
1185
+ const origins = this.#origins(where, declared['origins'], kind)
1186
+ const declaredRate = declared['rate']
1187
+ if (declaredRate !== undefined && !isRecord(declaredRate)) {
1188
+ this.#problem(where, `admission.rate is not an object of buckets (allowed: ${allowed})`)
1189
+ }
1190
+ for (const [bucket, value] of Object.entries(isRecord(declaredRate) ? declaredRate : {})) {
1191
+ if (!Object.hasOwn(rate, bucket)) {
1192
+ this.#problem(where, `admission.rate.${bucket} is not a bucket (allowed: perClient, perCaller, perEndpoint, bytesPerHour)`)
1193
+ continue
1194
+ }
1195
+ const field = bucket === 'perClient' || bucket === 'perCaller' || bucket === 'perEndpoint' || bucket === 'bytesPerHour' ? bucket : null
1196
+ if (field === null) continue
1197
+ if (field === 'perClient' && kind !== 'public') {
1198
+ this.#problem(where, 'admission.rate.perClient counts by the client address, which only a public procedure does (allowed: perCaller)')
1199
+ } else if (field === 'perCaller' && kind === 'public') {
1200
+ this.#problem(where, 'admission.rate.perCaller counts by the caller, and a public procedure has none (allowed: perClient)')
1201
+ } else if (field === 'bytesPerHour' && files === null) {
1202
+ this.#problem(where, 'admission.rate.bytesPerHour bounds the bytes of file fields, and the input declares none')
1203
+ } else if (field === 'bytesPerHour' && typeof value === 'number' && value < (files?.largest ?? 0)) {
1204
+ this.#problem(where, `admission.rate.bytesPerHour ${value} is less than the ${files?.largest ?? 0} bytes one of its files may hold`)
1205
+ }
1206
+ const [ceiling, unit] = field === 'bytesPerHour'
1207
+ ? [PROCEDURE_LIMITS.maxBytesPerHour, 'bytes per hour']
1208
+ : [PROCEDURE_LIMITS.maxPerMinute, 'calls per minute']
1209
+ rate[field] = value === null ? null : this.#bounded(where, `admission.rate.${field}`, value, rate[field] ?? 1, ceiling, unit)
1210
+ }
1211
+ const challenge = declared['challenge']
1212
+ if (challenge === undefined) {
1213
+ return { origins, rate, challenge: null }
1214
+ }
1215
+ if (kind !== 'public' || !isRecord(challenge)) {
1216
+ this.#problem(where, kind === 'public' ? 'admission.challenge is not an object' : 'admission.challenge belongs to a public procedure only')
1217
+ return { origins, rate, challenge: null }
1218
+ }
1219
+ const fields = procedure.input?.type === 'object' ? procedure.input.fields : {}
1220
+ return { origins, rate, challenge: this.#challenge(id, where, challenge, fields) ?? null }
1221
+ }
1222
+
1223
+ #origins(where: string, value: unknown, kind: 'public' | 'credentialed' | 'link'): AdmissionOrigins {
1224
+ if (value === undefined || value === 'same-origin') {
1225
+ return 'same-origin'
1226
+ }
1227
+ if ((value === 'listed' && kind !== 'link') || (value === 'any' && kind === 'public')) {
1228
+ return value
1229
+ }
1230
+ const allowed = kind === 'public' ? "'same-origin', 'listed' or 'any'" : kind === 'link' ? "'same-origin'" : "'same-origin' or 'listed'"
1231
+ this.#problem(where, `admission.origins ${JSON.stringify(value)} is not an origin rule here (allowed: ${allowed})`)
1232
+ return 'same-origin'
926
1233
  }
927
1234
 
928
1235
  /**
@@ -930,6 +1237,16 @@ class ArtifactBuilder {
930
1237
  * scalar cell, the name is fixed and safe in a header, and each limit is within `ATTACHMENT_LIMITS`.
931
1238
  */
932
1239
  #output(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>): OutputShape {
1240
+ if (procedure.redirect !== undefined) {
1241
+ return this.#redirect(where, procedure.redirect.origins)
1242
+ }
1243
+ if (procedure.output.type === 'file') {
1244
+ this.#fileKind(`${where} output`, procedure.output.kind)
1245
+ return procedure.output
1246
+ }
1247
+ if (containsFile(procedure.output)) {
1248
+ this.#problem(where, "answers JSON with a file inside, which JSON cannot carry (allowed: output: file('<kind>'), or the file's reference fields)")
1249
+ }
933
1250
  const attachment = procedure.attachment
934
1251
  if (attachment === undefined) {
935
1252
  return procedure.output
@@ -960,7 +1277,18 @@ class ArtifactBuilder {
960
1277
  this.#problem(at, `column '${column.name}' is not a cell (allowed: a string, number, boolean, enum or literal, optional or nullable)`)
961
1278
  }
962
1279
  }
963
- return { type: 'attachment', format, filename: attachment.filename, maxBytes, maxRows, columns }
1280
+ return { type: 'attachment', format, filename: attachment.filename, maxBytes, maxRows, columns }
1281
+ }
1282
+
1283
+ /** A redirect's listed origins: exact `https://` origins, each once; the application's own needs none. */
1284
+ #redirect(where: string, origins: readonly string[]): OutputShape {
1285
+ this.#unique(where, 'redirect origins', origins)
1286
+ for (const origin of origins) {
1287
+ if (!isPolicyOrigin(origin)) {
1288
+ this.#problem(where, `redirect origin '${origin}' is not an exact https origin (allowed: 'https://shop.example')`)
1289
+ }
1290
+ }
1291
+ return { type: 'redirect', origins: [...origins] }
964
1292
  }
965
1293
 
966
1294
  #attachmentLimit(where: string, field: string, value: number, ceiling: number): number {
@@ -982,12 +1310,12 @@ class ArtifactBuilder {
982
1310
 
983
1311
  #anonymous(
984
1312
  name: string,
985
- kind: 'health' | 'form' | 'webhook' | 'public-spa',
1313
+ kind: 'health' | 'webhook',
986
1314
  method: 'GET' | 'POST',
987
1315
  path: string,
988
1316
  admission: BodyAdmission | null,
989
1317
  input: SchemaShape | null,
990
- crossOrigin: { readonly crossOrigin?: CrossOriginForm } = {},
1318
+ extra: Pick<EndpointRow, 'authorization'> = {},
991
1319
  ): void {
992
1320
  this.#row({
993
1321
  id: name,
@@ -1000,12 +1328,99 @@ class ArtifactBuilder {
1000
1328
  admission,
1001
1329
  input,
1002
1330
  output: null,
1003
- ...crossOrigin,
1331
+ ...extra,
1004
1332
  })
1005
1333
  }
1006
1334
 
1335
+ /** A row that answers a stored file carries the file-response profile, which its digest covers. */
1007
1336
  #row(row: EndpointRow): void {
1008
- this.#endpoints.push(row)
1337
+ const where = `endpoint '${row.id}'`
1338
+ // A token travels only as a top-level field of a procedure's own input, never into an export's consumer.
1339
+ const carries = (row.kind === 'query' || row.kind === 'mutation' || row.kind === 'public') && row.source.kind === 'mount'
1340
+ if (row.input !== null) {
1341
+ this.#tokenFields(`${where} input`, row.input, carries)
1342
+ }
1343
+ if (row.output !== null && isSchemaOutput(row.output)) {
1344
+ this.#tokenFields(`${where} output`, row.output, false)
1345
+ }
1346
+ this.#endpoints.push(row.output?.type === 'file' ? { ...row, response: 'file' } : row)
1347
+ }
1348
+
1349
+ /**
1350
+ * Every signed token field of `shape`: a declared purpose, and, where `topLevel` allows it, a field of the object
1351
+ * itself (optional or not); anywhere else a token is refused, since the framework verifies only those.
1352
+ */
1353
+ #tokenFields(where: string, shape: SchemaShape, topLevel: boolean): void {
1354
+ const visit = (at: SchemaShape, top: boolean): void => {
1355
+ switch (at.type) {
1356
+ case 'token':
1357
+ if (!top) {
1358
+ this.#problem(
1359
+ where,
1360
+ `carries signedToken('${at.purpose}') where the framework does not verify it (allowed: a top-level field of a procedure's input or http.query)`,
1361
+ )
1362
+ }
1363
+ this.#knownPurpose(where, at.purpose)
1364
+ return
1365
+ case 'object':
1366
+ for (const field of Object.values(at.fields)) visit(field, top && at === shape)
1367
+ return
1368
+ case 'optional':
1369
+ visit(at.inner, top)
1370
+ return
1371
+ case 'nullable':
1372
+ case 'array':
1373
+ visit(at.type === 'array' ? at.items : at.inner, false)
1374
+ return
1375
+ case 'tuple':
1376
+ for (const item of [...at.items, ...(at.rest === null ? [] : [at.rest])]) visit(item, false)
1377
+ return
1378
+ case 'record':
1379
+ visit(at.value, false)
1380
+ return
1381
+ case 'union':
1382
+ for (const option of at.options) visit(option, false)
1383
+ return
1384
+ default:
1385
+ return
1386
+ }
1387
+ }
1388
+ visit(shape, topLevel)
1389
+ }
1390
+
1391
+ #knownPurpose(where: string, purpose: string): void {
1392
+ const tokens = this.#contract.tokens ?? {}
1393
+ if (!Object.hasOwn(tokens, purpose)) {
1394
+ this.#problem(
1395
+ where,
1396
+ `signedToken('${purpose}') names no token purpose of defineApp's options${
1397
+ suggestion(purpose, Object.keys(tokens))
1398
+ } (allowed: tokens: { ${purpose}: token(Claims, { maxDays }) })`,
1399
+ )
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * The token purposes: each a name, its claims a strict object that carries no token, its `maxDays` a whole number of
1405
+ * days up to `TOKEN_MAX_DAYS`.
1406
+ */
1407
+ #tokens(): Record<string, TokenPurposeArtifact> {
1408
+ const tokens: Record<string, TokenPurposeArtifact> = {}
1409
+ for (const [purpose, declared] of Object.entries(this.#contract.tokens ?? {})) {
1410
+ const where = `token '${purpose}'`
1411
+ if (!this.#name(where, purpose)) {
1412
+ continue
1413
+ }
1414
+ if (!isRecord(declared) || !hasExactKeys(declared, ['claims', 'maxDays'])) {
1415
+ this.#problem(where, 'is not built with token(Claims, { maxDays })')
1416
+ continue
1417
+ }
1418
+ this.#objectSchema(`${where} claims`, declared.claims)
1419
+ this.#tokenFields(`${where} claims`, declared.claims, false)
1420
+ const maxDays = this.#bounded(where, 'maxDays', declared.maxDays, 1, TOKEN_MAX_DAYS, 'days')
1421
+ tokens[purpose] = { claims: declared.claims, maxDays }
1422
+ }
1423
+ return tokens
1009
1424
  }
1010
1425
 
1011
1426
  #reserve(prefix: string | null): void {
@@ -1018,6 +1433,9 @@ class ArtifactBuilder {
1018
1433
 
1019
1434
  #requirement(where: string, procedure: ProcedureDeclaration<SchemaShape>): EndpointRequirement | null {
1020
1435
  const access: AccessDeclaration = procedure.access
1436
+ if ('capabilities' in access) {
1437
+ return this.#capabilities(where, procedure, access)
1438
+ }
1021
1439
  const form = accessForm(access)
1022
1440
  if (form === null) {
1023
1441
  this.#problem(
@@ -1056,6 +1474,42 @@ class ArtifactBuilder {
1056
1474
  return { kind: 'action', action: form.action, access: form.access }
1057
1475
  }
1058
1476
 
1477
+ /**
1478
+ * Several capabilities, each an action `over` a dimension or `on` or `in` the value an input field names: at least two,
1479
+ * by names the handler reads (`ctx.capabilities.<name>`). Several fields may name one value.
1480
+ */
1481
+ #capabilities(
1482
+ where: string,
1483
+ procedure: ProcedureDeclaration<SchemaShape>,
1484
+ access: { readonly capabilities: Readonly<Record<string, CapabilityDeclaration>> },
1485
+ ): EndpointRequirement | null {
1486
+ const allowed = '{ capabilities: { <name>: { action, over } | { action, in, from } | { action, on, from }, … } }, at least two'
1487
+ if (!hasExactKeys(access, ['capabilities']) || !isRecord(access.capabilities) || Object.keys(access.capabilities).length < 2) {
1488
+ this.#problem(where, `access ${JSON.stringify(access)} is not several capabilities (allowed: ${allowed})`)
1489
+ return null
1490
+ }
1491
+ const capabilities: Record<string, CapabilityRequirement> = {}
1492
+ for (const [name, declared] of Object.entries(access.capabilities)) {
1493
+ const at = `${where} capability '${name}'`
1494
+ const form = accessForm(declared)
1495
+ if (!this.#name(at, name) || form === null || form.form !== 'action' || !isCapabilityForm(form.access)) {
1496
+ if (form !== null) this.#problem(at, `is not one capability (allowed: { action, over }, { action, in, from }, { action, on, from })`)
1497
+ else {this.#problem(
1498
+ at,
1499
+ `${JSON.stringify(declared)} is not one access form (allowed: { action, over }, { action, in, from }, { action, on, from })`,
1500
+ )}
1501
+ continue
1502
+ }
1503
+ this.#knownAction(at, form.action)
1504
+ this.#knownDimension(at, form.access.dimension)
1505
+ if (form.access.form !== 'over') {
1506
+ this.#fromField(at, procedure.input, form.access.from)
1507
+ }
1508
+ capabilities[name] = { action: form.action, access: form.access }
1509
+ }
1510
+ return { kind: 'capabilities', capabilities }
1511
+ }
1512
+
1059
1513
  #linkRequirement(where: string, access: LinkAccessDeclaration, mount: LinkMountDeclaration<SchemaShape>): EndpointRequirement | null {
1060
1514
  if (!hasExactKeys(access, ['action'])) {
1061
1515
  this.#problem(where, `access ${JSON.stringify(access)} is not a link access (allowed: { action: <one of the link's grants> })`)
@@ -1112,218 +1566,77 @@ class ArtifactBuilder {
1112
1566
 
1113
1567
  // ── Sections ────────────────────────────────────────────────────────────────────────────────
1114
1568
 
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) {
1569
+ /** The media types and the scan of a file kind: exact types, and a scan that can vouch for each file. */
1570
+ #fileTypes(where: string, declared: Pick<FileKindDeclaration, 'types' | 'maxBytes' | 'scan'>): void {
1571
+ this.#unique(where, 'types', declared.types)
1572
+ for (const type of declared.types) {
1286
1573
  if (!MIME_TYPE.test(type)) {
1287
1574
  this.#problem(where, `'${type}' is not an exact media type (allowed: 'image/png'; no wildcards or parameters)`)
1288
1575
  }
1289
1576
  }
1290
- if (collection.types.length === 0) {
1577
+ if (declared.types.length === 0) {
1291
1578
  this.#problem(where, 'admits no media type (allowed: types: [<exact media types>])')
1292
1579
  }
1293
- if (collection.scan !== undefined && typeof collection.scan !== 'boolean') {
1580
+ if (declared.scan !== undefined && typeof declared.scan !== 'boolean') {
1294
1581
  this.#problem(where, 'scan is true or false')
1295
1582
  }
1296
- for (const type of collection.scan === true ? collection.types.filter((candidate) => FILE_LIMITS.unscannedTypes.includes(candidate)) : []) {
1583
+ for (const type of declared.scan === true ? declared.types.filter((candidate) => FILE_LIMITS.unscannedTypes.includes(candidate)) : []) {
1297
1584
  this.#problem(
1298
1585
  where,
1299
1586
  `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
1587
  )
1301
1588
  }
1302
- if (collection.scan === true && collection.maxBytes > FILE_LIMITS.maxScannedBytes) {
1589
+ if (declared.scan === true && declared.maxBytes > FILE_LIMITS.maxScannedBytes) {
1303
1590
  this.#problem(
1304
1591
  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)`,
1592
+ `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
1593
  )
1307
1594
  }
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 } : {}),
1595
+ }
1596
+
1597
+ /** The file kinds (`c.files({ types, maxBytes, scan? })`), before the mounts that name them. */
1598
+ #readFileKinds(): Record<string, FileKindArtifact> {
1599
+ const kinds: Record<string, FileKindArtifact> = {}
1600
+ for (const [name, declared] of Object.entries(this.#contract.files)) {
1601
+ const where = `files '${name}'`
1602
+ if (!this.#name(where, name) || !this.#kind(where, declared, 'file-kind', 'c.files({ types, maxBytes, scan? })')) {
1603
+ continue
1604
+ }
1605
+ if (declared.unknownOptions?.includes('owner') === true) {
1606
+ this.#problem(
1607
+ where,
1608
+ "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>' })",
1609
+ )
1610
+ continue
1611
+ }
1612
+ this.#options(where, 'fileKind', declared.unknownOptions)
1613
+ this.#positiveInteger(`${where} maxBytes`, declared.maxBytes)
1614
+ if (declared.maxBytes > PROCEDURE_LIMITS.maxFileBodyBytes) {
1615
+ this.#problem(
1616
+ where,
1617
+ `admits ${declared.maxBytes} bytes per file, more than the ${PROCEDURE_LIMITS.maxFileBodyBytes} bytes a request may carry (allowed: maxBytes: ${PROCEDURE_LIMITS.maxFileBodyBytes} or less)`,
1618
+ )
1619
+ }
1620
+ this.#fileTypes(where, declared)
1621
+ kinds[name] = { types: [...declared.types], maxBytes: declared.maxBytes, ...(declared.scan === true ? { scan: true } : {}) }
1318
1622
  }
1623
+ return kinds
1319
1624
  }
1320
1625
 
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]
1626
+ /** A file kind the contract declares, by the name a file field, a file output or a file column gives. */
1627
+ #fileKind(where: string, kind: string): FileKindArtifact | null {
1628
+ const found = Object.hasOwn(this.#fileKinds, kind) ? this.#fileKinds[kind] : undefined
1629
+ if (found === undefined) {
1630
+ const declared = Object.keys(this.#fileKinds)
1631
+ this.#problem(
1632
+ where,
1633
+ `names the file kind '${kind}', which the contract does not declare (allowed: ${
1634
+ declared.length === 0 ? 'files: { <kind>: c.files({ types, maxBytes }) }' : declared.join(', ')
1635
+ })`,
1636
+ )
1637
+ return null
1638
+ }
1639
+ return found
1327
1640
  }
1328
1641
 
1329
1642
  #jobs(): Record<string, JobArtifact> {
@@ -1333,35 +1646,46 @@ class ArtifactBuilder {
1333
1646
  if (!this.#name(where, name) || !this.#kind(where, job, 'cron', "c.cron('<schedule>', { … })")) {
1334
1647
  continue
1335
1648
  }
1336
- const scope = this.#job(where, job)
1649
+ this.#job(where, job)
1337
1650
  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
- }
1651
+ jobs[name] = {
1652
+ schedule: job.schedule,
1653
+ actions: [...job.actions],
1654
+ scope: { kind: 'app' },
1655
+ timeoutMs: this.#jobLimits(where, job.limits),
1656
+ retries: job.retries ?? CONTRACT_DEFAULTS.jobRetries,
1346
1657
  }
1347
1658
  }
1348
1659
  return jobs
1349
1660
  }
1350
1661
 
1351
- #job(where: string, job: CronDeclaration): ScopeBinding | null {
1662
+ /** A job's `limits`: its attempt's time only, since a job reads no body. */
1663
+ #jobLimits(where: string, value: unknown): number {
1664
+ if (value === undefined) {
1665
+ return JOB_LIMITS.timeoutMs
1666
+ }
1667
+ if (!isRecord(value)) {
1668
+ this.#problem(where, 'limits is not an object (allowed: limits: { timeoutMs? })')
1669
+ return JOB_LIMITS.timeoutMs
1670
+ }
1671
+ if (value['maxBytes'] !== undefined) {
1672
+ this.#problem(where, 'declares limits.maxBytes, and a job reads no body (allowed: limits: { timeoutMs? })')
1673
+ }
1674
+ this.#keys(`${where} limits`, value, ['maxBytes', 'timeoutMs'])
1675
+ return this.#bounded(where, 'limits.timeoutMs', value['timeoutMs'], JOB_LIMITS.timeoutMs, JOB_LIMITS.maxTimeoutMs, 'ms')
1676
+ }
1677
+
1678
+ /** A job acts for the whole application: it holds its actions globally. */
1679
+ #job(where: string, job: CronDeclaration): void {
1352
1680
  this.#options(where, 'cron', job.unknownOptions)
1353
1681
  const fields = job.schedule.split(' ')
1354
1682
  if (fields.length !== 5 || !fields.every((field) => CRON_FIELD.test(field))) {
1355
1683
  this.#problem(where, `schedule '${job.schedule}' is not five cron fields separated by single spaces (allowed: '0 3 * * *')`)
1356
1684
  }
1357
1685
  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
1686
  if (job.retries !== undefined && (!Number.isInteger(job.retries) || job.retries < 0)) {
1362
1687
  this.#problem(where, `retries ${job.retries} is not a whole number of at least 0`)
1363
1688
  }
1364
- return this.#scopeBinding(where, 'scope', job.scope)
1365
1689
  }
1366
1690
 
1367
1691
  #exports(): Record<string, ExportArtifact> {
@@ -1437,11 +1761,15 @@ class ArtifactBuilder {
1437
1761
  // `#exports` refused the app form already: a description needs one dimension.
1438
1762
  return null
1439
1763
  }
1764
+ if (first.kind === 'capabilities') {
1765
+ // `#exports` refused several capabilities already: a consumer's key holds one action.
1766
+ return null
1767
+ }
1440
1768
  const description: EndpointRequirement = first.kind === 'member' || dimension === null
1441
1769
  ? first
1442
1770
  : { kind: 'action', action: first.action, access: { form: 'over', dimension } }
1443
1771
  const same = requirements.every((requirement) =>
1444
- requirement !== null && (requirement.kind === 'member'
1772
+ requirement !== null && requirement.kind !== 'capabilities' && (requirement.kind === 'member'
1445
1773
  ? description.kind === 'member'
1446
1774
  : description.kind === 'action' && requirement.action === description.action && dimensionOf(requirement) === dimension)
1447
1775
  )
@@ -1567,14 +1895,14 @@ class ArtifactBuilder {
1567
1895
  }
1568
1896
  }
1569
1897
 
1570
- /** The declared sources, plus each fixed origin a form's challenge provider loads that the contract does not name itself. */
1898
+ /** The declared sources, plus each fixed origin a challenge provider loads that the contract does not name itself. */
1571
1899
  #withChallengeOrigins(directive: 'scriptSrc' | 'frameSrc', declared: readonly CspSource[]): CspSource[] {
1572
1900
  const sources = [...declared]
1573
1901
  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()
1902
+ const sites = this.#challenges.filter((challenge) => challenge.provider === provider).map((challenge) => challenge.site).sort()
1575
1903
  for (const origin of CHALLENGE_ORIGINS[provider][directive]) {
1576
1904
  if (!sources.some((source) => source.origin === origin)) {
1577
- sources.push({ origin, purpose: `the ${provider} challenge of ${forms.map((form) => `'${form}'`).join(', ')}` })
1905
+ sources.push({ origin, purpose: `the ${provider} challenge of ${sites.map((site) => `'${site}'`).join(', ')}` })
1578
1906
  }
1579
1907
  }
1580
1908
  }
@@ -1596,6 +1924,15 @@ class ArtifactBuilder {
1596
1924
  return sources.map((source) => ({ origin: source.origin, purpose: source.purpose }))
1597
1925
  }
1598
1926
 
1927
+ /** An RPC mount's promise to its callers; `unchecked` when it makes none. */
1928
+ #compatibility(where: string, value: unknown): 'backward' | 'unchecked' {
1929
+ if (value === undefined || value === 'unchecked' || value === 'backward') {
1930
+ return value ?? 'unchecked'
1931
+ }
1932
+ this.#problem(where, `compatibility ${JSON.stringify(value)} is not a promise (allowed: compatibility: 'backward' or 'unchecked')`)
1933
+ return 'unchecked'
1934
+ }
1935
+
1599
1936
  // ── Declared reach ──────────────────────────────────────────────────────────────────────────
1600
1937
 
1601
1938
  /**
@@ -1616,8 +1953,19 @@ class ArtifactBuilder {
1616
1953
  this.#problem(where, `'${key}' is not a declared field (allowed: ${DECLARED_ALLOWED})`)
1617
1954
  }
1618
1955
  }
1619
- const then = this.#then(where, value['then'], place)
1956
+ const workload = place.kind === 'rpc' ? place.actions : null
1957
+ if (workload !== null) {
1958
+ for (const field of ['sql', 'unscoped', 'connect', 'issues', 'binds'] as const) {
1959
+ if (value[field] !== undefined) {
1960
+ this.#problem(where, `is public and declares ${field}: a public procedure reaches its actions only (allowed: kicks, signs)`)
1961
+ }
1962
+ }
1963
+ }
1620
1964
  const mailer = this.#mailer(where, value['mailer'], place)
1965
+ const kicks = this.#kicks(where, value['kicks'], place)
1966
+ const binds = this.#binds(where, value['binds'], place)
1967
+ const signs = this.#signs(where, value['signs'], place)
1968
+ const egress = this.#egress(where, value['egress'], place)
1621
1969
  const reach: DeclaredReach = {
1622
1970
  sql: this.#sqlMode(where, value['sql'], place.readOnly),
1623
1971
  unscoped: this.#unscoped(where, value['unscoped'], place.readOnly),
@@ -1627,8 +1975,12 @@ class ArtifactBuilder {
1627
1975
  : place.kind === 'job'
1628
1976
  ? this.#jobIssues(where, value['issues'], place.actions)
1629
1977
  : null,
1630
- ...(then === undefined ? {} : { then }),
1631
1978
  ...(mailer === undefined ? {} : { mailer }),
1979
+ ...(workload === null || workload.length === 0 ? {} : { actions: workload }),
1980
+ ...(kicks.length === 0 ? {} : { kicks }),
1981
+ ...(binds === undefined ? {} : { binds }),
1982
+ ...(signs.length === 0 ? {} : { signs }),
1983
+ ...(egress.length === 0 ? {} : { egress }),
1632
1984
  }
1633
1985
  if (place.kind !== 'rpc') {
1634
1986
  this.#rpcOnly(where, 'connect', value['connect'])
@@ -1637,8 +1989,9 @@ class ArtifactBuilder {
1637
1989
  this.#problem(where, 'declares issues, which only an endpoint of an RPC mount or a cron job declares')
1638
1990
  }
1639
1991
  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
1992
+ reach.sql !== null || Object.keys(reach.unscoped).length > 0 || reach.connect.length > 0 || reach.issues !== null
1993
+ || reach.mailer !== undefined || reach.actions !== undefined || reach.kicks !== undefined || reach.binds !== undefined
1994
+ || reach.signs !== undefined || reach.egress !== undefined
1642
1995
  ) {
1643
1996
  this.#declared[site] = reach
1644
1997
  }
@@ -1686,8 +2039,6 @@ class ArtifactBuilder {
1686
2039
  where,
1687
2040
  `declares unscoped '${table}': 'write', and no site writes a reference table: its rows come from a migration (allowed: 'read')`,
1688
2041
  )
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
2042
  }
1692
2043
  unscoped[table] = mode
1693
2044
  }
@@ -1767,24 +2118,152 @@ class ArtifactBuilder {
1767
2118
  return name
1768
2119
  }
1769
2120
 
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 {
2121
+ /**
2122
+ * The webhook whose bindings a credentialed mutation of an RPC mount creates and removes: a webhook of this contract.
2123
+ * `undefined` when it declares none or after a problem.
2124
+ */
2125
+ #binds(where: string, value: unknown, place: DeclaringSite): string | undefined {
1772
2126
  if (value === undefined) {
1773
2127
  return undefined
1774
2128
  }
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')
2129
+ if (place.kind !== 'rpc' || place.readOnly || place.actions !== null) {
2130
+ // A public procedure is refused with the other reach it may not declare.
2131
+ if (place.kind !== 'rpc' || place.actions === null) {
2132
+ this.#problem(where, 'declares binds, which only a credentialed mutation of an RPC mount declares')
2133
+ }
1777
2134
  return undefined
1778
2135
  }
1779
- if (place.readOnly) {
1780
- this.#problem(where, 'declares then, and it runs read-only (allowed: then on a mutation)')
2136
+ const mounts = this.#contract.mounts
2137
+ const webhook = typeof value === 'string' && Object.hasOwn(mounts, value) ? mounts[value] : undefined
2138
+ if (typeof value !== 'string' || webhook?.kind !== 'webhook') {
2139
+ const names = Object.entries(mounts).filter(([, mount]) => mount.kind === 'webhook').map(([name]) => name)
2140
+ this.#problem(
2141
+ where,
2142
+ `binds ${JSON.stringify(value)} is not a webhook of the contract (webhooks: ${names.length === 0 ? 'none' : names.join(', ')})${
2143
+ typeof value === 'string' ? suggestion(value, names) : ''
2144
+ }`,
2145
+ )
1781
2146
  return undefined
1782
2147
  }
1783
- if (typeof value !== 'string') {
1784
- this.#problem(where, "then is not a job's name (allowed: then: '<cron job>')")
1785
- return undefined
2148
+ return value
2149
+ }
2150
+
2151
+ /**
2152
+ * The token purposes a site signs (`ctx.tokens.sign`): a procedure of an RPC mount, a webhook or a job, never an export
2153
+ * or a link procedure. Each names a purpose of `defineApp`'s options, once; `[]` when it declares none or after a problem.
2154
+ */
2155
+ #signs(where: string, value: unknown, place: DeclaringSite): string[] {
2156
+ if (value === undefined) {
2157
+ return []
2158
+ }
2159
+ if (place.kind === 'export' || place.kind === 'link') {
2160
+ this.#problem(
2161
+ where,
2162
+ `declares signs, which ${
2163
+ place.kind === 'export' ? 'an export procedure' : 'a link procedure'
2164
+ } never does: a procedure of an RPC mount, a webhook or a job signs`,
2165
+ )
2166
+ return []
2167
+ }
2168
+ if (!Array.isArray(value) || value.length === 0 || !value.every((purpose) => typeof purpose === 'string')) {
2169
+ this.#problem(where, "signs is not a list of token purposes (allowed: signs: ['<token purpose>'])")
2170
+ return []
2171
+ }
2172
+ const purposes: string[] = value.filter((purpose) => typeof purpose === 'string')
2173
+ this.#unique(where, 'signs', purposes)
2174
+ for (const purpose of purposes) {
2175
+ this.#knownPurpose(where, purpose)
2176
+ }
2177
+ return [...new Set(purposes)].sort()
2178
+ }
2179
+
2180
+ /**
2181
+ * The HTTPS origins a site's code calls: a credentialed procedure of an RPC mount, a webhook or a job, never a public,
2182
+ * export or link procedure. Each is an exact origin with no path or wildcard, once; `[]` when it declares none or after
2183
+ * a problem.
2184
+ */
2185
+ #egress(where: string, value: unknown, place: DeclaringSite): string[] {
2186
+ if (value === undefined) {
2187
+ return []
2188
+ }
2189
+ const refusedBy = place.kind === 'export'
2190
+ ? 'an export procedure'
2191
+ : place.kind === 'link'
2192
+ ? 'a link procedure'
2193
+ : place.kind === 'rpc' && place.actions !== null
2194
+ ? 'a public procedure'
2195
+ : null
2196
+ if (refusedBy !== null) {
2197
+ this.#problem(where, `declares egress, which ${refusedBy} never does: a credentialed procedure, a webhook or a job calls out`)
2198
+ return []
2199
+ }
2200
+ if (!Array.isArray(value) || value.length === 0 || !value.every((origin) => typeof origin === 'string')) {
2201
+ this.#problem(where, "egress is not a list of origins (allowed: egress: ['https://api.example.com'])")
2202
+ return []
2203
+ }
2204
+ const origins: string[] = value.filter((origin) => typeof origin === 'string')
2205
+ if (origins.length > MAX_EGRESS_ORIGINS) {
2206
+ this.#problem(where, `${origins.length} egress origins exceed the limit of ${MAX_EGRESS_ORIGINS}`)
2207
+ }
2208
+ for (const origin of origins) {
2209
+ if (!isPolicyOrigin(origin)) {
2210
+ this.#problem(where, `egress '${origin}' is not an https origin (allowed: 'https://api.example.com', no path or wildcard)`)
2211
+ }
2212
+ }
2213
+ this.#unique(where, 'egress', origins)
2214
+ return [...new Set(origins)].sort()
2215
+ }
2216
+
2217
+ /**
2218
+ * 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
2219
+ * that runs read-only. Each names a cron job of the contract, once; `[]` when it declares none or after a problem.
2220
+ */
2221
+ #kicks(where: string, value: unknown, place: DeclaringSite): string[] {
2222
+ if (value === undefined) {
2223
+ return []
2224
+ }
2225
+ if (place.kind === 'export' || place.readOnly) {
2226
+ this.#problem(where, 'declares kicks, and it runs read-only: only a mutation, a webhook or a job kicks a job')
2227
+ return []
2228
+ }
2229
+ if (!Array.isArray(value) || value.length === 0 || !value.every((name) => typeof name === 'string')) {
2230
+ this.#problem(where, "kicks is not a list of cron jobs (allowed: kicks: ['<cron job>'])")
2231
+ return []
2232
+ }
2233
+ const names: string[] = value.filter((name) => typeof name === 'string')
2234
+ this.#unique(where, 'kicks', names)
2235
+ for (const name of names) {
2236
+ this.#kickedJob(where, name)
2237
+ }
2238
+ return [...new Set(names)]
2239
+ }
2240
+
2241
+ /**
2242
+ * The kick edges between jobs are acyclic: a job never kicks itself, directly or around a cycle, so one run never
2243
+ * starts an unbounded chain of runs.
2244
+ */
2245
+ #checkKickCycles(): void {
2246
+ const edges = new Map<string, readonly string[]>()
2247
+ for (const [site, reach] of Object.entries(this.#declared)) {
2248
+ if (site.startsWith(JOB_SITE_PREFIX) && reach.kicks !== undefined) {
2249
+ edges.set(site.slice(JOB_SITE_PREFIX.length), reach.kicks)
2250
+ }
2251
+ }
2252
+ const done = new Set<string>()
2253
+ const visit = (job: string, path: readonly string[]): void => {
2254
+ if (path.includes(job)) {
2255
+ this.#problem(`job '${job}'`, `kicks itself around the cycle ${[...path.slice(path.indexOf(job)), job].join(' → ')}`)
2256
+ return
2257
+ }
2258
+ if (done.has(job)) return
2259
+ for (const next of edges.get(job) ?? []) {
2260
+ visit(next, [...path, job])
2261
+ }
2262
+ done.add(job)
2263
+ }
2264
+ for (const job of [...edges.keys()].sort()) {
2265
+ visit(job, [])
1786
2266
  }
1787
- return this.#kickedJob(where, value)
1788
2267
  }
1789
2268
 
1790
2269
  /** The mail transport a cron job reaches; `undefined` when it declares none or after a problem. */
@@ -1814,15 +2293,12 @@ class ArtifactBuilder {
1814
2293
 
1815
2294
  #checkTables(): Record<string, TablePolicyArtifact> {
1816
2295
  const owners = new Map<string, string>()
1817
- const memberActions = new Set(Object.values(this.#contract.roles).flatMap((role) => role.member === undefined ? [] : role.grants))
1818
2296
  const tables: Record<string, TablePolicyArtifact> = {}
1819
2297
  for (const [name, table] of Object.entries(this.#tables)) {
1820
2298
  const where = `table '${name}'`
1821
2299
  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
- }
2300
+ for (const action of [read, insert, update, remove].flatMap(tableActions).concat(appRead, appCount)) {
2301
+ this.#knownAction(where, action)
1826
2302
  }
1827
2303
  const scope = table.scope
1828
2304
  if (scope.mode === 'dimension') {
@@ -1845,47 +2321,17 @@ class ArtifactBuilder {
1845
2321
  this.#problem(where, `its parent '${scope.parent}' is not a table with a dimension scope`)
1846
2322
  }
1847
2323
  } 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
2324
  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(', ')})`,
2325
+ where,
2326
+ `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
2327
  )
1887
2328
  }
2329
+ for (const [column, kind] of Object.entries(table.files ?? {})) {
2330
+ this.#fileKind(`${where} files.${column}`, kind)
2331
+ }
2332
+ tables[name] = table
1888
2333
  }
2334
+ return tables
1889
2335
  }
1890
2336
 
1891
2337
  // ── Shared checks ───────────────────────────────────────────────────────────────────────────
@@ -2009,14 +2455,139 @@ class ArtifactBuilder {
2009
2455
 
2010
2456
  /** Where a declaration stands: what kind of site, whether it runs read-only, and an RPC endpoint's access form. */
2011
2457
  type DeclaringSite =
2012
- | { readonly kind: 'rpc'; readonly readOnly: boolean; readonly access: AccessDeclaration }
2458
+ /** `actions` are a public procedure's workload actions; `null` for a credentialed procedure. */
2459
+ | { readonly kind: 'rpc'; readonly readOnly: boolean; readonly access: AccessDeclaration; readonly actions: readonly string[] | null }
2013
2460
  | { readonly kind: 'job'; readonly readOnly: boolean; readonly actions: readonly string[] }
2014
2461
  | { readonly kind: 'link' | 'export' | 'webhook'; readonly readOnly: boolean }
2015
2462
 
2463
+ /** The prefix a mount serves under, or a health or webhook mount's own path. */
2464
+ function mountPath(mount: MountDeclaration<SchemaShape>): string {
2465
+ return mount.kind === 'health' || mount.kind === 'webhook' ? mount.path : mount.prefix
2466
+ }
2467
+
2016
2468
  const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === 'object' && value !== null && !Array.isArray(value)
2017
2469
 
2470
+ /** What a procedure's row takes from its rules: the body limit and the origin rule. */
2471
+ /** A procedure's body codec: `multipart` is derived from its file fields, never declared. */
2472
+ type ProcedureBody = 'json' | 'urlencoded' | 'multipart'
2473
+
2474
+ interface ProcedureRules {
2475
+ readonly method: 'GET' | 'POST'
2476
+ /** `null` for a GET, which takes no body. */
2477
+ readonly body: ProcedureBody | null
2478
+ readonly maxBytes: number
2479
+ readonly origins: AdmissionOrigins
2480
+ /** A multipart procedure whose every file field may be left empty also takes a JSON body. */
2481
+ readonly filesOptional?: boolean
2482
+ /** A public GET query's `http.cache`. */
2483
+ readonly cache?: EndpointCache
2484
+ }
2485
+
2486
+ /** What a procedure's file fields add to its body: their bytes together, the largest one, and whether every kind scans. */
2487
+ interface InputFiles {
2488
+ readonly bytes: number
2489
+ readonly largest: number
2490
+ readonly scanning: boolean
2491
+ readonly optional: boolean
2492
+ }
2493
+
2494
+ /** A top-level input field that holds files: `file(k)`, optional, or an array bounded by `.max(n)`. */
2495
+ function fileField(shape: SchemaShape): { readonly kind: string; readonly count: number; readonly required: boolean } | 'unbounded' | null {
2496
+ if (shape.type === 'file') {
2497
+ return { kind: shape.kind, count: 1, required: true }
2498
+ }
2499
+ if (shape.type === 'optional') {
2500
+ const inner = fileField(shape.inner)
2501
+ return inner === null || inner === 'unbounded' ? inner : { ...inner, required: false }
2502
+ }
2503
+ if (shape.type === 'array' && shape.items.type === 'file') {
2504
+ return shape.maxItems === null ? 'unbounded' : { kind: shape.items.kind, count: shape.maxItems, required: (shape.minItems ?? 0) > 0 }
2505
+ }
2506
+ return null
2507
+ }
2508
+
2509
+ /** Whether a schema holds a file anywhere. */
2510
+ export function containsFile(shape: SchemaShape): boolean {
2511
+ switch (shape.type) {
2512
+ case 'file':
2513
+ return true
2514
+ case 'array':
2515
+ return containsFile(shape.items)
2516
+ case 'tuple':
2517
+ return shape.items.some(containsFile) || (shape.rest !== null && containsFile(shape.rest))
2518
+ case 'record':
2519
+ return containsFile(shape.value)
2520
+ case 'object':
2521
+ return Object.values(shape.fields).some(containsFile)
2522
+ case 'optional':
2523
+ case 'nullable':
2524
+ return containsFile(shape.inner)
2525
+ case 'union':
2526
+ return shape.options.some(containsFile)
2527
+ default:
2528
+ return false
2529
+ }
2530
+ }
2531
+
2532
+ /** The file fields of an input, by name: the parts a multipart body carries beside its JSON. */
2533
+ export function fileFieldsOf(input: SchemaShape | null): ReadonlyMap<string, FileShape & { readonly many: boolean; readonly max: number }> {
2534
+ const fields = new Map<string, FileShape & { readonly many: boolean; readonly max: number }>()
2535
+ if (input?.type !== 'object') {
2536
+ return fields
2537
+ }
2538
+ for (const [name, shape] of Object.entries(input.fields)) {
2539
+ const inner = shape.type === 'optional' ? shape.inner : shape
2540
+ if (inner.type === 'file') {
2541
+ fields.set(name, { type: 'file', kind: inner.kind, many: false, max: 1 })
2542
+ } else if (inner.type === 'array' && inner.items.type === 'file') {
2543
+ fields.set(name, { type: 'file', kind: inner.items.kind, many: true, max: inner.maxItems ?? 0 })
2544
+ }
2545
+ }
2546
+ return fields
2547
+ }
2548
+
2549
+ type AdmissionOrigins = 'same-origin' | 'listed' | 'any'
2550
+
2551
+ /** The method and body admission of a procedure's row: POST with a JSON body, unlimited, for a `defineContract` one. */
2552
+ function rowWire(rules: ProcedureRules | null): Pick<EndpointRow, 'method' | 'admission' | 'cache'> {
2553
+ if (rules === null) {
2554
+ return { method: 'POST', admission: JSON_ADMISSION }
2555
+ }
2556
+ if (rules.body === null) {
2557
+ return { method: rules.method, admission: null, ...(rules.cache === undefined ? {} : { cache: rules.cache }) }
2558
+ }
2559
+ if (rules.body === 'multipart') {
2560
+ const contentTypes: readonly [string, ...string[]] = rules.filesOptional === true
2561
+ ? ['application/json', FORM_MULTIPART_TYPE]
2562
+ : [FORM_MULTIPART_TYPE]
2563
+ return { method: rules.method, admission: { contentTypes, maxBytes: rules.maxBytes } }
2564
+ }
2565
+ return {
2566
+ method: rules.method,
2567
+ admission: { contentTypes: [rules.body === 'urlencoded' ? FORM_URLENCODED_TYPE : 'application/json'], maxBytes: rules.maxBytes },
2568
+ }
2569
+ }
2570
+
2571
+ /** A described schema: what `describeContract` and the reader leave where a contract wrote one. */
2572
+ function isSchemaShape(value: unknown): value is SchemaShape {
2573
+ return isRecord(value) && typeof value['type'] === 'string'
2574
+ }
2575
+
2576
+ function nonEmpty<T>(list: readonly T[]): readonly [T, ...T[]] | null {
2577
+ const [first, ...rest] = list
2578
+ return first === undefined ? null : [first, ...rest]
2579
+ }
2580
+
2581
+ function isPublicAccess(access: unknown): boolean {
2582
+ return isRecord(access) && access['public'] === true
2583
+ }
2584
+
2018
2585
  type ResolvedAccess = { readonly form: 'member' } | { readonly form: 'action'; readonly action: string; readonly access: AccessForm }
2019
2586
 
2587
+ function isCapabilityForm(access: AccessForm): access is CapabilityAccessForm {
2588
+ return access.form === 'over' || access.form === 'in' || (access.form === 'on' && access.from !== null)
2589
+ }
2590
+
2020
2591
  /** The one access form the declaration spells, with exactly its keys; a mixture of two forms is none. */
2021
2592
  function accessForm(access: AccessDeclaration): ResolvedAccess | null {
2022
2593
  if ('member' in access) {
@@ -2046,6 +2617,11 @@ function accessForm(access: AccessDeclaration): ResolvedAccess | null {
2046
2617
  return null
2047
2618
  }
2048
2619
 
2620
+ /** A value one URL or urlencoded parameter carries: a scalar, or a signed token, possibly absent. */
2621
+ function isParameter(shape: SchemaShape): boolean {
2622
+ return isCell(shape) || shape.type === 'token' || (shape.type === 'optional' && shape.inner.type === 'token')
2623
+ }
2624
+
2049
2625
  /** A value one CSV field holds: a scalar, possibly absent. */
2050
2626
  function isCell(shape: SchemaShape): boolean {
2051
2627
  switch (shape.type) {
@@ -2081,11 +2657,6 @@ function isRequiredString(shape: SchemaShape): boolean {
2081
2657
  }
2082
2658
  }
2083
2659
 
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
2660
  function hasExactKeys(value: object, keys: readonly string[]): boolean {
2090
2661
  const own = Object.keys(value)
2091
2662
  return own.length === keys.length && keys.every((key) => own.includes(key))