@fabrika/app 0.3.3 → 0.4.1

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 (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +746 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +308 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +331 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +824 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +760 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. package/src/testing.ts +0 -119
@@ -0,0 +1,1932 @@
1
+ // `buildArtifact`: a described contract and its table policies → the normalized `ContractArtifact`. Every check
2
+ // the types cannot make, every default and every generated endpoint is here, once.
3
+
4
+ import {
5
+ type AccessForm,
6
+ assertNoAmbiguity,
7
+ type AttachmentFormat,
8
+ type BodyAdmission,
9
+ BUILTIN_ROLE_KEYS,
10
+ type Caller,
11
+ canonicalizeArtifact,
12
+ type ChallengeProvider,
13
+ type ContractArtifact,
14
+ type CspExtensions,
15
+ type CspSource,
16
+ type DeclaredReach,
17
+ type DimensionArtifact,
18
+ directoryReach,
19
+ displayNameProblem,
20
+ type EndpointDirectory,
21
+ type EndpointRequirement,
22
+ type EndpointRow,
23
+ type EndpointSource,
24
+ type ExportArtifact,
25
+ type FileCollectionArtifact,
26
+ type FormChallenge,
27
+ type FormMountArtifact,
28
+ type FormRate,
29
+ type ImportArtifact,
30
+ isLocale,
31
+ isMessageKind,
32
+ JOB_SITE_PREFIX,
33
+ type JobArtifact,
34
+ type Locale,
35
+ LOCALES,
36
+ type MailTemplate,
37
+ mailTemplateProblems,
38
+ type MailTemplates,
39
+ MESSAGE_KINDS,
40
+ type MessageKind,
41
+ type MountArtifact,
42
+ type OutputShape,
43
+ type RoleArtifact,
44
+ type SchemaShape,
45
+ type ScopeBinding,
46
+ type TablePolicyArtifact,
47
+ } from '@fabrika/auth-core'
48
+ import type { InboxColumns } from '@fabrika/db/tables'
49
+ import { isPolicyOrigin, MAX_CSP_ORIGINS } from '@fabrika/proxy-core/headers'
50
+ import { contractArtifactSchema } from './artifact-schema'
51
+ import {
52
+ type AccessDeclaration,
53
+ type Builder,
54
+ BUILDER_FIELDS,
55
+ type ContractDeclaration,
56
+ type CronDeclaration,
57
+ type CspDeclaration,
58
+ DECLARED_FIELDS,
59
+ type ExportDeclaration,
60
+ type FilesDeclaration,
61
+ type FormMountDeclaration,
62
+ type ImportDeclaration,
63
+ isProcedure,
64
+ type LinkAccessDeclaration,
65
+ type LinkMountDeclaration,
66
+ type MountDeclaration,
67
+ type ProcedureDeclaration,
68
+ type ProcedureTreeDeclaration,
69
+ type SpaAccessDeclaration,
70
+ } from './declaration'
71
+ import {
72
+ ATTACHMENT_LIMITS,
73
+ CHALLENGE_ORIGINS,
74
+ CONNECT_LIMITS,
75
+ CONNECT_PREFIX,
76
+ CONTRACT_DEFAULTS,
77
+ exportBase,
78
+ FILE_LIMITS,
79
+ fileCollectionPaths,
80
+ FILES_PREFIX,
81
+ FORM_CHALLENGE_FIELD,
82
+ FORM_LIMITS,
83
+ joinPath,
84
+ JSON_ADMISSION,
85
+ LINK_API_SEGMENT,
86
+ spaPrefixRowId,
87
+ WEBHOOK_ACCOUNT_PATTERN,
88
+ WEBHOOK_BINDING_PARAMETER,
89
+ WEBHOOK_SCOPE_FIELD,
90
+ WEBHOOKS_PREFIX,
91
+ } from './defaults'
92
+ import { ContractError, Problems } from './problems'
93
+
94
+ const APP_ID = /^[a-z0-9][a-z0-9-]*$/
95
+ const NAME = /^[A-Za-z][A-Za-z0-9_]*$/
96
+ const DIMENSION = /^[a-z][A-Za-z0-9_]*$/
97
+ const ACTION = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/
98
+ /** Literal segments of unreserved URL characters, none starting with `.`: canonical without any decoding. */
99
+ const LITERAL_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
100
+ const MIME_TYPE = /^[a-z]+\/[a-z0-9][a-z0-9.+-]*$/
101
+ const CRON_FIELD = /^[0-9*/,-]+$/
102
+ const TIMEOUT = /^[1-9][0-9]*[smh]$/
103
+ const DIGEST = /^sha256:[0-9a-f]+$/
104
+ const FRAMEWORK_PREFIX = '/__fabrika'
105
+ /** A fixed name a header carries as it is: no path, no quoting, no extension but the format's. */
106
+ const ATTACHMENT_FILENAMES: Readonly<Record<AttachmentFormat, RegExp>> = {
107
+ csv: /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.csv$/,
108
+ xlsx: /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.xlsx$/,
109
+ }
110
+ /** The `boundTo` and job `scope` value for the whole application; no dimension may take the name. */
111
+ const APP_SCOPE = 'app'
112
+ const AUDIENCE = 'any consumer user'
113
+ const IMPORT_FIELDS = ['app', 'export', 'version', 'digest', 'procedures', 'usedBy', 'timeoutMs']
114
+ /** Tables the framework keeps in the application's schema; no site reaches them unscoped. */
115
+ const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
116
+ const DECLARED_ALLOWED = "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>'"
117
+ const CSP_FIELDS = ['connectSrc', 'imgSrc', 'frameAncestors', 'scriptSrc', 'frameSrc', 'styleSrc', 'fontSrc']
118
+ /** An environment variable an application reads; a form's challenge names its secret by one. */
119
+ const APPLICATION_VARIABLE = /^[A-Z][A-Z0-9_]*$/
120
+ /** Turnstile's `action`: the form's name, which the verification expects back. */
121
+ const CHALLENGE_ACTION = /^[A-Za-z0-9_-]{1,32}$/
122
+
123
+ const UUID: SchemaShape = {
124
+ type: 'string',
125
+ format: 'uuid',
126
+ minLength: null,
127
+ maxLength: null,
128
+ pattern: null,
129
+ startsWith: null,
130
+ endsWith: null,
131
+ includes: null,
132
+ }
133
+ const ID_OBJECT: SchemaShape = { type: 'object', fields: { id: UUID } }
134
+ const TEXT: SchemaShape = { ...UUID, format: null, minLength: 1 }
135
+
136
+ const COUNT: SchemaShape = { type: 'number', int: true, min: 0, exclusiveMin: false, max: null, exclusiveMax: false, multipleOf: null }
137
+
138
+ /** The shapes of a collection's generated list query; `src/files/collections.ts` parses with the same schemas. */
139
+ export const FILE_LIST_INPUT: SchemaShape = {
140
+ type: 'object',
141
+ fields: {
142
+ owner: TEXT,
143
+ after: { type: 'optional', inner: UUID },
144
+ limit: { type: 'optional', inner: { ...COUNT, min: 1, max: FILE_LIMITS.listPageMax } },
145
+ },
146
+ }
147
+ /** A link holder's list names no owner: the owner is the link's object. */
148
+ export const FILE_LINK_LIST_INPUT: SchemaShape = {
149
+ type: 'object',
150
+ fields: {
151
+ after: { type: 'optional', inner: UUID },
152
+ limit: { type: 'optional', inner: { ...COUNT, min: 1, max: FILE_LIMITS.listPageMax } },
153
+ },
154
+ }
155
+ export const FILE_LIST_OUTPUT: SchemaShape = {
156
+ type: 'object',
157
+ fields: {
158
+ items: {
159
+ type: 'array',
160
+ minItems: null,
161
+ maxItems: FILE_LIMITS.listPageMax,
162
+ items: {
163
+ type: 'object',
164
+ fields: {
165
+ id: UUID,
166
+ name: { type: 'nullable', inner: TEXT },
167
+ type: TEXT,
168
+ size: COUNT,
169
+ status: { type: 'enum', values: ['live', 'quarantined', 'rejected'] },
170
+ createdAt: { ...UUID, format: 'datetime' },
171
+ },
172
+ },
173
+ },
174
+ next: { type: 'nullable', inner: UUID },
175
+ },
176
+ }
177
+
178
+ /** The shapes of the generated webhook binding endpoints; `@fabrika/app/implement` parses with the same schemas. */
179
+ export const WEBHOOK_BIND_INPUT: SchemaShape = {
180
+ type: 'object',
181
+ fields: { [WEBHOOK_SCOPE_FIELD]: TEXT, account: { ...UUID, format: null, pattern: WEBHOOK_ACCOUNT_PATTERN } },
182
+ }
183
+ export const WEBHOOK_BIND_OUTPUT: SchemaShape = { type: 'object', fields: { id: UUID, url: TEXT, secret: TEXT } }
184
+ export const WEBHOOK_UNBIND_INPUT: SchemaShape = { type: 'object', fields: { [WEBHOOK_SCOPE_FIELD]: TEXT, id: UUID } }
185
+ export const WEBHOOK_UNBIND_OUTPUT: SchemaShape = ID_OBJECT
186
+
187
+ /**
188
+ * Validates a described contract against its table policies and normalizes it; throws `ContractError` with every
189
+ * problem. A form's fields are checked against its inbox's columns only where `inboxes` names them.
190
+ */
191
+ export function buildArtifact(
192
+ contract: ContractDeclaration<SchemaShape>,
193
+ tables: Readonly<Record<string, TablePolicyArtifact>>,
194
+ inboxes: InboxColumns = {},
195
+ ): ContractArtifact {
196
+ const problems = new Problems()
197
+ const artifact = canonicalizeArtifact(new ArtifactBuilder(contract, tables, inboxes, problems).build())
198
+ problems.throwIfAny()
199
+ // A check missing above must surface here, not in the proxy or Control that reads the artifact later.
200
+ const parsed = contractArtifactSchema.safeParse(JSON.parse(JSON.stringify(artifact)))
201
+ if (!parsed.success) {
202
+ throw new ContractError(parsed.error.issues.map((issue) => `artifact ${issue.path.map(String).join('.')}: ${issue.message}`))
203
+ }
204
+ return parsed.data
205
+ }
206
+
207
+ class ArtifactBuilder {
208
+ readonly #contract: ContractDeclaration<SchemaShape>
209
+ readonly #tables: Readonly<Record<string, TablePolicyArtifact>>
210
+ readonly #inboxes: InboxColumns
211
+ readonly #problems: Problems
212
+ readonly #endpoints: EndpointRow[] = []
213
+ readonly #reservedPrefixes: string[] = []
214
+ readonly #declared: Record<string, DeclaredReach> = {}
215
+ /** Whether any site is the staged kit's, whose reach is only what it declares; `defineContract`'s sites are not. */
216
+ #kit = false
217
+ /** The endpoints that declare `connect`, by the import they name. */
218
+ readonly #connected = new Map<string, string[]>()
219
+ /** The forms that declare a challenge: their provider's fixed origins join the CSP, and their secret stays private. */
220
+ readonly #challenges: { readonly form: string; readonly provider: ChallengeProvider; readonly secret: string }[] = []
221
+ /** Each variable a browser config serves, and where the contract names it. */
222
+ readonly #publicVariables: { readonly where: string; readonly field: string; readonly name: string }[] = []
223
+
224
+ constructor(
225
+ contract: ContractDeclaration<SchemaShape>,
226
+ tables: Readonly<Record<string, TablePolicyArtifact>>,
227
+ inboxes: InboxColumns,
228
+ problems: Problems,
229
+ ) {
230
+ this.#contract = contract
231
+ this.#tables = tables
232
+ this.#inboxes = inboxes
233
+ this.#problems = problems
234
+ }
235
+
236
+ build(): ContractArtifact {
237
+ const contract = this.#contract
238
+ if (!APP_ID.test(contract.app)) {
239
+ this.#problem('app', `'${contract.app}' is not an application id (allowed: ${APP_ID.source})`)
240
+ }
241
+ const actions = this.#actions()
242
+ const dimensions = this.#dimensions()
243
+ const roles = this.#roles()
244
+ const mounts: Record<string, MountArtifact> = {}
245
+ for (const [name, mount] of Object.entries(contract.mounts)) {
246
+ if (this.#name(`mount '${name}'`, name)) {
247
+ const artifact = this.#mount(name, mount)
248
+ if (artifact !== null) {
249
+ mounts[name] = artifact
250
+ }
251
+ }
252
+ }
253
+ this.#checkPublicVariables()
254
+ const files = this.#files(dimensions)
255
+ const jobs = this.#jobs()
256
+ const exports = this.#exports()
257
+ const imports = this.#imports()
258
+ const csp = this.#csp(contract.csp)
259
+ const tables = this.#checkTables()
260
+ this.#reserve(this.#endpoints.some((row) => row.source.kind === 'files') ? FILES_PREFIX : null)
261
+ this.#reserve(this.#endpoints.some((row) => row.source.kind === 'export') ? CONNECT_PREFIX : null)
262
+ const directories = this.#directories(dimensions)
263
+ const locale = this.#locale()
264
+ const displayName = this.#displayName()
265
+ const mail = this.#mail()
266
+ const people = this.#people()
267
+ const artifact: ContractArtifact = {
268
+ app: contract.app,
269
+ dimensions,
270
+ actions,
271
+ roles,
272
+ mounts,
273
+ files,
274
+ jobs,
275
+ exports,
276
+ imports,
277
+ csp,
278
+ tables,
279
+ endpoints: this.#endpoints,
280
+ reservedPrefixes: this.#reservedPrefixes,
281
+ ...(this.#kit ? { declared: this.#declared } : {}),
282
+ ...(Object.keys(directories).length === 0 ? {} : { directories }),
283
+ ...(locale === null ? {} : { locale }),
284
+ ...(displayName === null ? {} : { displayName }),
285
+ ...(mail === null ? {} : { mail }),
286
+ ...people,
287
+ }
288
+ try {
289
+ assertNoAmbiguity(artifact)
290
+ } catch (error) {
291
+ this.#problem('endpoints', error instanceof Error ? error.message : String(error))
292
+ }
293
+ return artifact
294
+ }
295
+
296
+ /** The member directory each endpoint reads, recorded so the approval and the digest cover the reach. */
297
+ #directories(dimensions: ContractArtifact['dimensions']): Record<string, EndpointDirectory> {
298
+ const directories: Record<string, EndpointDirectory> = {}
299
+ for (const row of this.#endpoints) {
300
+ const reach = directoryReach(dimensions, row)
301
+ if (reach !== null) {
302
+ directories[row.id] = reach
303
+ }
304
+ }
305
+ return directories
306
+ }
307
+
308
+ // ── Vocabulary ──────────────────────────────────────────────────────────────────────────────
309
+
310
+ /** The application's own people: its `members` actions, and its directory's labels. Each field only when declared. */
311
+ #people(): Pick<ContractArtifact, 'members' | 'directory'> {
312
+ const { members, directory } = this.#contract
313
+ if (members !== undefined) {
314
+ this.#knownAction('members.manage', members.manage)
315
+ this.#knownAction('members.list', members.list)
316
+ }
317
+ if (directory !== undefined && directory !== 'labels') {
318
+ this.#problem('directory', `is '${String(directory)}' (allowed: 'labels')`)
319
+ }
320
+ if (directory !== undefined && members === undefined) {
321
+ this.#problem('directory', 'declares a directory and no members: { manage, list } whose list it labels')
322
+ }
323
+ return {
324
+ ...(members === undefined ? {} : { members: { manage: members.manage, list: members.list } }),
325
+ ...(directory === 'labels' ? { directory: 'labels' } : {}),
326
+ }
327
+ }
328
+
329
+ #locale(): Locale | null {
330
+ const locale = this.#contract.locale
331
+ if (locale === undefined) return null
332
+ if (isLocale(locale)) return locale
333
+ this.#problem('locale', `'${locale}' is not a language IAM speaks (allowed: ${LOCALES.map((value) => `'${value}'`).join(', ')})`)
334
+ return null
335
+ }
336
+
337
+ #displayName(): string | null {
338
+ const displayName = this.#contract.displayName
339
+ if (displayName === undefined) return null
340
+ const problem = displayNameProblem(displayName)
341
+ if (problem !== null) this.#problem('displayName', problem)
342
+ return displayName
343
+ }
344
+
345
+ /** The declared e-mail templates; an empty declaration is none, so the digest stays. */
346
+ #mail(): MailTemplates | null {
347
+ const mail = this.#contract.mail
348
+ if (mail === undefined) return null
349
+ const templates: { [K in MessageKind]?: { [L in Locale]?: MailTemplate } } = {}
350
+ for (const [kind, byLocale] of Object.entries(mail)) {
351
+ if (!isMessageKind(kind)) {
352
+ this.#problem(`mail '${kind}'`, `is not a message IAM sends (allowed: ${MESSAGE_KINDS.map((value) => `'${value}'`).join(', ')})`)
353
+ continue
354
+ }
355
+ const declared: { [L in Locale]?: MailTemplate } = {}
356
+ for (const [locale, template] of Object.entries(byLocale)) {
357
+ const where = `mail.${kind}.${locale}`
358
+ if (!isLocale(locale)) {
359
+ this.#problem(where, `'${locale}' is not a language IAM speaks (allowed: ${LOCALES.map((value) => `'${value}'`).join(', ')})`)
360
+ continue
361
+ }
362
+ for (const problem of mailTemplateProblems(kind, template)) this.#problem(where, problem)
363
+ declared[locale] = { subject: template.subject, text: template.text, ...(template.html === undefined ? {} : { html: template.html }) }
364
+ }
365
+ if (Object.keys(declared).length > 0) templates[kind] = declared
366
+ }
367
+ return Object.keys(templates).length === 0 ? null : templates
368
+ }
369
+
370
+ #actions(): Record<string, string> {
371
+ const actions: Record<string, string> = {}
372
+ for (const [action, description] of Object.entries(this.#contract.actions)) {
373
+ if (!ACTION.test(action)) {
374
+ this.#problem(`action '${action}'`, `an action is exact, dot-separated words (allowed: 'notes.read'; no '*')`)
375
+ }
376
+ if (description.trim() === '') {
377
+ this.#problem(`action '${action}'`, 'needs a description, which IAM shows to the people who grant it')
378
+ }
379
+ actions[action] = description
380
+ }
381
+ return actions
382
+ }
383
+
384
+ #dimensions(): Record<string, DimensionArtifact> {
385
+ const dimensions: Record<string, DimensionArtifact> = {}
386
+ const declared = this.#contract.dimensions
387
+ for (const [name, dimension] of Object.entries(declared)) {
388
+ const where = `dimension '${name}'`
389
+ if (!DIMENSION.test(name) || name === APP_SCOPE) {
390
+ this.#problem(where, `is not a dimension name (allowed: ${DIMENSION.source}, not '${APP_SCOPE}')`)
391
+ }
392
+ if (dimension.in !== undefined && !Object.hasOwn(declared, dimension.in)) {
393
+ this.#problem(where, `lies in '${dimension.in}', which is not a dimension of the contract`)
394
+ }
395
+ if (dimension.creator !== undefined) {
396
+ const role = this.#contract.roles[dimension.creator]
397
+ if (role?.member?.scope !== name) {
398
+ this.#problem(where, `its creator '${dimension.creator}' must be a membership role of '${name}' (member: { scope: '${name}', … })`)
399
+ }
400
+ }
401
+ if (dimension.members !== undefined) {
402
+ this.#knownAction(`${where} members.manage`, dimension.members.manage)
403
+ this.#knownAction(`${where} members.list`, dimension.members.list)
404
+ }
405
+ if (dimension.directory !== undefined && dimension.directory !== 'labels') {
406
+ this.#problem(where, `its directory is '${String(dimension.directory)}' (allowed: 'labels')`)
407
+ }
408
+ if (dimension.directory !== undefined && dimension.members === undefined) {
409
+ this.#problem(where, 'declares a directory and no members: { manage, list } whose list it labels')
410
+ }
411
+ dimensions[name] = {
412
+ in: dimension.in ?? null,
413
+ creator: dimension.creator ?? null,
414
+ members: dimension.members === undefined ? null : { manage: dimension.members.manage, list: dimension.members.list },
415
+ ...(dimension.directory === 'labels' ? { directory: 'labels' } : {}),
416
+ }
417
+ }
418
+ for (const name of Object.keys(declared)) {
419
+ const chain = [name]
420
+ let parent = declared[name]?.in
421
+ while (parent !== undefined && !chain.includes(parent)) {
422
+ chain.push(parent)
423
+ parent = declared[parent]?.in
424
+ }
425
+ if (parent !== undefined) {
426
+ this.#problem(`dimension '${name}'`, `its containment is a cycle (${[...chain, parent].join(' → ')})`)
427
+ }
428
+ }
429
+ return dimensions
430
+ }
431
+
432
+ #roles(): Record<string, RoleArtifact> {
433
+ const roles: Record<string, RoleArtifact> = {}
434
+ for (const [name, role] of Object.entries(this.#contract.roles)) {
435
+ const where = `role '${name}'`
436
+ this.#name(where, name)
437
+ if (BUILTIN_ROLE_KEYS.includes(name)) {
438
+ this.#problem(where, `is a role IAM defines itself for every application (reserved: ${BUILTIN_ROLE_KEYS.join(', ')}); choose another name`)
439
+ }
440
+ if (role.grants.length === 0) {
441
+ this.#problem(where, 'grants nothing (allowed: grants: [<exact actions>])')
442
+ }
443
+ this.#unique(where, 'grants', role.grants)
444
+ for (const action of role.grants) {
445
+ this.#knownAction(where, action)
446
+ }
447
+ if (role.member !== undefined) {
448
+ const scope = role.member.scope
449
+ if (!Object.hasOwn(this.#contract.dimensions, scope)) {
450
+ this.#problem(where, `is a membership role of '${scope}', which is not a dimension of the contract`)
451
+ }
452
+ for (const assigner of role.member.assignableBy) {
453
+ const assigning = Object.hasOwn(this.#contract.roles, assigner) ? this.#contract.roles[assigner] : undefined
454
+ if (assigning === undefined || (assigning.member !== undefined && assigning.member.scope !== scope)) {
455
+ this.#problem(
456
+ where,
457
+ `is assignable by '${assigner}', which is neither a membership role of '${scope}' nor an application-wide role (a role with no member)`,
458
+ )
459
+ }
460
+ }
461
+ }
462
+ if (role.external !== undefined && role.external !== true) {
463
+ this.#problem(where, `its external is ${String(role.external)} (allowed: external: true, or no external)`)
464
+ }
465
+ if (role.external === true && role.member === undefined) {
466
+ this.#problem(where, 'is declared external and is not a membership role: only a role with member: { scope, … } may admit externals')
467
+ }
468
+ roles[name] = {
469
+ grants: [...role.grants],
470
+ member: role.member === undefined ? null : { scope: role.member.scope, assignableBy: [...role.member.assignableBy] },
471
+ ...(role.external === true ? { external: true } : {}),
472
+ }
473
+ }
474
+ return roles
475
+ }
476
+
477
+ // ── Mounts ──────────────────────────────────────────────────────────────────────────────────
478
+
479
+ #mount(name: string, mount: MountDeclaration<SchemaShape>): MountArtifact | null {
480
+ const where = `mount '${name}'`
481
+ if (mount.kind !== 'health') {
482
+ this.#options(where, mount.kind, mount.unknownOptions)
483
+ }
484
+ switch (mount.kind) {
485
+ case 'rpc': {
486
+ this.#prefix(where, mount.prefix)
487
+ this.#callers(where, mount.callers)
488
+ this.#reserve(mount.prefix)
489
+ for (const [path, procedure] of procedures(mount.procedures)) {
490
+ const id = `${name}.${path}`
491
+ this.#procedure({ kind: 'mount', name }, id, joinPath(mount.prefix, path), mount.callers, procedure)
492
+ this.#declare(`procedure '${id}'`, id, procedure.declared, { kind: 'rpc', readOnly: procedure.type === 'query', access: procedure.access })
493
+ }
494
+ return { kind: 'rpc', prefix: mount.prefix, callers: mount.callers }
495
+ }
496
+ case 'link':
497
+ return this.#link(name, mount)
498
+ case 'spa': {
499
+ if (mount.prefix !== '/') {
500
+ this.#prefix(where, mount.prefix)
501
+ }
502
+ this.#nonEmpty(`${where} dir`, mount.dir)
503
+ const requirement = this.#spaRequirement(where, mount.access)
504
+ // The bare prefix (`/admin`) is the mount's too, under its access: it redirects to `/admin/`.
505
+ const rows = mount.prefix === '/'
506
+ ? [{ id: name, path: joinPath(mount.prefix, '*') }]
507
+ : [{ id: name, path: joinPath(mount.prefix, '*') }, { id: spaPrefixRowId(name), path: mount.prefix }]
508
+ for (const { id, path } of rows) {
509
+ const source = { kind: 'mount' as const, name }
510
+ if (requirement === 'public') {
511
+ this.#row({ id, source, kind: 'public-spa', method: 'GET', path, callers: [], requirement: null, admission: null, input: null, output: null })
512
+ } else if (requirement !== null) {
513
+ this.#row({ id, source, kind: 'spa', method: 'GET', path, callers: ['human'], requirement, admission: null, input: null, output: null })
514
+ }
515
+ }
516
+ const publicVariables = this.#spaPublic(where, mount.public)
517
+ return { kind: 'spa', prefix: mount.prefix, dir: mount.dir, ...(publicVariables.length === 0 ? {} : { public: publicVariables }) }
518
+ }
519
+ case 'health':
520
+ this.#prefix(where, mount.path)
521
+ this.#anonymous(name, 'health', 'GET', mount.path, null, null)
522
+ return { kind: 'health', path: mount.path }
523
+ case 'form':
524
+ this.#prefix(where, mount.path)
525
+ this.#objectSchema(`${where} input`, mount.input)
526
+ this.#nonEmpty(`${where} appendTo`, mount.appendTo)
527
+ this.#anonymous(name, 'form', 'POST', mount.path, this.#bodyLimit(where, mount.maxBytes, CONTRACT_DEFAULTS.formMaxBytes), mount.input)
528
+ return {
529
+ kind: 'form',
530
+ path: mount.path,
531
+ appendTo: mount.appendTo,
532
+ ...this.#formAdmission(name, where, mount),
533
+ ...(mount.then === undefined ? {} : { then: this.#formJob(where, mount.then) }),
534
+ }
535
+ case 'webhook': {
536
+ this.#prefix(where, mount.path)
537
+ this.#objectSchema(`${where} input`, mount.input)
538
+ const boundTo = this.#scopeBinding(where, 'boundTo', mount.boundTo)
539
+ const members = boundTo?.kind === 'dimension' ? this.#contract.dimensions[boundTo.dimension]?.members : undefined
540
+ if (boundTo?.kind === 'dimension' && members === undefined) {
541
+ this.#problem(where, `is bound to '${boundTo.dimension}', which declares no members: { manage, list } to create bindings with`)
542
+ }
543
+ this.#actionList(where, mount.actions)
544
+ const path = joinPath(mount.path, `:${WEBHOOK_BINDING_PARAMETER}`)
545
+ this.#anonymous(name, 'webhook', 'POST', path, this.#bodyLimit(where, mount.maxBytes, CONTRACT_DEFAULTS.webhookMaxBytes), mount.input)
546
+ this.#declare(where, name, mount.declared, { kind: 'webhook', readOnly: false })
547
+ if (boundTo?.kind === 'dimension' && members !== undefined) {
548
+ this.#webhookBindings(name, boundTo.dimension, members.manage)
549
+ }
550
+ return boundTo === null ? null : { kind: 'webhook', path: mount.path, scheme: mount.scheme, boundTo, actions: [...mount.actions] }
551
+ }
552
+ default:
553
+ this.#problem(where, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health, c.form or c.webhook)')
554
+ return null
555
+ }
556
+ }
557
+
558
+ /** The job a form kicks: a cron job of this contract. */
559
+ #formJob(where: string, then: string): string {
560
+ const jobs = this.#contract.jobs
561
+ const job: { readonly kind: string } | undefined = Object.hasOwn(jobs, then) ? jobs[then] : undefined
562
+ if (job === undefined) {
563
+ const names = Object.keys(jobs)
564
+ this.#problem(
565
+ where,
566
+ `then '${then}' is not a job of the contract (jobs: ${names.length === 0 ? 'none' : names.join(', ')})${suggestion(then, names)}`,
567
+ )
568
+ } else if (job.kind !== 'cron') {
569
+ this.#problem(where, `then '${then}' is not a cron job (allowed: a job c.cron declares)`)
570
+ }
571
+ return then
572
+ }
573
+
574
+ /** A form's declared rate, honeypot and challenge, each present in the artifact only when declared. */
575
+ #formAdmission(name: string, where: string, mount: FormMountDeclaration<SchemaShape>): Pick<FormMountArtifact, 'rate' | 'honeypot' | 'challenge'> {
576
+ const fields = mount.input.type === 'object' ? mount.input.fields : {}
577
+ const admission: { rate?: FormRate; honeypot?: string; challenge?: FormChallenge } = {}
578
+ if (mount.rate !== undefined) {
579
+ admission.rate = this.#formRate(`${where} rate`, mount.rate)
580
+ }
581
+ if (mount.honeypot !== undefined) {
582
+ const field = fields[mount.honeypot]
583
+ if (field === undefined) {
584
+ this.#problem(where, `honeypot '${mount.honeypot}' is not a field of its input (allowed: a z.string() field the form's page hides)`)
585
+ } else if (!isStringField(field)) {
586
+ this.#problem(where, `honeypot '${mount.honeypot}' is not a string field (allowed: z.string() or z.optional(z.string()))`)
587
+ }
588
+ if (mount.honeypot === FORM_CHALLENGE_FIELD) {
589
+ this.#problem(where, `honeypot '${mount.honeypot}' is the field the challenge token travels in`)
590
+ }
591
+ admission.honeypot = mount.honeypot
592
+ }
593
+ if (mount.challenge !== undefined) {
594
+ admission.challenge = this.#formChallenge(name, where, mount.challenge, fields)
595
+ }
596
+ return admission
597
+ }
598
+
599
+ #formRate(where: string, rate: object): FormRate {
600
+ this.#keys(where, rate, ['perClient', 'perForm'])
601
+ const parsed: { perClient?: number; perForm?: number } = {}
602
+ for (const key of ['perClient', 'perForm'] as const) {
603
+ const value: unknown = Reflect.get(rate, key)
604
+ if (value === undefined) {
605
+ continue
606
+ }
607
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > FORM_LIMITS.maxPerMinute) {
608
+ this.#problem(where, `${key} ${String(value)} is not a whole number of submissions per minute from 1 to ${FORM_LIMITS.maxPerMinute}`)
609
+ continue
610
+ }
611
+ parsed[key] = value
612
+ }
613
+ return parsed
614
+ }
615
+
616
+ #formChallenge(name: string, where: string, challenge: object, fields: Readonly<Record<string, SchemaShape>>): FormChallenge | undefined {
617
+ this.#keys(`${where} challenge`, challenge, ['provider', 'secret', 'siteKey'])
618
+ const provider: unknown = Reflect.get(challenge, 'provider')
619
+ const secret: unknown = Reflect.get(challenge, 'secret')
620
+ const siteKey: unknown = Reflect.get(challenge, 'siteKey')
621
+ if (provider !== 'turnstile') {
622
+ this.#problem(where, `challenge provider ${JSON.stringify(provider)} is not one the framework verifies (allowed: 'turnstile')`)
623
+ return undefined
624
+ }
625
+ if (typeof secret !== 'string' || !APPLICATION_VARIABLE.test(secret) || secret.startsWith('FABRIKA_')) {
626
+ this.#problem(
627
+ where,
628
+ `challenge secret ${
629
+ JSON.stringify(secret)
630
+ } is not the name of an application variable (allowed: 'TURNSTILE_SECRET'; never the secret itself, never FABRIKA_*)`,
631
+ )
632
+ return undefined
633
+ }
634
+ if (!CHALLENGE_ACTION.test(name)) {
635
+ this.#problem(where, `a form with a challenge is named as the widget's action, at most 32 letters, digits, '_' or '-'`)
636
+ }
637
+ const token = fields[FORM_CHALLENGE_FIELD]
638
+ if (token === undefined || token.type !== 'string') {
639
+ this.#problem(where, `declares a challenge, so its input needs the required field ${FORM_CHALLENGE_FIELD}: z.string(), the widget's token`)
640
+ }
641
+ this.#challenges.push({ form: name, provider, secret })
642
+ if (siteKey === undefined || !this.#applicationVariable(where, 'challenge siteKey', siteKey, 'TURNSTILE_SITE_KEY')) {
643
+ return { provider, secret }
644
+ }
645
+ this.#publicVariables.push({ where, field: 'challenge siteKey', name: siteKey })
646
+ return { provider, secret, siteKey }
647
+ }
648
+
649
+ /** The variables an SPA mount serves its pages; `[]` when it declares none. */
650
+ #spaPublic(where: string, declared: readonly string[] | undefined): readonly string[] {
651
+ if (declared === undefined) {
652
+ return []
653
+ }
654
+ if (declared.length === 0) {
655
+ this.#problem(where, "public names no variable (allowed: public: ['SIGNUP_URL'], or no public)")
656
+ }
657
+ this.#unique(where, 'public', declared)
658
+ const names = declared.filter((name) => this.#applicationVariable(where, 'public', name, 'SIGNUP_URL'))
659
+ for (const name of names) {
660
+ this.#publicVariables.push({ where, field: 'public', name })
661
+ }
662
+ return names
663
+ }
664
+
665
+ /** Whether `name` names an application variable: a value or a framework variable is refused. */
666
+ #applicationVariable(where: string, field: string, name: unknown, example: string): name is string {
667
+ if (typeof name === 'string' && APPLICATION_VARIABLE.test(name) && !name.startsWith('FABRIKA_')) {
668
+ return true
669
+ }
670
+ this.#problem(
671
+ where,
672
+ `${field} ${JSON.stringify(name)} is not the name of an application variable (allowed: '${example}'; never a value, never FABRIKA_*)`,
673
+ )
674
+ return false
675
+ }
676
+
677
+ /** A browser config serves every public variable to anyone who opens a page, so none of them may hold a secret. */
678
+ #checkPublicVariables(): void {
679
+ for (const variable of this.#publicVariables) {
680
+ const owner = this.#challenges.find((challenge) => challenge.secret === variable.name)
681
+ if (owner !== undefined) {
682
+ this.#problem(variable.where, `${variable.field} '${variable.name}' is the secret of form '${owner.form}'; a secret is never public`)
683
+ }
684
+ }
685
+ }
686
+
687
+ /** A holder of the dimension's `members.manage` there binds a provider account to one value, and unbinds it. */
688
+ #webhookBindings(name: string, dimension: string, manage: string): void {
689
+ const access: AccessForm = { form: 'in', dimension, from: WEBHOOK_SCOPE_FIELD }
690
+ const binding = (operation: 'bind' | 'unbind', input: SchemaShape, output: SchemaShape): EndpointRow => ({
691
+ id: `webhooks.${name}.${operation}`,
692
+ source: { kind: 'mount', name },
693
+ kind: 'mutation',
694
+ method: 'POST',
695
+ path: joinPath(WEBHOOKS_PREFIX, `webhooks.${name}.${operation}`),
696
+ callers: ['human'],
697
+ requirement: { kind: 'action', action: manage, access },
698
+ admission: JSON_ADMISSION,
699
+ input,
700
+ output,
701
+ })
702
+ this.#row(binding('bind', WEBHOOK_BIND_INPUT, WEBHOOK_BIND_OUTPUT))
703
+ this.#row(binding('unbind', WEBHOOK_UNBIND_INPUT, WEBHOOK_UNBIND_OUTPUT))
704
+ this.#reserve(WEBHOOKS_PREFIX)
705
+ }
706
+
707
+ #link(name: string, mount: LinkMountDeclaration<SchemaShape>): MountArtifact {
708
+ const where = `mount '${name}'`
709
+ this.#prefix(where, mount.prefix)
710
+ this.#nonEmpty(`${where} spa`, mount.spa)
711
+ this.#knownDimension(where, mount.dimension)
712
+ if (mount.grants.length === 0) {
713
+ this.#problem(where, 'grants nothing (allowed: grants: [<exact actions>])')
714
+ }
715
+ this.#unique(where, 'grants', mount.grants)
716
+ this.#actionList(where, mount.grants)
717
+ this.#knownAction(`${where} issuedBy`, mount.issuedBy)
718
+ if (!Number.isInteger(mount.maxDays) || mount.maxDays < 1) {
719
+ this.#problem(where, `maxDays ${mount.maxDays} is not a positive whole number of days`)
720
+ }
721
+ this.#row({
722
+ id: name,
723
+ source: { kind: 'mount', name },
724
+ kind: 'link-spa',
725
+ method: 'GET',
726
+ path: joinPath(mount.prefix, '*'),
727
+ callers: [],
728
+ requirement: null,
729
+ admission: null,
730
+ input: null,
731
+ output: null,
732
+ })
733
+ const api = joinPath(mount.prefix, LINK_API_SEGMENT)
734
+ this.#reserve(api)
735
+ for (const [path, procedure] of procedures(mount.procedures)) {
736
+ const id = `${name}.${path}`
737
+ const requirement = this.#linkRequirement(`procedure '${id}'`, procedure.access, mount)
738
+ this.#procedureSchemas(`procedure '${id}'`, procedure)
739
+ this.#declare(`procedure '${id}'`, id, procedure.declared, { kind: 'link', readOnly: procedure.type === 'query' })
740
+ if (requirement !== null) {
741
+ this.#row({
742
+ id,
743
+ source: { kind: 'mount', name },
744
+ kind: procedure.type,
745
+ method: 'POST',
746
+ path: joinPath(api, path),
747
+ callers: ['link'],
748
+ requirement,
749
+ admission: JSON_ADMISSION,
750
+ input: procedure.input,
751
+ output: this.#output(`procedure '${id}'`, procedure),
752
+ })
753
+ }
754
+ }
755
+ return {
756
+ kind: 'link',
757
+ prefix: mount.prefix,
758
+ spa: mount.spa,
759
+ dimension: mount.dimension,
760
+ grants: [...mount.grants],
761
+ issuedBy: mount.issuedBy,
762
+ maxDays: mount.maxDays,
763
+ }
764
+ }
765
+
766
+ #procedure(
767
+ source: EndpointSource,
768
+ id: string,
769
+ path: string,
770
+ callers: readonly [Caller, ...Caller[]],
771
+ procedure: ProcedureDeclaration<SchemaShape>,
772
+ ): void {
773
+ const where = `procedure '${id}'`
774
+ this.#procedureSchemas(where, procedure)
775
+ const requirement = this.#requirement(where, procedure)
776
+ if (requirement !== null) {
777
+ this.#row({
778
+ id,
779
+ source,
780
+ kind: procedure.type,
781
+ method: 'POST',
782
+ path,
783
+ callers,
784
+ requirement,
785
+ admission: JSON_ADMISSION,
786
+ input: procedure.input,
787
+ output: this.#output(where, procedure),
788
+ })
789
+ }
790
+ }
791
+
792
+ /**
793
+ * A procedure's answer: its output schema, or for an `attachment()` the file it encodes from the rows. A column is a
794
+ * scalar cell, the name is fixed and safe in a header, and each limit is within `ATTACHMENT_LIMITS`.
795
+ */
796
+ #output(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>): OutputShape {
797
+ const attachment = procedure.attachment
798
+ if (attachment === undefined) {
799
+ return procedure.output
800
+ }
801
+ const at = `${where} attachment`
802
+ this.#options(at, 'attachment', attachment.unknownOptions)
803
+ const format: AttachmentFormat = attachment.format === 'xlsx' ? 'xlsx' : 'csv'
804
+ if (attachment.format !== format) {
805
+ this.#problem(at, `format '${attachment.format}' is not a file format the framework encodes (allowed: 'csv', 'xlsx')`)
806
+ }
807
+ const filename = ATTACHMENT_FILENAMES[format]
808
+ if (!filename.test(attachment.filename)) {
809
+ this.#problem(at, `filename '${attachment.filename}' is not a file name (allowed: ${filename.source})`)
810
+ }
811
+ const maxBytes = this.#attachmentLimit(at, 'maxBytes', attachment.maxBytes, ATTACHMENT_LIMITS.maxBytes)
812
+ const maxRows = this.#attachmentLimit(at, 'maxRows', attachment.maxRows ?? ATTACHMENT_LIMITS.defaultMaxRows, ATTACHMENT_LIMITS.maxRows)
813
+ const items = procedure.output.type === 'array' ? procedure.output.items : null
814
+ if (items?.type !== 'object') {
815
+ this.#problem(at, 'the row is not an object (allowed: row: z.strictObject({ … }))')
816
+ return procedure.output
817
+ }
818
+ const columns = Object.entries(items.fields).map(([name, shape]) => ({ name, shape }))
819
+ if (columns.length === 0) {
820
+ this.#problem(at, 'the row has no field, so the file would have no column')
821
+ }
822
+ for (const column of columns) {
823
+ if (!isCell(column.shape)) {
824
+ this.#problem(at, `column '${column.name}' is not a cell (allowed: a string, number, boolean, enum or literal, optional or nullable)`)
825
+ }
826
+ }
827
+ return { type: 'attachment', format, filename: attachment.filename, maxBytes, maxRows, columns }
828
+ }
829
+
830
+ #attachmentLimit(where: string, field: string, value: number, ceiling: number): number {
831
+ if (!Number.isInteger(value) || value < 1 || value > ceiling) {
832
+ this.#problem(where, `${field} ${value} is not a whole number from 1 to ${ceiling}`)
833
+ }
834
+ return value
835
+ }
836
+
837
+ #procedureSchemas(where: string, procedure: ProcedureDeclaration<SchemaShape, unknown>): void {
838
+ this.#options(where, 'procedure', procedure.unknownOptions)
839
+ if (procedure.type !== 'query' && procedure.type !== 'mutation') {
840
+ this.#problem(where, `is not a procedure (allowed: c.query or c.mutation)`)
841
+ }
842
+ if (procedure.input !== null) {
843
+ this.#objectSchema(`${where} input`, procedure.input)
844
+ }
845
+ }
846
+
847
+ #anonymous(
848
+ name: string,
849
+ kind: 'health' | 'form' | 'webhook' | 'public-spa',
850
+ method: 'GET' | 'POST',
851
+ path: string,
852
+ admission: BodyAdmission | null,
853
+ input: SchemaShape | null,
854
+ ): void {
855
+ this.#row({ id: name, source: { kind: 'mount', name }, kind, method, path, callers: [], requirement: null, admission, input, output: null })
856
+ }
857
+
858
+ #row(row: EndpointRow): void {
859
+ this.#endpoints.push(row)
860
+ }
861
+
862
+ #reserve(prefix: string | null): void {
863
+ if (prefix !== null && !this.#reservedPrefixes.includes(prefix)) {
864
+ this.#reservedPrefixes.push(prefix)
865
+ }
866
+ }
867
+
868
+ // ── Requirements ────────────────────────────────────────────────────────────────────────────
869
+
870
+ #requirement(where: string, procedure: ProcedureDeclaration<SchemaShape>): EndpointRequirement | null {
871
+ const access: AccessDeclaration = procedure.access
872
+ const form = accessForm(access)
873
+ if (form === null) {
874
+ this.#problem(
875
+ where,
876
+ `access ${
877
+ JSON.stringify(access)
878
+ } is not one access form (allowed: { member: true }, { action, over }, { action, in, from }, { action, on, from }, { action, creates }, { action, app: true })`,
879
+ )
880
+ return null
881
+ }
882
+ if (form.form === 'member') {
883
+ return { kind: 'member' }
884
+ }
885
+ this.#knownAction(where, form.action)
886
+ if (form.access.form !== 'app') {
887
+ this.#knownDimension(where, form.access.dimension)
888
+ }
889
+ if ('from' in access) {
890
+ this.#fromField(where, procedure.input, access.from)
891
+ }
892
+ if (form.access.form === 'creates') {
893
+ if (procedure.type !== 'mutation') {
894
+ this.#problem(where, `creates a '${form.access.dimension}' in a query (allowed: c.mutation)`)
895
+ }
896
+ const created = this.#contract.dimensions[form.access.dimension]
897
+ if (created?.creator === undefined) {
898
+ this.#problem(where, `creates a '${form.access.dimension}', which declares no creator role`)
899
+ }
900
+ if (created?.in !== undefined) {
901
+ this.#problem(
902
+ where,
903
+ `creates a '${form.access.dimension}', which lies in '${created.in}' (allowed: a dimension without in; a contained value is a row a store inserts)`,
904
+ )
905
+ }
906
+ }
907
+ return { kind: 'action', action: form.action, access: form.access }
908
+ }
909
+
910
+ #linkRequirement(where: string, access: LinkAccessDeclaration, mount: LinkMountDeclaration<SchemaShape>): EndpointRequirement | null {
911
+ if (!hasExactKeys(access, ['action'])) {
912
+ this.#problem(where, `access ${JSON.stringify(access)} is not a link access (allowed: { action: <one of the link's grants> })`)
913
+ return null
914
+ }
915
+ if (!mount.grants.includes(access.action)) {
916
+ this.#problem(where, `needs '${access.action}', which the link does not grant (grants: ${mount.grants.join(', ')})`)
917
+ }
918
+ return { kind: 'action', action: access.action, access: { form: 'on', dimension: mount.dimension, from: null } }
919
+ }
920
+
921
+ /** `'public'` is the anonymous `public-spa` row: no requirement at all. */
922
+ #spaRequirement(where: string, access: SpaAccessDeclaration): EndpointRequirement | 'public' | null {
923
+ if (hasExactKeys(access, ['public']) && 'public' in access && access.public === true) {
924
+ return 'public'
925
+ }
926
+ if (hasExactKeys(access, ['member']) && 'member' in access && access.member === true) {
927
+ return { kind: 'member' }
928
+ }
929
+ if (hasExactKeys(access, ['action', 'over']) && 'over' in access) {
930
+ this.#knownAction(where, access.action)
931
+ this.#knownDimension(where, access.over)
932
+ return { kind: 'action', action: access.action, access: { form: 'over', dimension: access.over } }
933
+ }
934
+ if (hasExactKeys(access, ['action', 'app']) && 'app' in access && access.app === true) {
935
+ this.#knownAction(where, access.action)
936
+ return { kind: 'action', action: access.action, access: { form: 'app' } }
937
+ }
938
+ this.#problem(
939
+ where,
940
+ `access ${JSON.stringify(access)} is not an SPA access (allowed: { member: true }, { action, over }, { action, app: true } or { public: true })`,
941
+ )
942
+ return null
943
+ }
944
+
945
+ /** `from` must name a required string field of the input, which the framework removes before the handler. */
946
+ #fromField(where: string, input: SchemaShape | null, from: string): void {
947
+ const field = input?.type === 'object' ? input.fields[from] : undefined
948
+ if (field === undefined || !isRequiredString(field)) {
949
+ this.#problem(where, `access.from '${from}' is not a required string field of the input (allowed: a string, a string literal or an enum)`)
950
+ }
951
+ }
952
+
953
+ #scopeBinding(where: string, field: string, value: string): ScopeBinding | null {
954
+ if (value === APP_SCOPE) {
955
+ return { kind: 'app' }
956
+ }
957
+ if (!Object.hasOwn(this.#contract.dimensions, value)) {
958
+ this.#problem(where, `${field} '${value}' is neither '${APP_SCOPE}' nor a dimension of the contract`)
959
+ return null
960
+ }
961
+ return { kind: 'dimension', dimension: value }
962
+ }
963
+
964
+ // ── Sections ────────────────────────────────────────────────────────────────────────────────
965
+
966
+ #files(dimensions: Record<string, DimensionArtifact>): Record<string, FileCollectionArtifact> {
967
+ const files: Record<string, FileCollectionArtifact> = {}
968
+ for (const [name, collection] of Object.entries(this.#contract.files)) {
969
+ const where = `files '${name}'`
970
+ if (!this.#name(where, name) || !this.#kind(where, collection, 'files', 'c.files({ … })')) {
971
+ continue
972
+ }
973
+ const artifact = this.#fileCollection(where, collection)
974
+ const owner = this.#tables[collection.owner]
975
+ if (owner?.scope.mode !== 'dimension') {
976
+ this.#problem(where, `its owner '${collection.owner}' is not a table with a dimension scope in src/db/tables.ts`)
977
+ continue
978
+ }
979
+ const dimension = `file:${name}`
980
+ dimensions[dimension] = { in: owner.scope.dimension, creator: null, members: null }
981
+ files[name] = artifact
982
+ const source: EndpointSource = { kind: 'files', name }
983
+ const paths = fileCollectionPaths(FILES_PREFIX, name)
984
+ const [firstType, ...otherTypes] = collection.types
985
+ if (firstType !== undefined) {
986
+ this.#row({
987
+ id: `files.${name}.upload`,
988
+ source,
989
+ kind: 'upload',
990
+ method: 'POST',
991
+ path: `${paths.upload}/:owner`,
992
+ callers: ['human'],
993
+ requirement: { kind: 'action', action: collection.write, access: { form: 'on', dimension: owner.scope.dimension, from: 'owner' } },
994
+ admission: { contentTypes: [firstType, ...otherTypes], maxBytes: collection.maxBytes },
995
+ input: null,
996
+ output: ID_OBJECT,
997
+ })
998
+ }
999
+ this.#row({
1000
+ id: `files.${name}.download`,
1001
+ source,
1002
+ kind: 'download',
1003
+ method: 'GET',
1004
+ path: `${paths.download}/:fileId`,
1005
+ callers: ['human'],
1006
+ requirement: { kind: 'action', action: collection.read, access: { form: 'on', dimension, from: 'fileId' } },
1007
+ admission: null,
1008
+ input: null,
1009
+ output: null,
1010
+ })
1011
+ this.#row({
1012
+ id: `files.${name}.list`,
1013
+ source,
1014
+ kind: 'query',
1015
+ method: 'POST',
1016
+ path: paths.list,
1017
+ callers: ['human'],
1018
+ requirement: { kind: 'action', action: collection.read, access: { form: 'on', dimension: owner.scope.dimension, from: 'owner' } },
1019
+ admission: JSON_ADMISSION,
1020
+ input: FILE_LIST_INPUT,
1021
+ output: FILE_LIST_OUTPUT,
1022
+ })
1023
+ this.#row({
1024
+ id: `files.${name}.remove`,
1025
+ source,
1026
+ kind: 'mutation',
1027
+ method: 'POST',
1028
+ path: paths.remove,
1029
+ callers: ['human'],
1030
+ requirement: { kind: 'action', action: collection.delete, access: { form: 'on', dimension, from: 'id' } },
1031
+ admission: JSON_ADMISSION,
1032
+ input: ID_OBJECT,
1033
+ output: ID_OBJECT,
1034
+ })
1035
+ if (artifact.link !== false) {
1036
+ this.#linkFiles(where, name, collection, owner.scope.dimension, artifact.link)
1037
+ }
1038
+ }
1039
+ return files
1040
+ }
1041
+
1042
+ /**
1043
+ * The rows a link holder reaches a collection by (upload, list, download): under the API prefix of every link mount
1044
+ * bound to the owner's dimension (`link: true`) or of each one `link` names, caller `link`, the owner being the
1045
+ * link's object (no parameter names it). A serving mount grants the collection's write and read actions, so a key of
1046
+ * it can do what the rows require; a mount `link` does not name serves no row and need not grant them.
1047
+ */
1048
+ #linkFiles(where: string, name: string, collection: FilesDeclaration, ownerDimension: string, link: true | readonly string[]): void {
1049
+ const bound = Object.entries(this.#contract.mounts).flatMap(([mountName, mount]) =>
1050
+ mount.kind === 'link' && mount.dimension === ownerDimension ? [{ mountName, mount }] : []
1051
+ )
1052
+ const boundForm = `c.link(prefix, { dimension: '${ownerDimension}', … })`
1053
+ if (link === true && bound.length === 0) {
1054
+ this.#problem(where, `sets link: true, and no link mount is bound to '${ownerDimension}' (allowed: ${boundForm})`)
1055
+ }
1056
+ const mounts = link === true ? bound : link.flatMap((mountName) => {
1057
+ const found = bound.find((candidate) => candidate.mountName === mountName)
1058
+ if (found === undefined) {
1059
+ const allowed = bound.length === 0 ? boundForm : bound.map((candidate) => candidate.mountName).join(', ')
1060
+ this.#problem(where, `link names '${mountName}', which is no link mount bound to '${ownerDimension}' (allowed: ${allowed})`)
1061
+ }
1062
+ return found === undefined ? [] : [found]
1063
+ })
1064
+ const [firstType, ...otherTypes] = collection.types
1065
+ for (const { mountName, mount } of mounts) {
1066
+ const missing = [collection.write, collection.read].filter((action, index, all) => all.indexOf(action) === index && !mount.grants.includes(action))
1067
+ if (missing.length > 0) {
1068
+ const subject = link === true ? `sets link: true, and link mount '${mountName}'` : `link names mount '${mountName}', which`
1069
+ this.#problem(
1070
+ where,
1071
+ `${subject} grants no ${missing.map((action) => `'${action}'`).join(' or ')} (allowed: grants: [${
1072
+ [...mount.grants, ...missing].map((action) => `'${action}'`).join(', ')
1073
+ }])`,
1074
+ )
1075
+ continue
1076
+ }
1077
+ const source: EndpointSource = { kind: 'mount', name: mountName }
1078
+ const paths = fileCollectionPaths(joinPath(mount.prefix, LINK_API_SEGMENT), name)
1079
+ const ofTheLink = (action: string): EndpointRequirement => ({
1080
+ kind: 'action',
1081
+ action,
1082
+ access: { form: 'on', dimension: ownerDimension, from: null },
1083
+ })
1084
+ if (firstType !== undefined) {
1085
+ this.#row({
1086
+ id: `${mountName}.files.${name}.upload`,
1087
+ source,
1088
+ kind: 'upload',
1089
+ method: 'POST',
1090
+ path: paths.upload,
1091
+ callers: ['link'],
1092
+ requirement: ofTheLink(collection.write),
1093
+ admission: { contentTypes: [firstType, ...otherTypes], maxBytes: collection.maxBytes },
1094
+ input: null,
1095
+ output: ID_OBJECT,
1096
+ })
1097
+ }
1098
+ this.#row({
1099
+ id: `${mountName}.files.${name}.list`,
1100
+ source,
1101
+ kind: 'query',
1102
+ method: 'POST',
1103
+ path: paths.list,
1104
+ callers: ['link'],
1105
+ requirement: ofTheLink(collection.read),
1106
+ admission: JSON_ADMISSION,
1107
+ input: FILE_LINK_LIST_INPUT,
1108
+ output: FILE_LIST_OUTPUT,
1109
+ })
1110
+ this.#row({
1111
+ id: `${mountName}.files.${name}.download`,
1112
+ source,
1113
+ kind: 'download',
1114
+ method: 'GET',
1115
+ path: `${paths.download}/:fileId`,
1116
+ callers: ['link'],
1117
+ requirement: ofTheLink(collection.read),
1118
+ admission: null,
1119
+ input: null,
1120
+ output: null,
1121
+ })
1122
+ }
1123
+ }
1124
+
1125
+ #fileCollection(where: string, collection: FilesDeclaration): FileCollectionArtifact {
1126
+ this.#options(where, 'files', collection.unknownOptions)
1127
+ this.#knownAction(`${where} read`, collection.read)
1128
+ this.#knownAction(`${where} write`, collection.write)
1129
+ this.#knownAction(`${where} delete`, collection.delete)
1130
+ this.#positiveInteger(`${where} maxBytes`, collection.maxBytes)
1131
+ if (collection.maxPerOwner !== undefined) {
1132
+ this.#positiveInteger(`${where} maxPerOwner`, collection.maxPerOwner)
1133
+ }
1134
+ this.#unique(where, 'types', collection.types)
1135
+ const link = this.#fileLink(where, collection.link)
1136
+ for (const type of collection.types) {
1137
+ if (!MIME_TYPE.test(type)) {
1138
+ this.#problem(where, `'${type}' is not an exact media type (allowed: 'image/png'; no wildcards or parameters)`)
1139
+ }
1140
+ }
1141
+ if (collection.types.length === 0) {
1142
+ this.#problem(where, 'admits no media type (allowed: types: [<exact media types>])')
1143
+ }
1144
+ if (collection.scan !== undefined && typeof collection.scan !== 'boolean') {
1145
+ this.#problem(where, 'scan is true or false')
1146
+ }
1147
+ for (const type of collection.scan === true ? collection.types.filter((candidate) => FILE_LIMITS.unscannedTypes.includes(candidate)) : []) {
1148
+ this.#problem(
1149
+ where,
1150
+ `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)`,
1151
+ )
1152
+ }
1153
+ if (collection.scan === true && collection.maxBytes > FILE_LIMITS.maxScannedBytes) {
1154
+ this.#problem(
1155
+ where,
1156
+ `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)`,
1157
+ )
1158
+ }
1159
+ return {
1160
+ owner: collection.owner,
1161
+ read: collection.read,
1162
+ write: collection.write,
1163
+ delete: collection.delete,
1164
+ maxBytes: collection.maxBytes,
1165
+ types: [...collection.types],
1166
+ maxPerOwner: collection.maxPerOwner ?? CONTRACT_DEFAULTS.fileMaxPerOwner,
1167
+ link,
1168
+ ...(collection.scan === true ? { scan: true } : {}),
1169
+ }
1170
+ }
1171
+
1172
+ /** `link` as the artifact holds it: a boolean, or the link mounts it names, each once. */
1173
+ #fileLink(where: string, link: FilesDeclaration['link']): boolean | readonly string[] {
1174
+ if (link === undefined || typeof link === 'boolean') return link ?? CONTRACT_DEFAULTS.fileLink
1175
+ if (link.length === 0) this.#problem(where, "link names no mount (allowed: link: true, or link: ['<link mount>'])")
1176
+ this.#unique(where, 'link', link)
1177
+ return [...link]
1178
+ }
1179
+
1180
+ #jobs(): Record<string, JobArtifact> {
1181
+ const jobs: Record<string, JobArtifact> = {}
1182
+ for (const [name, job] of Object.entries(this.#contract.jobs)) {
1183
+ const where = `job '${name}'`
1184
+ if (!this.#name(where, name) || !this.#kind(where, job, 'cron', "c.cron('<schedule>', { … })")) {
1185
+ continue
1186
+ }
1187
+ const scope = this.#job(where, job)
1188
+ this.#declare(where, `${JOB_SITE_PREFIX}${name}`, job.declared, { kind: 'job', readOnly: false, actions: job.actions })
1189
+ if (scope !== null) {
1190
+ jobs[name] = {
1191
+ schedule: job.schedule,
1192
+ actions: [...job.actions],
1193
+ scope,
1194
+ timeout: job.timeout ?? null,
1195
+ retries: job.retries ?? CONTRACT_DEFAULTS.jobRetries,
1196
+ }
1197
+ }
1198
+ }
1199
+ return jobs
1200
+ }
1201
+
1202
+ #job(where: string, job: CronDeclaration): ScopeBinding | null {
1203
+ this.#options(where, 'cron', job.unknownOptions)
1204
+ const fields = job.schedule.split(' ')
1205
+ if (fields.length !== 5 || !fields.every((field) => CRON_FIELD.test(field))) {
1206
+ this.#problem(where, `schedule '${job.schedule}' is not five cron fields separated by single spaces (allowed: '0 3 * * *')`)
1207
+ }
1208
+ this.#actionList(where, job.actions)
1209
+ if (job.timeout !== undefined && !TIMEOUT.test(job.timeout)) {
1210
+ this.#problem(where, `timeout '${job.timeout}' is not a duration (allowed: '30s', '10m', '1h')`)
1211
+ }
1212
+ if (job.retries !== undefined && (!Number.isInteger(job.retries) || job.retries < 0)) {
1213
+ this.#problem(where, `retries ${job.retries} is not a whole number of at least 0`)
1214
+ }
1215
+ return this.#scopeBinding(where, 'scope', job.scope)
1216
+ }
1217
+
1218
+ #exports(): Record<string, ExportArtifact> {
1219
+ const exports: Record<string, ExportArtifact> = {}
1220
+ for (const [name, declaration] of Object.entries(this.#contract.exports)) {
1221
+ const where = `export '${name}'`
1222
+ if (!this.#name(where, name) || !this.#kind(where, declaration, 'export', 'c.export({ … })')) {
1223
+ continue
1224
+ }
1225
+ const callers = this.#consumers(where, declaration)
1226
+ exports[name] = { version: declaration.version, consumers: [...declaration.consumers], audience: declaration.audience }
1227
+ if (callers === null) {
1228
+ continue
1229
+ }
1230
+ const source: EndpointSource = { kind: 'export', name }
1231
+ const base = exportBase(name, declaration.version)
1232
+ for (const [path, procedure] of procedures(declaration.procedures)) {
1233
+ const id = `${base}.${path}`
1234
+ if (procedure.type !== 'query') {
1235
+ this.#problem(`procedure '${id}'`, 'an export serves queries only (allowed: c.query)')
1236
+ }
1237
+ if (!('action' in procedure.access)) {
1238
+ // The consumer's key holds exactly the export's actions, so a `member` procedure would have none to hold.
1239
+ this.#problem(`procedure '${id}'`, 'an export procedure names its action (allowed: { action, over })')
1240
+ } else if ('app' in procedure.access) {
1241
+ this.#problem(
1242
+ `procedure '${id}'`,
1243
+ 'an export procedure reads in one dimension (allowed: { action, over }, { action, in, from }, { action, on, from })',
1244
+ )
1245
+ }
1246
+ if (procedure.attachment !== undefined) {
1247
+ this.#problem(`procedure '${id}'`, 'an export answers its consumers JSON; an attachment() is for an RPC or a link procedure')
1248
+ }
1249
+ this.#readAction(`procedure '${id}'`, procedure.access)
1250
+ this.#procedure(source, id, joinPath(CONNECT_PREFIX, id), callers, procedure)
1251
+ this.#declare(`procedure '${id}'`, id, procedure.declared, { kind: 'export', readOnly: true })
1252
+ }
1253
+ const requirement = this.#descriptionRequirement(where, base)
1254
+ if (requirement !== null) {
1255
+ // The description a consumer vendors, behind the callers and the action of the procedures it describes.
1256
+ this.#row({
1257
+ id: base,
1258
+ source,
1259
+ kind: 'query',
1260
+ method: 'POST',
1261
+ path: joinPath(CONNECT_PREFIX, base),
1262
+ callers,
1263
+ requirement,
1264
+ admission: JSON_ADMISSION,
1265
+ input: null,
1266
+ output: null,
1267
+ })
1268
+ }
1269
+ }
1270
+ return exports
1271
+ }
1272
+
1273
+ /**
1274
+ * The requirement of an export's description: the one requirement its procedures share. A description shows every
1275
+ * procedure, so it needs what each procedure needs; v1 exports declare one action, in one dimension, for all of them.
1276
+ */
1277
+ #descriptionRequirement(where: string, base: string): EndpointRequirement | null {
1278
+ const requirements = this.#endpoints.filter((row) => row.id.startsWith(`${base}.`)).map((row) => row.requirement)
1279
+ const [first] = requirements
1280
+ if (first === undefined || first === null) {
1281
+ this.#problem(where, 'exports no procedure (allowed: procedures: { <name>: c.query({ … }) })')
1282
+ return null
1283
+ }
1284
+ const dimensionOf = (requirement: EndpointRequirement): string | null =>
1285
+ requirement.kind === 'action' && requirement.access.form !== 'app' ? requirement.access.dimension : null
1286
+ const dimension = dimensionOf(first)
1287
+ if (first.kind === 'action' && dimension === null) {
1288
+ // `#exports` refused the app form already: a description needs one dimension.
1289
+ return null
1290
+ }
1291
+ const description: EndpointRequirement = first.kind === 'member' || dimension === null
1292
+ ? first
1293
+ : { kind: 'action', action: first.action, access: { form: 'over', dimension } }
1294
+ const same = requirements.every((requirement) =>
1295
+ requirement !== null && (requirement.kind === 'member'
1296
+ ? description.kind === 'member'
1297
+ : description.kind === 'action' && requirement.action === description.action && dimensionOf(requirement) === dimension)
1298
+ )
1299
+ if (!same) {
1300
+ this.#problem(where, 'its procedures need different actions or dimensions (allowed: one action in one dimension for every procedure of an export)')
1301
+ return null
1302
+ }
1303
+ return description
1304
+ }
1305
+
1306
+ /** An export runs read-only, so its action may not be one a table writes with, nor one that manages members. */
1307
+ #readAction(where: string, access: AccessDeclaration): void {
1308
+ if (!('action' in access)) {
1309
+ return
1310
+ }
1311
+ for (const [table, policy] of Object.entries(this.#tables)) {
1312
+ const { insert, update, delete: remove } = policy.actions
1313
+ if (access.action === insert || access.action === update || access.action === remove) {
1314
+ this.#problem(where, `'${access.action}' writes table '${table}'; an export reads only (allowed: a read action)`)
1315
+ }
1316
+ }
1317
+ for (const [dimension, declared] of Object.entries(this.#contract.dimensions)) {
1318
+ if (declared.members?.manage === access.action) {
1319
+ this.#problem(where, `'${access.action}' manages the members of '${dimension}'; an export reads only (allowed: a read action)`)
1320
+ }
1321
+ }
1322
+ }
1323
+
1324
+ #consumers(where: string, declaration: ExportDeclaration<SchemaShape>): readonly [Caller, ...Caller[]] | null {
1325
+ this.#options(where, 'export', declaration.unknownOptions)
1326
+ this.#positiveInteger(`${where} version`, declaration.version)
1327
+ if (declaration.audience !== AUDIENCE) {
1328
+ this.#problem(where, `audience '${declaration.audience}' is not supported (allowed: '${AUDIENCE}')`)
1329
+ }
1330
+ this.#unique(where, 'consumers', declaration.consumers)
1331
+ for (const consumer of declaration.consumers) {
1332
+ if (!APP_ID.test(consumer) || consumer === this.#contract.app) {
1333
+ this.#problem(where, `consumer '${consumer}' is not another application's id`)
1334
+ }
1335
+ }
1336
+ if (declaration.consumers.length === 0) {
1337
+ this.#problem(where, 'names no consumer (allowed: consumers: [<application ids>]; there is no wildcard)')
1338
+ return null
1339
+ }
1340
+ const [first, ...rest] = declaration.consumers.map((consumer): Caller => `app:${consumer}`)
1341
+ return first === undefined ? null : [first, ...rest]
1342
+ }
1343
+
1344
+ #imports(): Record<string, ImportArtifact> {
1345
+ const imports: Record<string, ImportArtifact> = {}
1346
+ const procedureIds = new Set(
1347
+ this.#endpoints.filter((row) => row.source.kind === 'mount' && (row.kind === 'query' || row.kind === 'mutation')).map((row) => row.id),
1348
+ )
1349
+ for (const [name, declaration] of Object.entries(this.#contract.imports)) {
1350
+ const where = `import '${name}'`
1351
+ if (!this.#name(where, name)) {
1352
+ continue
1353
+ }
1354
+ this.#import(where, name, declaration, procedureIds)
1355
+ imports[name] = {
1356
+ app: declaration.app,
1357
+ export: declaration.export,
1358
+ version: declaration.version,
1359
+ digest: declaration.digest,
1360
+ procedures: [...declaration.procedures],
1361
+ usedBy: [...declaration.usedBy],
1362
+ timeoutMs: declaration.timeoutMs ?? CONTRACT_DEFAULTS.importTimeoutMs,
1363
+ }
1364
+ }
1365
+ return imports
1366
+ }
1367
+
1368
+ #import(where: string, name: string, declaration: ImportDeclaration, procedureIds: ReadonlySet<string>): void {
1369
+ this.#keys(where, declaration, IMPORT_FIELDS)
1370
+ if (!APP_ID.test(declaration.app) || declaration.app === this.#contract.app) {
1371
+ this.#problem(where, `app '${declaration.app}' is not another application's id`)
1372
+ }
1373
+ this.#name(`${where} export`, declaration.export)
1374
+ this.#positiveInteger(`${where} version`, declaration.version)
1375
+ if (!DIGEST.test(declaration.digest)) {
1376
+ this.#problem(where, `digest '${declaration.digest}' is not the snapshot's digest (allowed: 'sha256:<hex>', as fabrika connect add writes it)`)
1377
+ }
1378
+ if (declaration.procedures.length === 0) {
1379
+ this.#problem(where, 'imports no procedure (allowed: procedures: [<names of the export>])')
1380
+ }
1381
+ this.#unique(where, 'procedures', declaration.procedures)
1382
+ if (declaration.usedBy.length === 0) {
1383
+ this.#problem(where, 'is used by no endpoint (allowed: usedBy: [<procedure ids of this contract>])')
1384
+ }
1385
+ this.#unique(where, 'usedBy', declaration.usedBy)
1386
+ for (const id of declaration.usedBy) {
1387
+ if (!procedureIds.has(id)) {
1388
+ this.#problem(where, `usedBy '${id}' is not a procedure of this contract (allowed: an id such as 'api.products.show')`)
1389
+ }
1390
+ }
1391
+ for (const id of this.#connected.get(name) ?? []) {
1392
+ if (!declaration.usedBy.includes(id)) {
1393
+ this.#problem(where, `is not used by '${id}', which declares connect: ['${name}'] (allowed: usedBy naming every endpoint that connects)`)
1394
+ }
1395
+ }
1396
+ if (declaration.timeoutMs !== undefined) {
1397
+ this.#positiveInteger(`${where} timeoutMs`, declaration.timeoutMs)
1398
+ if (declaration.timeoutMs > CONNECT_LIMITS.maxTimeoutMs) {
1399
+ this.#problem(where, `timeoutMs ${declaration.timeoutMs} is above the ${CONNECT_LIMITS.maxTimeoutMs} ms every connector call is bounded by`)
1400
+ }
1401
+ }
1402
+ }
1403
+
1404
+ #csp(declaration: CspDeclaration): CspExtensions {
1405
+ this.#keys('csp', declaration, CSP_FIELDS)
1406
+ const scriptSrc = this.#cspSources('csp scriptSrc', this.#withChallengeOrigins('scriptSrc', declaration.scriptSrc ?? []))
1407
+ const frameSrc = this.#cspSources('csp frameSrc', this.#withChallengeOrigins('frameSrc', declaration.frameSrc ?? []))
1408
+ const styleSrc = this.#cspSources('csp styleSrc', declaration.styleSrc ?? [])
1409
+ const fontSrc = this.#cspSources('csp fontSrc', declaration.fontSrc ?? [])
1410
+ return {
1411
+ connectSrc: this.#cspSources('csp connectSrc', declaration.connectSrc ?? []),
1412
+ imgSrc: this.#cspSources('csp imgSrc', declaration.imgSrc ?? []),
1413
+ frameAncestors: this.#cspSources('csp frameAncestors', declaration.frameAncestors ?? []),
1414
+ ...(scriptSrc.length === 0 ? {} : { scriptSrc }),
1415
+ ...(frameSrc.length === 0 ? {} : { frameSrc }),
1416
+ ...(styleSrc.length === 0 ? {} : { styleSrc }),
1417
+ ...(fontSrc.length === 0 ? {} : { fontSrc }),
1418
+ }
1419
+ }
1420
+
1421
+ /** The declared sources, plus each fixed origin a form's challenge provider loads that the contract does not name itself. */
1422
+ #withChallengeOrigins(directive: 'scriptSrc' | 'frameSrc', declared: readonly CspSource[]): CspSource[] {
1423
+ const sources = [...declared]
1424
+ for (const provider of new Set(this.#challenges.map((challenge) => challenge.provider))) {
1425
+ const forms = this.#challenges.filter((challenge) => challenge.provider === provider).map((challenge) => challenge.form).sort()
1426
+ for (const origin of CHALLENGE_ORIGINS[provider][directive]) {
1427
+ if (!sources.some((source) => source.origin === origin)) {
1428
+ sources.push({ origin, purpose: `the ${provider} challenge of ${forms.map((form) => `'${form}'`).join(', ')}` })
1429
+ }
1430
+ }
1431
+ }
1432
+ return sources
1433
+ }
1434
+
1435
+ #cspSources(where: string, sources: readonly CspSource[]): CspSource[] {
1436
+ if (sources.length > MAX_CSP_ORIGINS) {
1437
+ this.#problem(where, `${sources.length} origins exceed the limit of ${MAX_CSP_ORIGINS}`)
1438
+ }
1439
+ for (const source of sources) {
1440
+ this.#keys(where, source, ['origin', 'purpose'])
1441
+ if (!isPolicyOrigin(source.origin)) {
1442
+ this.#problem(where, `'${source.origin}' is not an https origin (allowed: 'https://maps.example.com', no path or wildcard)`)
1443
+ }
1444
+ this.#nonEmpty(`${where} '${source.origin}' purpose`, source.purpose)
1445
+ }
1446
+ this.#unique(where, 'origins', sources.map((source) => source.origin))
1447
+ return sources.map((source) => ({ origin: source.origin, purpose: source.purpose }))
1448
+ }
1449
+
1450
+ // ── Declared reach ──────────────────────────────────────────────────────────────────────────
1451
+
1452
+ /**
1453
+ * What a site of the staged kit declares off the paved road, parsed: the static reader copies it from source
1454
+ * unchecked. A site that declares anything is recorded under `site`; a read-only site declares only `'read'`.
1455
+ */
1456
+ #declare(where: string, site: string, value: unknown, place: DeclaringSite): void {
1457
+ if (value === undefined) {
1458
+ return
1459
+ }
1460
+ this.#kit = true
1461
+ if (!isRecord(value)) {
1462
+ this.#problem(where, `declares its reach as something other than an object (allowed: ${DECLARED_ALLOWED})`)
1463
+ return
1464
+ }
1465
+ for (const key of Object.keys(value)) {
1466
+ if (!DECLARED_FIELDS.some((field) => field === key)) {
1467
+ this.#problem(where, `'${key}' is not a declared field (allowed: ${DECLARED_ALLOWED})`)
1468
+ }
1469
+ }
1470
+ const reach: DeclaredReach = {
1471
+ sql: this.#sqlMode(where, value['sql'], place.readOnly),
1472
+ unscoped: this.#unscoped(where, value['unscoped'], place.readOnly),
1473
+ connect: place.kind === 'rpc' ? this.#connect(where, site, value['connect']) : [],
1474
+ issues: place.kind === 'rpc'
1475
+ ? this.#issues(where, value['issues'], place.access)
1476
+ : place.kind === 'job'
1477
+ ? this.#jobIssues(where, value['issues'], place.actions)
1478
+ : null,
1479
+ }
1480
+ if (place.kind !== 'rpc') {
1481
+ this.#rpcOnly(where, 'connect', value['connect'])
1482
+ }
1483
+ if (place.kind !== 'rpc' && place.kind !== 'job' && value['issues'] !== undefined) {
1484
+ this.#problem(where, 'declares issues, which only an endpoint of an RPC mount or a cron job declares')
1485
+ }
1486
+ if (reach.sql !== null || Object.keys(reach.unscoped).length > 0 || reach.connect.length > 0 || reach.issues !== null) {
1487
+ this.#declared[site] = reach
1488
+ }
1489
+ }
1490
+
1491
+ #sqlMode(where: string, value: unknown, readOnly: boolean): DeclaredReach['sql'] {
1492
+ if (value === undefined) {
1493
+ return null
1494
+ }
1495
+ if (value !== 'read' && value !== 'write') {
1496
+ this.#problem(where, `sql ${JSON.stringify(value)} is not a mode (allowed: sql: 'read' or 'write')`)
1497
+ return null
1498
+ }
1499
+ if (readOnly && value === 'write') {
1500
+ this.#problem(where, "declares sql: 'write', and it runs read-only (allowed: sql: 'read')")
1501
+ }
1502
+ return value
1503
+ }
1504
+
1505
+ #unscoped(where: string, value: unknown, readOnly: boolean): DeclaredReach['unscoped'] {
1506
+ if (value === undefined) {
1507
+ return {}
1508
+ }
1509
+ if (!isRecord(value)) {
1510
+ this.#problem(where, "unscoped is not an object of tables (allowed: unscoped: { <table>: 'read' | 'write' })")
1511
+ return {}
1512
+ }
1513
+ const unscoped: Record<string, 'read' | 'write'> = {}
1514
+ for (const [table, mode] of Object.entries(value)) {
1515
+ if (table.startsWith(FRAMEWORK_TABLE_PREFIX)) {
1516
+ this.#problem(where, `unscoped '${table}' is a table the framework keeps; ${FRAMEWORK_TABLE_PREFIX}* is reserved`)
1517
+ } else if (!Object.hasOwn(this.#tables, table)) {
1518
+ this.#problem(where, `unscoped '${table}' is not a table of src/db/tables.ts${suggestion(table, Object.keys(this.#tables))}`)
1519
+ }
1520
+ if (mode !== 'read' && mode !== 'write') {
1521
+ this.#problem(where, `unscoped '${table}' has the mode ${JSON.stringify(mode)} (allowed: 'read' or 'write')`)
1522
+ continue
1523
+ }
1524
+ if (readOnly && mode === 'write') {
1525
+ this.#problem(where, `declares unscoped '${table}': 'write', and it runs read-only (allowed: 'read')`)
1526
+ }
1527
+ const scope = Object.hasOwn(this.#tables, table) ? this.#tables[table]?.scope.mode : undefined
1528
+ if (mode === 'write' && scope === 'reference') {
1529
+ this.#problem(
1530
+ where,
1531
+ `declares unscoped '${table}': 'write', and no site writes a reference table: its rows come from a migration (allowed: 'read')`,
1532
+ )
1533
+ } else if (mode === 'write' && scope === 'inbox') {
1534
+ this.#problem(where, `declares unscoped '${table}': 'write', and only its form writes a form's inbox (allowed: 'read')`)
1535
+ }
1536
+ unscoped[table] = mode
1537
+ }
1538
+ return unscoped
1539
+ }
1540
+
1541
+ #connect(where: string, site: string, value: unknown): string[] {
1542
+ if (value === undefined) {
1543
+ return []
1544
+ }
1545
+ if (!Array.isArray(value) || !value.every((name) => typeof name === 'string')) {
1546
+ this.#problem(where, "connect is not a list of import names (allowed: connect: ['<import>'])")
1547
+ return []
1548
+ }
1549
+ const names: string[] = value.filter((name) => typeof name === 'string')
1550
+ this.#unique(where, 'connect', names)
1551
+ for (const name of names) {
1552
+ if (!Object.hasOwn(this.#contract.imports, name)) {
1553
+ this.#problem(where, `connect '${name}' is not an import of the application${suggestion(name, Object.keys(this.#contract.imports))}`)
1554
+ }
1555
+ this.#connected.set(name, [...(this.#connected.get(name) ?? []), site])
1556
+ }
1557
+ return names
1558
+ }
1559
+
1560
+ /** The link mount `issues` names, or null after a problem; `undefined` when the site declares none. */
1561
+ #issuedMount(where: string, value: unknown): { readonly name: string; readonly mount: LinkMountDeclaration<SchemaShape> | null } | null | undefined {
1562
+ if (value === undefined) {
1563
+ return undefined
1564
+ }
1565
+ if (typeof value !== 'string') {
1566
+ this.#problem(where, "issues is not a link mount's name (allowed: issues: '<link mount>')")
1567
+ return null
1568
+ }
1569
+ const mount = Object.hasOwn(this.#contract.mounts, value) ? this.#contract.mounts[value] : undefined
1570
+ if (mount?.kind !== 'link') {
1571
+ this.#problem(where, `issues '${value}', which is not a link mount of the contract`)
1572
+ return { name: value, mount: null }
1573
+ }
1574
+ return { name: value, mount }
1575
+ }
1576
+
1577
+ /**
1578
+ * A job issues the links of a mount when its actions hold the mount's `issuedBy` and every grant: IAM checks the same
1579
+ * against the job's declaration, app-wide, so the job issues for any object of the mount's dimension.
1580
+ */
1581
+ #jobIssues(where: string, value: unknown, actions: readonly string[]): string | null {
1582
+ const issued = this.#issuedMount(where, value)
1583
+ if (issued === undefined || issued === null) {
1584
+ return null
1585
+ }
1586
+ const { name, mount } = issued
1587
+ const missing = mount === null ? [] : [mount.issuedBy, ...mount.grants].filter((action) => !actions.includes(action))
1588
+ if (missing.length > 0) {
1589
+ this.#problem(where, `issues '${name}', whose links need ${missing.map((action) => `'${action}'`).join(', ')} among the job's actions`)
1590
+ }
1591
+ return name
1592
+ }
1593
+
1594
+ /** `issues` names a link mount whose links this endpoint's own capability can issue: its action on its dimension. */
1595
+ #issues(where: string, value: unknown, access: AccessDeclaration): string | null {
1596
+ const issued = this.#issuedMount(where, value)
1597
+ if (issued === undefined || issued === null) {
1598
+ return null
1599
+ }
1600
+ const { name, mount } = issued
1601
+ if (mount === null) {
1602
+ return name
1603
+ }
1604
+ const target = 'on' in access ? access.on : 'in' in access ? access.in : null
1605
+ if (target !== mount.dimension || !('action' in access) || access.action !== mount.issuedBy) {
1606
+ this.#problem(
1607
+ where,
1608
+ `issues '${name}', whose links a '${mount.issuedBy}' capability on a '${mount.dimension}' issues (allowed: access: { action: '${mount.issuedBy}', on: '${mount.dimension}', from })`,
1609
+ )
1610
+ }
1611
+ return name
1612
+ }
1613
+
1614
+ /** `connect` calls another application on behalf of a person, so only an RPC endpoint declares it. */
1615
+ #rpcOnly(where: string, field: 'connect', value: unknown): void {
1616
+ if (value !== undefined) {
1617
+ this.#problem(where, `declares ${field}, which only an endpoint of an RPC mount declares`)
1618
+ }
1619
+ }
1620
+
1621
+ // ── Table policies ──────────────────────────────────────────────────────────────────────────
1622
+
1623
+ #checkTables(): Record<string, TablePolicyArtifact> {
1624
+ const owners = new Map<string, string>()
1625
+ const memberActions = new Set(Object.values(this.#contract.roles).flatMap((role) => role.member === undefined ? [] : role.grants))
1626
+ const tables: Record<string, TablePolicyArtifact> = {}
1627
+ for (const [name, table] of Object.entries(this.#tables)) {
1628
+ const where = `table '${name}'`
1629
+ const { read, insert, update, delete: remove, appRead = [], appCount = [] } = table.actions
1630
+ for (const action of [read, insert, update, remove, ...appRead, ...appCount]) {
1631
+ if (action !== null) {
1632
+ this.#knownAction(where, action)
1633
+ }
1634
+ }
1635
+ const scope = table.scope
1636
+ if (scope.mode === 'dimension') {
1637
+ const dimension = this.#contract.dimensions[scope.dimension]
1638
+ if (dimension === undefined) {
1639
+ this.#problem(where, `is scoped by '${scope.dimension}', which is not a dimension of the contract`)
1640
+ } else if ((dimension.in ?? null) !== (scope.container?.dimension ?? null)) {
1641
+ this.#problem(
1642
+ where,
1643
+ `lies in '${scope.container?.dimension ?? 'nothing'}', but the contract puts '${scope.dimension}' in '${dimension.in ?? 'nothing'}'`,
1644
+ )
1645
+ }
1646
+ const owner = owners.get(scope.dimension)
1647
+ if (owner !== undefined) {
1648
+ this.#problem(where, `dimension '${scope.dimension}' is already owned by table '${owner}'`)
1649
+ }
1650
+ owners.set(scope.dimension, name)
1651
+ } else if (scope.mode === 'child') {
1652
+ if (this.#tables[scope.parent]?.scope.mode !== 'dimension') {
1653
+ this.#problem(where, `its parent '${scope.parent}' is not a table with a dimension scope`)
1654
+ }
1655
+ } else if (scope.mode === 'inbox') {
1656
+ const form = this.#contract.mounts[scope.form]
1657
+ if (form?.kind !== 'form' || form.appendTo !== name) {
1658
+ this.#problem(where, `is the inbox of '${scope.form}', which is not a c.form mount that appends to '${name}'`)
1659
+ }
1660
+ if (memberActions.has(read)) {
1661
+ this.#problem(where, `its read action '${read}' is granted by a membership role (allowed: an action only application-level roles grant)`)
1662
+ }
1663
+ }
1664
+ tables[name] = table
1665
+ }
1666
+ for (const [name, mount] of Object.entries(this.#contract.mounts)) {
1667
+ if (mount.kind === 'form') {
1668
+ const inbox = this.#tables[mount.appendTo]?.scope
1669
+ if (inbox?.mode !== 'inbox' || inbox.form !== name) {
1670
+ this.#problem(`mount '${name}'`, `appends to '${mount.appendTo}', which is not an inbox table of this form (scope: { inbox: '${name}', … })`)
1671
+ } else {
1672
+ this.#inboxFields(name, mount)
1673
+ }
1674
+ }
1675
+ }
1676
+ return tables
1677
+ }
1678
+
1679
+ /** Each input field of a form is a column of its inbox, of the same name; the honeypot and the challenge token are not. */
1680
+ #inboxFields(name: string, mount: FormMountDeclaration<SchemaShape>): void {
1681
+ const columns = Object.hasOwn(this.#inboxes, mount.appendTo) ? this.#inboxes[mount.appendTo] : undefined
1682
+ if (columns === undefined || mount.input.type !== 'object') {
1683
+ return
1684
+ }
1685
+ for (const field of Object.keys(mount.input.fields)) {
1686
+ const exempt = field === mount.honeypot || (mount.challenge !== undefined && field === FORM_CHALLENGE_FIELD)
1687
+ if (!exempt && !columns.includes(field)) {
1688
+ this.#problem(
1689
+ `mount '${name}'`,
1690
+ `the input field '${field}' is not a column of its inbox ${mount.appendTo}${suggestion(field, columns)} (allowed: a field named like a column: ${
1691
+ columns.join(', ')
1692
+ })`,
1693
+ )
1694
+ }
1695
+ }
1696
+ }
1697
+
1698
+ // ── Shared checks ───────────────────────────────────────────────────────────────────────────
1699
+
1700
+ #problem(where: string, problem: string): void {
1701
+ this.#problems.add(where, problem)
1702
+ }
1703
+
1704
+ #options(where: string, builder: Builder, unknown: readonly string[] | undefined): void {
1705
+ const fields = BUILDER_FIELDS[builder]
1706
+ const call = builder === 'procedure' ? 'c.query and c.mutation' : builder === 'attachment' ? 'attachment()' : `c.${builder}`
1707
+ for (const key of unknown ?? []) {
1708
+ if (fields.owned.includes(key)) {
1709
+ this.#problem(where, `'${key}' is set by ${call} itself and cannot be an option; remove it`)
1710
+ } else {
1711
+ this.#problem(where, `'${key}' is not an option of ${call} (allowed: ${fields.options.join(', ')})`)
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ #keys(where: string, value: object, allowed: readonly string[]): void {
1717
+ for (const key of Object.keys(value)) {
1718
+ if (!allowed.includes(key)) {
1719
+ this.#problem(where, `'${key}' is not a field here (allowed: ${allowed.join(', ')})`)
1720
+ }
1721
+ }
1722
+ }
1723
+
1724
+ #callers(where: string, callers: readonly string[]): void {
1725
+ this.#unique(where, 'callers', callers)
1726
+ for (const caller of callers) {
1727
+ if (caller.startsWith('app:')) {
1728
+ this.#problem(where, `'${caller}' reaches only an export; declare an export with consumers: ['${caller.slice('app:'.length)}'] instead`)
1729
+ } else if (caller !== 'human' && caller !== 'machine') {
1730
+ this.#problem(where, `'${caller}' is not a caller of an RPC mount (allowed: 'human' or 'machine')`)
1731
+ }
1732
+ }
1733
+ }
1734
+
1735
+ #name(where: string, name: string): boolean {
1736
+ if (NAME.test(name)) {
1737
+ return true
1738
+ }
1739
+ this.#problem(where, `is not a name (allowed: ${NAME.source}; a '.' joins names into ids and paths)`)
1740
+ return false
1741
+ }
1742
+
1743
+ #kind<K extends string>(where: string, value: { readonly kind: string }, kind: K, allowed: string): boolean {
1744
+ if (value.kind === kind) {
1745
+ return true
1746
+ }
1747
+ this.#problem(where, `is not built with ${allowed}`)
1748
+ return false
1749
+ }
1750
+
1751
+ #knownAction(where: string, action: string): void {
1752
+ if (!Object.hasOwn(this.#contract.actions, action)) {
1753
+ this.#problem(where, `'${action}' is not an action of the contract${suggestion(action, Object.keys(this.#contract.actions))}`)
1754
+ }
1755
+ }
1756
+
1757
+ #knownDimension(where: string, dimension: string): void {
1758
+ if (!Object.hasOwn(this.#contract.dimensions, dimension)) {
1759
+ this.#problem(where, `'${dimension}' is not a dimension of the contract${suggestion(dimension, Object.keys(this.#contract.dimensions))}`)
1760
+ }
1761
+ }
1762
+
1763
+ #actionList(where: string, actions: readonly string[]): void {
1764
+ if (actions.length === 0) {
1765
+ this.#problem(where, 'names no action (allowed: actions: [<exact actions>])')
1766
+ }
1767
+ this.#unique(where, 'actions', actions)
1768
+ for (const action of actions) {
1769
+ this.#knownAction(where, action)
1770
+ }
1771
+ }
1772
+
1773
+ #prefix(where: string, path: string): void {
1774
+ if (!LITERAL_PATH.test(path)) {
1775
+ this.#problem(where, `'${path}' is not a literal path (allowed: '/api', segments of letters, digits and '._~-', no trailing '/')`)
1776
+ } else if (path === FRAMEWORK_PREFIX || path.startsWith(`${FRAMEWORK_PREFIX}/`)) {
1777
+ this.#problem(where, `'${path}' is under ${FRAMEWORK_PREFIX}, which the framework reserves`)
1778
+ }
1779
+ }
1780
+
1781
+ #objectSchema(where: string, schema: SchemaShape): void {
1782
+ if (schema.type !== 'object') {
1783
+ this.#problem(where, `is a ${schema.type} schema (allowed: z.strictObject({ … }))`)
1784
+ }
1785
+ }
1786
+
1787
+ #bodyLimit(where: string, maxBytes: number | undefined, fallback: number): BodyAdmission {
1788
+ if (maxBytes !== undefined) {
1789
+ this.#positiveInteger(`${where} maxBytes`, maxBytes)
1790
+ }
1791
+ return { contentTypes: ['application/json'], maxBytes: maxBytes ?? fallback }
1792
+ }
1793
+
1794
+ #positiveInteger(where: string, value: number): void {
1795
+ if (!Number.isSafeInteger(value) || value < 1) {
1796
+ this.#problem(where, `${value} is not a positive whole number`)
1797
+ }
1798
+ }
1799
+
1800
+ #nonEmpty(where: string, value: string): void {
1801
+ if (value.trim() === '') {
1802
+ this.#problem(where, 'is empty')
1803
+ }
1804
+ }
1805
+
1806
+ #unique(where: string, field: string, values: readonly string[]): void {
1807
+ const seen = new Set<string>()
1808
+ for (const value of values) {
1809
+ if (seen.has(value)) {
1810
+ this.#problem(where, `${field} lists '${value}' twice`)
1811
+ }
1812
+ seen.add(value)
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ /** Where a declaration stands: what kind of site, whether it runs read-only, and an RPC endpoint's access form. */
1818
+ type DeclaringSite =
1819
+ | { readonly kind: 'rpc'; readonly readOnly: boolean; readonly access: AccessDeclaration }
1820
+ | { readonly kind: 'job'; readonly readOnly: boolean; readonly actions: readonly string[] }
1821
+ | { readonly kind: 'link' | 'export' | 'webhook'; readonly readOnly: boolean }
1822
+
1823
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === 'object' && value !== null && !Array.isArray(value)
1824
+
1825
+ type ResolvedAccess = { readonly form: 'member' } | { readonly form: 'action'; readonly action: string; readonly access: AccessForm }
1826
+
1827
+ /** The one access form the declaration spells, with exactly its keys; a mixture of two forms is none. */
1828
+ function accessForm(access: AccessDeclaration): ResolvedAccess | null {
1829
+ if ('member' in access) {
1830
+ return hasExactKeys(access, ['member']) && access.member === true ? { form: 'member' } : null
1831
+ }
1832
+ if ('over' in access) {
1833
+ return hasExactKeys(access, ['action', 'over']) ? { form: 'action', action: access.action, access: { form: 'over', dimension: access.over } } : null
1834
+ }
1835
+ if ('in' in access) {
1836
+ return hasExactKeys(access, ['action', 'in', 'from'])
1837
+ ? { form: 'action', action: access.action, access: { form: 'in', dimension: access.in, from: access.from } }
1838
+ : null
1839
+ }
1840
+ if ('on' in access) {
1841
+ return hasExactKeys(access, ['action', 'on', 'from'])
1842
+ ? { form: 'action', action: access.action, access: { form: 'on', dimension: access.on, from: access.from } }
1843
+ : null
1844
+ }
1845
+ if ('creates' in access) {
1846
+ return hasExactKeys(access, ['action', 'creates'])
1847
+ ? { form: 'action', action: access.action, access: { form: 'creates', dimension: access.creates } }
1848
+ : null
1849
+ }
1850
+ if ('app' in access) {
1851
+ return hasExactKeys(access, ['action', 'app']) && access.app === true ? { form: 'action', action: access.action, access: { form: 'app' } } : null
1852
+ }
1853
+ return null
1854
+ }
1855
+
1856
+ /** A value one CSV field holds: a scalar, possibly absent. */
1857
+ function isCell(shape: SchemaShape): boolean {
1858
+ switch (shape.type) {
1859
+ case 'string':
1860
+ case 'number':
1861
+ case 'boolean':
1862
+ case 'null':
1863
+ case 'literal':
1864
+ case 'enum':
1865
+ return true
1866
+ case 'optional':
1867
+ case 'nullable':
1868
+ return isCell(shape.inner)
1869
+ case 'union':
1870
+ return shape.options.every(isCell)
1871
+ default:
1872
+ return false
1873
+ }
1874
+ }
1875
+
1876
+ /** A shape whose every admitted value is a string, so it can carry a scope value. */
1877
+ function isRequiredString(shape: SchemaShape): boolean {
1878
+ switch (shape.type) {
1879
+ case 'string':
1880
+ case 'enum':
1881
+ return true
1882
+ case 'literal':
1883
+ return typeof shape.value === 'string'
1884
+ case 'union':
1885
+ return shape.options.length > 0 && shape.options.every(isRequiredString)
1886
+ default:
1887
+ return false
1888
+ }
1889
+ }
1890
+
1891
+ /** `z.string()` or `z.optional(z.string())`: a field a page can leave empty, such as a honeypot. */
1892
+ function isStringField(shape: SchemaShape): boolean {
1893
+ return shape.type === 'string' || (shape.type === 'optional' && shape.inner.type === 'string')
1894
+ }
1895
+
1896
+ function hasExactKeys(value: object, keys: readonly string[]): boolean {
1897
+ const own = Object.keys(value)
1898
+ return own.length === keys.length && keys.every((key) => own.includes(key))
1899
+ }
1900
+
1901
+ /** Procedures of a tree with their dotted paths, in declaration order. */
1902
+ function procedures<Access>(tree: ProcedureTreeDeclaration<SchemaShape, Access>, prefix = ''): [string, ProcedureDeclaration<SchemaShape, Access>][] {
1903
+ const found: [string, ProcedureDeclaration<SchemaShape, Access>][] = []
1904
+ for (const [name, node] of Object.entries(tree)) {
1905
+ const path = prefix === '' ? name : `${prefix}.${name}`
1906
+ if (isProcedure(node)) {
1907
+ found.push([path, node])
1908
+ } else {
1909
+ found.push(...procedures(node, path))
1910
+ }
1911
+ }
1912
+ return found
1913
+ }
1914
+
1915
+ /** `' — did you mean 'notes.read'?'` for a name one or two edits from a known one. */
1916
+ function suggestion(name: string, known: readonly string[]): string {
1917
+ const close = known.find((candidate) => editDistance(name, candidate) <= 2)
1918
+ return close === undefined ? '' : ` — did you mean '${close}'?`
1919
+ }
1920
+
1921
+ function editDistance(a: string, b: string): number {
1922
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index)
1923
+ for (let i = 1; i <= a.length; i++) {
1924
+ const current = [i]
1925
+ for (let j = 1; j <= b.length; j++) {
1926
+ const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
1927
+ current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution))
1928
+ }
1929
+ previous = current
1930
+ }
1931
+ return previous[b.length] ?? 0
1932
+ }