@fabrika/app 0.5.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// `egress`: while a site's handler runs, the global `fetch` reaches only the HTTPS origins the site declares. It stops a
|
|
2
|
+
// mistake and makes a bypass conspicuous; it is not a sandbox (ADR-0012 §8): code that kept the `fetch` it found before
|
|
3
|
+
// boot, or opens its own sockets, escapes it. The framework's own facilities (mail, connectors, IAM, the object store)
|
|
4
|
+
// run outside the site's scope, under their own declarations.
|
|
5
|
+
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
7
|
+
import { CapabilityRefusedError } from './capability'
|
|
8
|
+
|
|
9
|
+
interface EgressScope {
|
|
10
|
+
/** The site, as a refusal names it. */
|
|
11
|
+
readonly site: string
|
|
12
|
+
readonly origins: ReadonlySet<string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const scope = new AsyncLocalStorage<EgressScope>()
|
|
16
|
+
const guards = new WeakSet<object>()
|
|
17
|
+
|
|
18
|
+
/** Runs a site's handler: every `fetch` it starts reaches only `egress`. */
|
|
19
|
+
export function runAsSite<T>(site: string, egress: readonly string[], work: () => Promise<T>): Promise<T> {
|
|
20
|
+
return scope.run({ site, origins: new Set(egress) }, work)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `facility` with each of its methods run outside any site's scope: a framework facility a handler calls, whose own
|
|
25
|
+
* requests are the platform's, not the site's.
|
|
26
|
+
*/
|
|
27
|
+
export function platformFacility<T extends object>(facility: T): T {
|
|
28
|
+
return new Proxy(facility, {
|
|
29
|
+
get(target, property) {
|
|
30
|
+
const value: unknown = Reflect.get(target, property, target)
|
|
31
|
+
if (typeof value !== 'function') {
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
return (...args: unknown[]): unknown => scope.exit(() => Reflect.apply(value, target, args))
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `work`, outside any site's scope: a framework call that happens to run inside a handler. */
|
|
40
|
+
export function platformCall<T>(work: () => T): T {
|
|
41
|
+
return scope.exit(work)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wraps the global `fetch` in the guard, once: a `fetch` that is already a guard stays. A test that replaces `fetch`
|
|
46
|
+
* after boot replaces the guard with it.
|
|
47
|
+
*/
|
|
48
|
+
export function installEgressGuard(): void {
|
|
49
|
+
const current = globalThis.fetch
|
|
50
|
+
if (guards.has(current)) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const guarded = Object.assign(
|
|
54
|
+
(input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
55
|
+
const running = scope.getStore()
|
|
56
|
+
if (running === undefined) {
|
|
57
|
+
return current(input, init)
|
|
58
|
+
}
|
|
59
|
+
const origin = originOf(input)
|
|
60
|
+
if (origin !== null && running.origins.has(origin)) {
|
|
61
|
+
return current(input, init)
|
|
62
|
+
}
|
|
63
|
+
return Promise.reject(
|
|
64
|
+
new CapabilityRefusedError(
|
|
65
|
+
'undeclared_egress',
|
|
66
|
+
origin === null
|
|
67
|
+
? `${running.site} called fetch with a URL that is not absolute`
|
|
68
|
+
: `${running.site} calls ${origin}, which it does not declare (egress: ['${origin}'])`,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
{ preconnect: current.preconnect },
|
|
73
|
+
)
|
|
74
|
+
guards.add(guarded)
|
|
75
|
+
globalThis.fetch = guarded
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function originOf(input: string | URL | Request): string | null {
|
|
79
|
+
try {
|
|
80
|
+
return new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).origin
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/implement/faults.ts
CHANGED
|
@@ -16,18 +16,28 @@ export const SERVER_FAULTS = {
|
|
|
16
16
|
output_refused: 'the handler returned a value its output schema refuses',
|
|
17
17
|
contract_inconsistent: 'the compiled endpoint misses what its kind needs (a path parameter, a body limit or a capability kind)',
|
|
18
18
|
webhook_binding_mismatch: "a webhook binding reaches another dimension than the webhook's",
|
|
19
|
+
webhook_unverifiable: 'a webhook verifies a signature over its public URL (Twilio), and the environment has no FABRIKA_APP_ORIGIN',
|
|
20
|
+
webhook_claim_lost: "a delivery's claim expired and another copy of the delivery took it over before this one committed; nothing it wrote is kept",
|
|
19
21
|
workload_unauthorized: "the workload's key or token does not carry the authority its declaration needs",
|
|
20
22
|
request_cancelled: 'the request was cancelled or outlived its budget before its transaction committed; nothing it wrote is kept',
|
|
21
23
|
file_object_missing: "a file's object is missing from storage",
|
|
22
24
|
file_object_mismatch: "a file's object has another size than its row records",
|
|
23
25
|
file_key_taken: "a new file's object key already held an object this upload did not write",
|
|
24
|
-
|
|
26
|
+
file_kind_unknown: 'ctx.files.put or a file field named a file kind the contract does not declare',
|
|
27
|
+
file_put: 'ctx.files.put was called without the options it takes ({ type, name? })',
|
|
28
|
+
file_output_kind: 'the handler answered a file of another kind than its output declares',
|
|
25
29
|
file_sweep_failed: "the sweep could not delete a removed or orphaned file's object or row; the next run retries it",
|
|
26
30
|
challenge_unavailable: "a form's challenge provider could not be reached in time or did not answer as it documents",
|
|
27
31
|
challenge_misconfigured: "a form's challenge provider refused the application's secret",
|
|
28
32
|
attachment_rows: 'the handler returned more rows than its attachment declares (maxRows)',
|
|
29
33
|
attachment_cell: 'a cell of the attachment is longer than one cell may hold',
|
|
30
34
|
attachment_size: 'the encoded attachment is larger than it declares (maxBytes); no body was sent',
|
|
35
|
+
redirect_refused:
|
|
36
|
+
"the handler redirected to a target that is neither its own origin's path, an origin its redirect() lists, nor a URL a verified token of the request carries",
|
|
37
|
+
token_claims_refused: "the claims a site signed are not what the token purpose's claims schema admits",
|
|
38
|
+
token_keys_missing: 'a site signed a token, and the environment holds no token key',
|
|
39
|
+
url_unbuildable:
|
|
40
|
+
'ctx.url named no procedure of the contract, passed a parameter its HTTP shape does not read, or the environment has no public origin',
|
|
31
41
|
} as const
|
|
32
42
|
|
|
33
43
|
export type ServerFaultReason = keyof typeof SERVER_FAULTS
|
package/src/implement/index.ts
CHANGED
|
@@ -10,8 +10,8 @@ export {
|
|
|
10
10
|
type EmailSenderCapabilities,
|
|
11
11
|
mayRetryDelivery,
|
|
12
12
|
} from '@fabrika/email'
|
|
13
|
-
export { FILE_SWEEP_JOB } from '../files/collections'
|
|
14
13
|
export type { FileCollectionHandle, FileCollectionReader, FileListing, ListedFile, OpenedFile } from '../files/handle'
|
|
14
|
+
export { FILE_SWEEP_JOB } from '../files/kinds'
|
|
15
15
|
export {
|
|
16
16
|
FILE_SCAN_JOB,
|
|
17
17
|
type FileScanner,
|
|
@@ -51,7 +51,7 @@ export type {
|
|
|
51
51
|
MemberManagement,
|
|
52
52
|
MemberTarget,
|
|
53
53
|
} from './membership'
|
|
54
|
-
export type { AppPrincipal, LinkHolder, Principal, PrincipalOf, ServicePrincipal, UserPrincipal } from './principal'
|
|
54
|
+
export type { AppPrincipal, LinkHolder, Principal, PrincipalOf, PublicPrincipal, ServicePrincipal, UserPrincipal } from './principal'
|
|
55
55
|
export { type AppDeps, defineDeps, serve, type ServeOptions } from './serve'
|
|
56
56
|
export type {
|
|
57
57
|
AccessContext,
|
|
@@ -63,6 +63,7 @@ export type {
|
|
|
63
63
|
Handler,
|
|
64
64
|
HandlerInput,
|
|
65
65
|
HandlersOf,
|
|
66
|
+
RequestClient,
|
|
66
67
|
RequestContext,
|
|
67
68
|
WebhookHandler,
|
|
68
69
|
} from './types'
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type DataAccess,
|
|
9
9
|
type DataHandles,
|
|
10
10
|
type DataSite,
|
|
11
|
+
type FileBinding,
|
|
11
12
|
type InvocationTransaction,
|
|
12
13
|
type QueryRefusal,
|
|
13
14
|
QueryRefusedError,
|
|
@@ -17,7 +18,6 @@ import {
|
|
|
17
18
|
import type { DataKind, UnscopedMode } from '@fabrika/db/tables'
|
|
18
19
|
import type { SqlMode } from '../contract/context'
|
|
19
20
|
import { DECLARED_FIELDS } from '../contract/declaration'
|
|
20
|
-
import type { FileSite } from '../files/collections'
|
|
21
21
|
import { CapabilityRefusedError, isActiveRequest, type RequestFrame } from './capability'
|
|
22
22
|
import { ServerFault } from './faults'
|
|
23
23
|
|
|
@@ -27,8 +27,20 @@ export interface SiteDeclaration {
|
|
|
27
27
|
readonly unscoped: Readonly<Record<string, UnscopedMode>>
|
|
28
28
|
readonly connect: readonly string[]
|
|
29
29
|
readonly issues: string | null
|
|
30
|
-
readonly then: string | null
|
|
31
30
|
readonly mailer: JobMailer | null
|
|
31
|
+
/** The cron jobs `ctx.kick` may name. */
|
|
32
|
+
readonly kicks: readonly string[]
|
|
33
|
+
/** The webhook whose bindings `ctx.webhooks` creates and removes. */
|
|
34
|
+
readonly binds: string | null
|
|
35
|
+
/** The token purposes `ctx.tokens.sign` may sign. */
|
|
36
|
+
readonly signs: readonly string[]
|
|
37
|
+
/** The HTTPS origins the global `fetch` reaches while the site's handler runs. */
|
|
38
|
+
readonly egress: readonly string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A public procedure's reach: the texts the verified signed tokens of its request carry, which name the rows it changes. */
|
|
42
|
+
export interface PublicReach {
|
|
43
|
+
readonly carried: ReadonlySet<string>
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
export type SiteFunction = (args: { readonly input?: unknown; readonly ctx: object }) => unknown
|
|
@@ -61,8 +73,11 @@ const NO_DECLARATION: SiteDeclaration = Object.freeze({
|
|
|
61
73
|
unscoped: Object.freeze({}),
|
|
62
74
|
connect: Object.freeze([]),
|
|
63
75
|
issues: null,
|
|
64
|
-
then: null,
|
|
65
76
|
mailer: null,
|
|
77
|
+
kicks: Object.freeze([]),
|
|
78
|
+
binds: null,
|
|
79
|
+
signs: Object.freeze([]),
|
|
80
|
+
egress: Object.freeze([]),
|
|
66
81
|
})
|
|
67
82
|
|
|
68
83
|
function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
@@ -131,22 +146,40 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
|
|
|
131
146
|
if (issues !== undefined && typeof issues !== 'string') {
|
|
132
147
|
problems.push(`${where}: issues is not the name of a link mount`)
|
|
133
148
|
}
|
|
134
|
-
const then = ownField(declared, 'then')
|
|
135
|
-
if (then !== undefined && typeof then !== 'string') {
|
|
136
|
-
problems.push(`${where}: then is not the name of a job`)
|
|
137
|
-
}
|
|
138
149
|
const mailerField = ownField(declared, 'mailer')
|
|
139
150
|
const mailer = JOB_MAILERS.find((transport) => transport === mailerField) ?? null
|
|
140
151
|
if (mailerField !== undefined && mailer === null) {
|
|
141
152
|
problems.push(`${where}: mailer is not 'installation' or 'own'`)
|
|
142
153
|
}
|
|
154
|
+
const kicksField = ownField(declared, 'kicks')
|
|
155
|
+
const kicks = Array.isArray(kicksField) ? kicksField.filter((name): name is string => typeof name === 'string') : []
|
|
156
|
+
if (kicksField !== undefined && (!Array.isArray(kicksField) || kicks.length !== kicksField.length)) {
|
|
157
|
+
problems.push(`${where}: kicks is not a list of job names`)
|
|
158
|
+
}
|
|
159
|
+
const binds = ownField(declared, 'binds')
|
|
160
|
+
if (binds !== undefined && typeof binds !== 'string') {
|
|
161
|
+
problems.push(`${where}: binds is not the name of a webhook`)
|
|
162
|
+
}
|
|
163
|
+
const signsField = ownField(declared, 'signs')
|
|
164
|
+
const signs = Array.isArray(signsField) ? signsField.filter((purpose): purpose is string => typeof purpose === 'string') : []
|
|
165
|
+
if (signsField !== undefined && (!Array.isArray(signsField) || signs.length !== signsField.length)) {
|
|
166
|
+
problems.push(`${where}: signs is not a list of token purposes`)
|
|
167
|
+
}
|
|
168
|
+
const egressField = ownField(declared, 'egress')
|
|
169
|
+
const egress = Array.isArray(egressField) ? egressField.filter((origin): origin is string => typeof origin === 'string') : []
|
|
170
|
+
if (egressField !== undefined && (!Array.isArray(egressField) || egress.length !== egressField.length)) {
|
|
171
|
+
problems.push(`${where}: egress is not a list of origins`)
|
|
172
|
+
}
|
|
143
173
|
return Object.freeze({
|
|
144
174
|
sql: sql === 'read' || sql === 'write' ? sql : null,
|
|
145
175
|
unscoped: Object.freeze(unscoped),
|
|
146
176
|
connect: Object.freeze(connectors),
|
|
147
177
|
issues: typeof issues === 'string' ? issues : null,
|
|
148
|
-
then: typeof then === 'string' ? then : null,
|
|
149
178
|
mailer,
|
|
179
|
+
kicks: Object.freeze([...new Set(kicks)].sort()),
|
|
180
|
+
binds: typeof binds === 'string' ? binds : null,
|
|
181
|
+
signs: Object.freeze([...new Set(signs)].sort()),
|
|
182
|
+
egress: Object.freeze([...new Set(egress)].sort()),
|
|
150
183
|
})
|
|
151
184
|
}
|
|
152
185
|
|
|
@@ -187,14 +220,17 @@ export function approvedReach(site: string, artifact: ContractArtifact): SiteDec
|
|
|
187
220
|
unscoped: Object.freeze({ ...reach.unscoped }),
|
|
188
221
|
connect: Object.freeze([...reach.connect]),
|
|
189
222
|
issues: reach.issues,
|
|
190
|
-
then: reach.then ?? null,
|
|
191
223
|
mailer: reach.mailer ?? null,
|
|
224
|
+
kicks: Object.freeze([...(reach.kicks ?? [])]),
|
|
225
|
+
binds: reach.binds ?? null,
|
|
226
|
+
signs: Object.freeze([...(reach.signs ?? [])]),
|
|
227
|
+
egress: Object.freeze([...(reach.egress ?? [])]),
|
|
192
228
|
})
|
|
193
229
|
}
|
|
194
230
|
|
|
195
231
|
/**
|
|
196
|
-
* A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted;
|
|
197
|
-
*
|
|
232
|
+
* A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted; each optional
|
|
233
|
+
* field only when declared.
|
|
198
234
|
*/
|
|
199
235
|
function describeDeclaration(declaration: SiteDeclaration): string {
|
|
200
236
|
const unscoped = Object.entries(declaration.unscoped).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
@@ -203,8 +239,11 @@ function describeDeclaration(declaration: SiteDeclaration): string {
|
|
|
203
239
|
unscoped,
|
|
204
240
|
connect: [...declaration.connect].sort(),
|
|
205
241
|
issues: declaration.issues,
|
|
206
|
-
...(declaration.then === null ? {} : { then: declaration.then }),
|
|
207
242
|
...(declaration.mailer === null ? {} : { mailer: declaration.mailer }),
|
|
243
|
+
...(declaration.kicks.length === 0 ? {} : { kicks: declaration.kicks }),
|
|
244
|
+
...(declaration.binds === null ? {} : { binds: declaration.binds }),
|
|
245
|
+
...(declaration.signs.length === 0 ? {} : { signs: declaration.signs }),
|
|
246
|
+
...(declaration.egress.length === 0 ? {} : { egress: declaration.egress }),
|
|
208
247
|
})
|
|
209
248
|
}
|
|
210
249
|
|
|
@@ -235,8 +274,17 @@ export class Invocation {
|
|
|
235
274
|
this.#transaction = data.invocation(readOnly)
|
|
236
275
|
}
|
|
237
276
|
|
|
238
|
-
/**
|
|
239
|
-
|
|
277
|
+
/**
|
|
278
|
+
* The handles of the site, all of them running in this invocation's transaction. A public procedure's (`reach` its
|
|
279
|
+
* carried texts) insert and read under its actions only, and update or remove only a row a verified token names.
|
|
280
|
+
*/
|
|
281
|
+
handles(
|
|
282
|
+
kind: DataKind,
|
|
283
|
+
declaration: SiteDeclaration,
|
|
284
|
+
principal: string,
|
|
285
|
+
reach: PublicReach | 'declared' = 'declared',
|
|
286
|
+
files?: FileBinding,
|
|
287
|
+
): DataHandles {
|
|
240
288
|
const transaction = this.#transaction
|
|
241
289
|
const site: DataSite = {
|
|
242
290
|
kind,
|
|
@@ -244,6 +292,8 @@ export class Invocation {
|
|
|
244
292
|
principal,
|
|
245
293
|
check: boundTo(this.#frame, () => transaction.ended),
|
|
246
294
|
session: () => transaction.session(),
|
|
295
|
+
...(reach === 'declared' ? {} : { public: true, carried: reach.carried }),
|
|
296
|
+
...(files === undefined ? {} : { files }),
|
|
247
297
|
}
|
|
248
298
|
return this.#data.handles(site)
|
|
249
299
|
}
|
|
@@ -259,16 +309,16 @@ export class Invocation {
|
|
|
259
309
|
})
|
|
260
310
|
}
|
|
261
311
|
|
|
262
|
-
/**
|
|
263
|
-
|
|
312
|
+
/** The check of `ctx.files`: bound to this invocation's transaction, like every handle. */
|
|
313
|
+
fileCheck(): () => void {
|
|
264
314
|
const transaction = this.#transaction
|
|
265
|
-
return
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
315
|
+
return boundTo(this.#frame, () => transaction.ended, 'ctx.files')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Where the framework writes for this site (a webhook binding): this invocation's transaction, bound like every handle. */
|
|
319
|
+
frameworkSite(handle: string): { readonly check: () => void; readonly session: () => Promise<Session> } {
|
|
320
|
+
const transaction = this.#transaction
|
|
321
|
+
return { check: boundTo(this.#frame, () => transaction.ended, handle), session: () => transaction.session() }
|
|
272
322
|
}
|
|
273
323
|
|
|
274
324
|
/** Commits; a request the caller abandoned meanwhile rolls back instead. */
|
|
@@ -297,14 +347,24 @@ export interface AccessCapabilities {
|
|
|
297
347
|
readonly app?: unknown
|
|
298
348
|
}
|
|
299
349
|
|
|
300
|
-
/**
|
|
350
|
+
/**
|
|
351
|
+
* `ctx.db` of an endpoint, by its access form: the API matrix of `docs/reference/application-runtime.md`. An endpoint of
|
|
352
|
+
* several capabilities binds each, as a job binds what it minted: `ctx.db.on(ref)`, `.in(ref)`, `.over(within)`.
|
|
353
|
+
*/
|
|
301
354
|
export function endpointData(
|
|
302
355
|
handles: DataHandles,
|
|
303
|
-
form: 'on' | 'in' | 'over' | 'creates' | 'app' | 'member',
|
|
356
|
+
form: 'on' | 'in' | 'over' | 'creates' | 'app' | 'member' | 'capabilities',
|
|
304
357
|
access: AccessCapabilities,
|
|
305
358
|
): object {
|
|
306
359
|
const shared = { reference: handles.reference, ...(handles.unscoped === undefined ? {} : { unscoped: handles.unscoped }) }
|
|
307
360
|
switch (form) {
|
|
361
|
+
case 'capabilities':
|
|
362
|
+
return Object.freeze({
|
|
363
|
+
on: (ref: unknown) => handles.target(ref, 'on'),
|
|
364
|
+
in: (ref: unknown) => handles.target(ref, 'in'),
|
|
365
|
+
over: (within: unknown) => handles.within(within),
|
|
366
|
+
...shared,
|
|
367
|
+
})
|
|
308
368
|
case 'on':
|
|
309
369
|
return Object.freeze({ target: handles.target(access.target, 'on'), app: handles.app(access.target), ...shared })
|
|
310
370
|
case 'in':
|
|
@@ -321,17 +381,34 @@ export function endpointData(
|
|
|
321
381
|
}
|
|
322
382
|
|
|
323
383
|
/** `ctx.db` of a job or a webhook: each capability it minted or received binds the same handles an endpoint gets. */
|
|
324
|
-
export function siteData(handles: DataHandles): object {
|
|
384
|
+
export function siteData(handles: DataHandles, app: (action: string) => object): object {
|
|
325
385
|
return Object.freeze({
|
|
326
386
|
on: (ref: unknown) => handles.target(ref, 'on'),
|
|
327
387
|
in: (ref: unknown) => handles.target(ref, 'in'),
|
|
328
388
|
over: (within: unknown) => handles.within(within),
|
|
389
|
+
app,
|
|
329
390
|
reference: handles.reference,
|
|
330
391
|
...(handles.unscoped === undefined ? {} : { unscoped: handles.unscoped }),
|
|
331
392
|
})
|
|
332
393
|
}
|
|
333
394
|
|
|
334
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* `ctx.app` and `ctx.db.app` of a webhook bound to a dimension. The types already refuse them; at run time they exist
|
|
397
|
+
* only to refuse by name.
|
|
398
|
+
*/
|
|
399
|
+
export function boundWebhookApp(webhook: string): (action: string) => never {
|
|
400
|
+
return () => {
|
|
401
|
+
throw new CapabilityRefusedError(
|
|
402
|
+
'undeclared_action',
|
|
403
|
+
`webhook '${webhook}' is bound to a dimension and acts on its bound value: use ctx.refs and ctx.db.on(ref)`,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The handles of a job attempt, outside its fenced transaction (`'job'`, each read on its own) or inside it. `tickMs`,
|
|
410
|
+
* the job's schedule period, is a queue claim's first backoff.
|
|
411
|
+
*/
|
|
335
412
|
export function jobHandles(
|
|
336
413
|
frame: RequestFrame,
|
|
337
414
|
data: DataAccess,
|
|
@@ -340,8 +417,18 @@ export function jobHandles(
|
|
|
340
417
|
kind: 'job' | 'mutation',
|
|
341
418
|
session: () => Promise<Session>,
|
|
342
419
|
ended: () => boolean,
|
|
420
|
+
tickMs?: number,
|
|
421
|
+
files?: FileBinding,
|
|
343
422
|
): DataHandles {
|
|
344
|
-
return data.handles({
|
|
423
|
+
return data.handles({
|
|
424
|
+
kind,
|
|
425
|
+
unscoped: declaration.unscoped,
|
|
426
|
+
principal,
|
|
427
|
+
check: boundTo(frame, ended),
|
|
428
|
+
session,
|
|
429
|
+
...(tickMs === undefined ? {} : { tickMs }),
|
|
430
|
+
...(files === undefined ? {} : { files }),
|
|
431
|
+
})
|
|
345
432
|
}
|
|
346
433
|
|
|
347
434
|
/**
|
|
@@ -366,14 +453,27 @@ export function jobSqlOutsideTransaction(job: string): SqlTag {
|
|
|
366
453
|
return refusingSql('sql_outside_transaction', `${job} runs ctx.sql inside ctx.transaction((tx) => …), as tx.sql`)
|
|
367
454
|
}
|
|
368
455
|
|
|
369
|
-
/** `ctx.files` of a contract that declares no file collection: every name is refused. */
|
|
370
|
-
export function noFileCollections(where: string): (collection: string) => never {
|
|
371
|
-
return (collection) => {
|
|
372
|
-
throw new ServerFault('file_collection_unknown', `${where}: ctx.files('${String(collection)}') names a collection, and the contract declares none`)
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
456
|
/** `tx.sql` of a job site that declares it: its statements run in the attempt's fenced transaction. */
|
|
377
457
|
export function jobSql(frame: RequestFrame, data: DataAccess, mode: SqlMode, session: Session, ended: () => boolean): SqlTag {
|
|
378
458
|
return data.sql({ mode, check: boundTo(frame, ended, 'tx.sql'), session: async () => session, fail: () => session.fail() })
|
|
379
459
|
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* `ctx.kick` of one invocation: it names only a declared job, and records it; the dispatcher sends each recorded job once,
|
|
463
|
+
* after the commit, and nothing on a rollback.
|
|
464
|
+
*/
|
|
465
|
+
export function kickRecorder(
|
|
466
|
+
site: string,
|
|
467
|
+
declared: readonly string[],
|
|
468
|
+
): { readonly kick: (job: string) => void; readonly kicked: () => readonly string[] } {
|
|
469
|
+
const kicked = new Set<string>()
|
|
470
|
+
return {
|
|
471
|
+
kick: (job: string) => {
|
|
472
|
+
if (!declared.includes(job)) {
|
|
473
|
+
throw new CapabilityRefusedError('undeclared_kick', `${site} does not declare kicks: ['${String(job)}']`)
|
|
474
|
+
}
|
|
475
|
+
kicked.add(job)
|
|
476
|
+
},
|
|
477
|
+
kicked: () => [...kicked],
|
|
478
|
+
}
|
|
479
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Flat scalar parameters: a GET procedure's input and a POST's `query` from the URL, and a native form's urlencoded body.
|
|
2
|
+
// Each parameter becomes the value its field's schema reads: a string as it is, a number, a boolean and `null` only in
|
|
3
|
+
// their canonical spelling, so one value has one URL. A name sent twice is refused; an unknown name is the strict
|
|
4
|
+
// input's to refuse.
|
|
5
|
+
|
|
6
|
+
import type { SchemaShape } from '@fabrika/auth-core'
|
|
7
|
+
import { BadRequestError, HttpError } from '../errors'
|
|
8
|
+
|
|
9
|
+
/** The longest URL a procedure reads its parameters from. */
|
|
10
|
+
export const MAX_URL_LENGTH = 8192
|
|
11
|
+
|
|
12
|
+
/** The parameters of `params` as the object `shape`'s fields read them; `shape` is a flat object, or `null` for none. */
|
|
13
|
+
export function paramsObject(params: URLSearchParams, shape: SchemaShape | null): Record<string, unknown> {
|
|
14
|
+
const fields = shape?.type === 'object' ? shape.fields : {}
|
|
15
|
+
const read: Record<string, unknown> = {}
|
|
16
|
+
for (const [name, value] of params) {
|
|
17
|
+
if (Object.hasOwn(read, name)) {
|
|
18
|
+
throw new BadRequestError(`the parameter '${name}' is sent twice`)
|
|
19
|
+
}
|
|
20
|
+
const field = Object.hasOwn(fields, name) ? fields[name] : undefined
|
|
21
|
+
read[name] = field === undefined ? value : scalarOf(field, value)
|
|
22
|
+
}
|
|
23
|
+
return read
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The URL's parameters of a request, within `MAX_URL_LENGTH`. */
|
|
27
|
+
export function urlParams(request: Request): URLSearchParams {
|
|
28
|
+
if (request.url.length > MAX_URL_LENGTH) {
|
|
29
|
+
throw new HttpError(414, 'url_too_long', `The URL is longer than ${MAX_URL_LENGTH} characters`)
|
|
30
|
+
}
|
|
31
|
+
return new URL(request.url).searchParams
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A parameter's value as `shape` reads it; a value no alternative spells canonically stays text, for the schema to refuse. */
|
|
35
|
+
function scalarOf(shape: SchemaShape, value: string): unknown {
|
|
36
|
+
switch (shape.type) {
|
|
37
|
+
case 'optional':
|
|
38
|
+
return scalarOf(shape.inner, value)
|
|
39
|
+
case 'nullable':
|
|
40
|
+
return value === 'null' && !readsText(shape.inner) ? null : scalarOf(shape.inner, value)
|
|
41
|
+
case 'number':
|
|
42
|
+
return canonicalNumber(value) ?? value
|
|
43
|
+
case 'boolean':
|
|
44
|
+
return value === 'true' ? true : value === 'false' ? false : value
|
|
45
|
+
case 'null':
|
|
46
|
+
return value === 'null' ? null : value
|
|
47
|
+
case 'literal':
|
|
48
|
+
return typeof shape.value === 'string' ? value : scalarOf(literalShape(shape.value), value)
|
|
49
|
+
case 'union':
|
|
50
|
+
for (const option of shape.options) {
|
|
51
|
+
const read = scalarOf(option, value)
|
|
52
|
+
if (read !== value || readsText(option)) return read
|
|
53
|
+
}
|
|
54
|
+
return value
|
|
55
|
+
default:
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function literalShape(value: number | boolean | null): SchemaShape {
|
|
61
|
+
if (value === null) return { type: 'null' }
|
|
62
|
+
if (typeof value === 'boolean') return { type: 'boolean' }
|
|
63
|
+
return { type: 'number', int: false, min: null, exclusiveMin: false, max: null, exclusiveMax: false, multipleOf: null }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether the shape reads text as itself, so `null` and `true` stay strings there. */
|
|
67
|
+
function readsText(shape: SchemaShape): boolean {
|
|
68
|
+
switch (shape.type) {
|
|
69
|
+
case 'string':
|
|
70
|
+
case 'enum':
|
|
71
|
+
return true
|
|
72
|
+
case 'literal':
|
|
73
|
+
return typeof shape.value === 'string'
|
|
74
|
+
case 'optional':
|
|
75
|
+
case 'nullable':
|
|
76
|
+
return readsText(shape.inner)
|
|
77
|
+
case 'union':
|
|
78
|
+
return shape.options.some(readsText)
|
|
79
|
+
default:
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `value` as a number when it is that number's canonical spelling (`1.5`, `-3`, never `01`, `1e3` or `+2`). */
|
|
85
|
+
function canonicalNumber(value: string): number | null {
|
|
86
|
+
const number = Number(value)
|
|
87
|
+
return value !== '' && Number.isFinite(number) && String(number) === value ? number : null
|
|
88
|
+
}
|
|
@@ -35,6 +35,13 @@ export interface AppPrincipal<Id extends string = string> {
|
|
|
35
35
|
|
|
36
36
|
export type Principal = UserPrincipal | ServicePrincipal | LinkHolder | AppPrincipal
|
|
37
37
|
|
|
38
|
+
/** Whoever called a public procedure: nobody the platform knows. The handler runs as the endpoint's own workload. */
|
|
39
|
+
export interface PublicPrincipal {
|
|
40
|
+
readonly kind: 'public'
|
|
41
|
+
/** The endpoint id, which is the workload's name: `<mount>.<group>.<procedure>`. */
|
|
42
|
+
readonly endpoint: string
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
export type PrincipalOf<C> = C extends 'human' ? UserPrincipal
|
|
39
46
|
: C extends 'machine' ? ServicePrincipal
|
|
40
47
|
: C extends 'link' ? LinkHolder
|