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