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