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