@fabrika/app 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// What a procedure admits before its handler runs, decided once at boot from the approved artifact: its buckets, a
|
|
2
|
+
// public procedure's challenge, and its time limit. The buckets live in the application's database
|
|
3
|
+
// (`@fabrika/db`'s `Admissions`, keyed by the endpoint id), so every instance shares them. Admission is not authorization: it bounds how much one client, one caller and everyone together send.
|
|
4
|
+
|
|
5
|
+
import { type AdmissionRate, CLIENT_ADDRESS_HEADER, type ContractArtifact, type ProcedureArtifact } from '@fabrika/auth-core'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { REQUEST_BUDGET_MS } from './bounded'
|
|
8
|
+
import { type ChallengeFetch, type ChallengeVerifier, turnstileVerifier } from './challenge'
|
|
9
|
+
import { clientBucket, clientKey } from './client-address'
|
|
10
|
+
import type { RequestClient } from './types'
|
|
11
|
+
|
|
12
|
+
/** The bucket every call of an endpoint takes from; a client's is `client:<digest>`, a caller's `caller:<digest>`. */
|
|
13
|
+
export const ENDPOINT_BUCKET = 'endpoint'
|
|
14
|
+
|
|
15
|
+
/** The longest `User-Agent` a handler reads: enough to tell browsers apart, too short to carry a payload. */
|
|
16
|
+
export const MAX_USER_AGENT_LENGTH = 256
|
|
17
|
+
|
|
18
|
+
/** A procedure's admission and time limit, from the approved artifact. */
|
|
19
|
+
export interface ProcedureAdmission {
|
|
20
|
+
readonly rate: AdmissionRate
|
|
21
|
+
/** The challenge's verifier, with the secret its application variable holds; `null` without a challenge. */
|
|
22
|
+
readonly challenge: AdmittedChallenge | null
|
|
23
|
+
/** The one token the challenge does not record, so it passes again: `fabrika app dev`'s test widget's. */
|
|
24
|
+
readonly repeatableToken: string | null
|
|
25
|
+
/** How long the handler may run after the last body byte. */
|
|
26
|
+
readonly timeoutMs: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A challenge's verifier and the widget action the verification expects back. */
|
|
30
|
+
export interface AdmittedChallenge {
|
|
31
|
+
readonly verifier: ChallengeVerifier
|
|
32
|
+
readonly action: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A procedure the artifact holds no rules for (a `defineContract` one): no bucket, the request budget. */
|
|
36
|
+
export const DEFAULT_ADMISSION: ProcedureAdmission = Object.freeze({
|
|
37
|
+
rate: Object.freeze({ perClient: null, perCaller: null, perEndpoint: null, bytesPerHour: null }),
|
|
38
|
+
challenge: null,
|
|
39
|
+
repeatableToken: null,
|
|
40
|
+
timeoutMs: REQUEST_BUDGET_MS,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export interface AdmissionOptions {
|
|
44
|
+
readonly application: Readonly<Record<string, unknown>>
|
|
45
|
+
readonly challengeFetch?: ChallengeFetch
|
|
46
|
+
readonly repeatableChallengeToken?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The admission of endpoint `id`, or `null` after a problem: a challenge whose secret's application variable is not set
|
|
51
|
+
* keeps the application from booting, as a form's does.
|
|
52
|
+
*/
|
|
53
|
+
export function procedureAdmission(id: string, artifact: ContractArtifact, options: AdmissionOptions, problems: string[]): ProcedureAdmission | null {
|
|
54
|
+
const rules: ProcedureArtifact | undefined = artifact.procedures !== undefined && Object.hasOwn(artifact.procedures, id)
|
|
55
|
+
? artifact.procedures[id]
|
|
56
|
+
: undefined
|
|
57
|
+
if (rules === undefined) {
|
|
58
|
+
return DEFAULT_ADMISSION
|
|
59
|
+
}
|
|
60
|
+
let challenge: AdmittedChallenge | null = null
|
|
61
|
+
if (rules.challenge !== null) {
|
|
62
|
+
const secret = options.application[rules.challenge.secret]
|
|
63
|
+
if (typeof secret !== 'string' || secret.trim() === '') {
|
|
64
|
+
problems.push(
|
|
65
|
+
`procedure '${id}' verifies its ${rules.challenge.provider} challenge with the application variable ${rules.challenge.secret}, which is not set`,
|
|
66
|
+
)
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
challenge = {
|
|
70
|
+
verifier: turnstileVerifier(secret.trim(), options.challengeFetch ?? ((request) => fetch(request))),
|
|
71
|
+
action: rules.challenge.action,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return Object.freeze({
|
|
75
|
+
rate: rules.rate,
|
|
76
|
+
challenge,
|
|
77
|
+
repeatableToken: challenge === null ? null : (options.repeatableChallengeToken ?? null),
|
|
78
|
+
timeoutMs: rules.timeoutMs,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The buckets a request takes from, in order: its client's or its caller's first, then the endpoint's. */
|
|
83
|
+
export function bucketsOf(rate: AdmissionRate, request: Request, caller: string | null): { readonly bucket: string; readonly limit: number }[] {
|
|
84
|
+
const buckets: { readonly bucket: string; readonly limit: number }[] = []
|
|
85
|
+
if (rate.perClient !== null) {
|
|
86
|
+
buckets.push({ bucket: clientBucket(request), limit: rate.perClient })
|
|
87
|
+
}
|
|
88
|
+
if (rate.perCaller !== null && caller !== null) {
|
|
89
|
+
buckets.push({ bucket: `caller:${createHash('sha256').update(caller).digest('hex')}`, limit: rate.perCaller })
|
|
90
|
+
}
|
|
91
|
+
if (rate.perEndpoint !== null) {
|
|
92
|
+
buckets.push({ bucket: ENDPOINT_BUCKET, limit: rate.perEndpoint })
|
|
93
|
+
}
|
|
94
|
+
return buckets
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Who sent the request, as the proxy saw it. The address is `X-Fabrika-Client-Ip`, which only the proxy sets, and only
|
|
99
|
+
* where the runtime adapter vouches that the proxy saw the visitor (`vouched`); behind a balancer whose range the
|
|
100
|
+
* platform does not trust it is `null`. Anything that is no IP address is `null` too.
|
|
101
|
+
*/
|
|
102
|
+
export function requestClient(request: Request, vouched: boolean): RequestClient {
|
|
103
|
+
const address = vouched ? request.headers.get(CLIENT_ADDRESS_HEADER)?.trim() ?? '' : ''
|
|
104
|
+
const agent = request.headers.get('User-Agent')
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
ip: address === '' || clientKey(address) === 'unknown' ? null : address,
|
|
107
|
+
userAgent: agent === null || agent === '' ? null : agent.slice(0, MAX_USER_AGENT_LENGTH),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* At most `limit` requests at once, beyond which the next is refused: the share of the connection pool all public
|
|
113
|
+
* procedures of an instance hold together, so a flood of anonymous calls leaves signed-in staff the rest.
|
|
114
|
+
*/
|
|
115
|
+
export class InFlightShare {
|
|
116
|
+
readonly #limit: number
|
|
117
|
+
#held = 0
|
|
118
|
+
|
|
119
|
+
constructor(limit: number) {
|
|
120
|
+
this.#limit = limit
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A release to call once the request is answered, or `null` when the share is full. */
|
|
124
|
+
enter(): (() => void) | null {
|
|
125
|
+
if (this.#held >= this.#limit) {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
this.#held++
|
|
129
|
+
let released = false
|
|
130
|
+
return () => {
|
|
131
|
+
if (!released) {
|
|
132
|
+
released = true
|
|
133
|
+
this.#held--
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A quarter of the pool, at least one connection. */
|
|
140
|
+
export function publicShareOf(connections: number): number {
|
|
141
|
+
return Math.max(1, Math.floor(connections / 4))
|
|
142
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Signed tokens at runtime: the keys `FABRIKA_TOKEN_KEYS` hands the environment, `ctx.tokens.sign` of a site that
|
|
2
|
+
// declares `signs`, and the verification of each token field of a procedure's input and `http.query` as they are parsed.
|
|
3
|
+
// A token is `<key id>.<payload>.<signature>`: the payload is the purpose, the expiry and the claims as base64url JSON,
|
|
4
|
+
// and the signature an HMAC-SHA256 over the key id and the payload, so a token of one purpose never passes as another.
|
|
5
|
+
|
|
6
|
+
import { type ContractArtifact, FABRIKA_TOKEN_KEYS, type SchemaShape } from '@fabrika/auth-core'
|
|
7
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
8
|
+
import { z } from 'zod/v4'
|
|
9
|
+
import type { Schema } from '../contract/define'
|
|
10
|
+
import { BadRequestError } from '../errors'
|
|
11
|
+
import { CapabilityRefusedError } from './capability'
|
|
12
|
+
import { ServerFault } from './faults'
|
|
13
|
+
|
|
14
|
+
const DAY_SECONDS = 86_400
|
|
15
|
+
const KEY_ID = /^[A-Za-z0-9_-]{8,64}$/
|
|
16
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/
|
|
17
|
+
/** A key below this many bytes is refused at boot: an HMAC key shorter than its hash is guessable sooner. */
|
|
18
|
+
const MIN_SECRET_BYTES = 32
|
|
19
|
+
|
|
20
|
+
/** One key of the environment: its id, which each token it signs carries, and its secret. */
|
|
21
|
+
export interface TokenKey {
|
|
22
|
+
readonly id: string
|
|
23
|
+
readonly secret: Uint8Array
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A purpose as the runtime holds it: the claims schema the application declared, and the longest lifetime. */
|
|
27
|
+
export interface RuntimePurpose {
|
|
28
|
+
readonly claims: Schema
|
|
29
|
+
readonly maxDays: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The keys of `FABRIKA_TOKEN_KEYS`: `<kid>:<secret>` entries joined by `,`, the signing key first. Required when the
|
|
34
|
+
* contract declares a token purpose; refused at boot when malformed. `null` without purposes or after a problem.
|
|
35
|
+
*/
|
|
36
|
+
export function readTokenKeys(
|
|
37
|
+
framework: Readonly<Record<string, string>>,
|
|
38
|
+
artifact: ContractArtifact,
|
|
39
|
+
problems: string[],
|
|
40
|
+
): readonly TokenKey[] | null {
|
|
41
|
+
const value = framework[FABRIKA_TOKEN_KEYS]?.trim() ?? ''
|
|
42
|
+
if (artifact.tokens === undefined || Object.keys(artifact.tokens).length === 0) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
if (value === '') {
|
|
46
|
+
problems.push(`${FABRIKA_TOKEN_KEYS} is not set; the contract declares token purposes, and the deploy writes their keys`)
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
const keys: TokenKey[] = []
|
|
50
|
+
for (const entry of value.split(',')) {
|
|
51
|
+
const separator = entry.indexOf(':')
|
|
52
|
+
const id = entry.slice(0, separator)
|
|
53
|
+
const encoded = entry.slice(separator + 1)
|
|
54
|
+
const secret = separator < 0 || !BASE64URL.test(encoded) ? null : new Uint8Array(Buffer.from(encoded, 'base64url'))
|
|
55
|
+
if (!KEY_ID.test(id) || secret === null || secret.byteLength < MIN_SECRET_BYTES || keys.some((key) => key.id === id)) {
|
|
56
|
+
problems.push(`${FABRIKA_TOKEN_KEYS} is not a list of <key id>:<secret> entries, each id once and each secret at least ${MIN_SECRET_BYTES} bytes`)
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
keys.push({ id, secret })
|
|
60
|
+
}
|
|
61
|
+
return keys
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The claims a verified token carried, and every text in them: a redirect target or a row id the request may use. */
|
|
65
|
+
export interface VerifiedTokens {
|
|
66
|
+
readonly carried: ReadonlySet<string>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const NO_VERIFIED_TOKENS: VerifiedTokens = Object.freeze({ carried: new Set<string>() })
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The environment's signed tokens. `purposes` are the application's declared claims schemas, by purpose; `keys` are
|
|
73
|
+
* `null` when the contract declares none, so a sign or a verification refuses.
|
|
74
|
+
*/
|
|
75
|
+
export class SignedTokens {
|
|
76
|
+
readonly #purposes: ReadonlyMap<string, RuntimePurpose>
|
|
77
|
+
readonly #keys: readonly TokenKey[]
|
|
78
|
+
readonly #now: () => number
|
|
79
|
+
|
|
80
|
+
constructor(purposes: ReadonlyMap<string, RuntimePurpose>, keys: readonly TokenKey[] | null, now: () => number = Date.now) {
|
|
81
|
+
this.#purposes = purposes
|
|
82
|
+
this.#keys = keys ?? []
|
|
83
|
+
this.#now = now
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `ctx.tokens` of a site: signs a token of a purpose `signs` names, valid for `days` (at most the purpose's
|
|
88
|
+
* `maxDays`). The claims are parsed by the purpose's schema first, so a token never carries what a verifier refuses.
|
|
89
|
+
*/
|
|
90
|
+
context(site: string, signs: readonly string[]): { readonly sign: (purpose: string, claims: unknown, options: unknown) => string } {
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
sign: (purpose: string, claims: unknown, options: unknown): string => this.#sign(site, signs, purpose, claims, options),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#sign(site: string, signs: readonly string[], purpose: string, claims: unknown, options: unknown): string {
|
|
97
|
+
const declared = signs.includes(purpose) ? this.#purposes.get(purpose) : undefined
|
|
98
|
+
if (declared === undefined) {
|
|
99
|
+
throw new CapabilityRefusedError('undeclared_signs', `${site} does not declare signs: ['${String(purpose)}']`)
|
|
100
|
+
}
|
|
101
|
+
const days: unknown = typeof options === 'object' && options !== null ? Reflect.get(options, 'days') : undefined
|
|
102
|
+
if (typeof days !== 'number' || !Number.isSafeInteger(days) || days < 1 || days > declared.maxDays) {
|
|
103
|
+
throw new CapabilityRefusedError(
|
|
104
|
+
'undeclared_signs',
|
|
105
|
+
`${site} signed a '${purpose}' token for ${String(days)} days; the purpose allows 1 to ${declared.maxDays}`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
const parsed = z.safeParse(declared.claims, claims)
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
throw new ServerFault('token_claims_refused', `${site} signed a '${purpose}' token whose claims its schema refuses`)
|
|
111
|
+
}
|
|
112
|
+
const [key] = this.#keys
|
|
113
|
+
if (key === undefined) {
|
|
114
|
+
throw new ServerFault('token_keys_missing', `${site} signed a '${purpose}' token, and the environment holds no token key`)
|
|
115
|
+
}
|
|
116
|
+
const expiresAt = Math.floor(this.#now() / 1000) + days * DAY_SECONDS
|
|
117
|
+
const payload = Buffer.from(JSON.stringify({ p: purpose, x: expiresAt, c: parsed.data })).toString('base64url')
|
|
118
|
+
return `${key.id}.${payload}.${signature(key, payload)}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The claims of `token` when it is a token of `purpose` this environment signed and it has not expired; a 400
|
|
123
|
+
* otherwise, which says only that it is not valid, so a caller learns nothing about the keys.
|
|
124
|
+
*/
|
|
125
|
+
verify(purpose: string, token: string): Readonly<Record<string, unknown>> {
|
|
126
|
+
const [id = '', payload = '', given = '', ...rest] = token.split('.')
|
|
127
|
+
const key = this.#keys.find((candidate) => candidate.id === id)
|
|
128
|
+
const declared = this.#purposes.get(purpose)
|
|
129
|
+
if (rest.length > 0 || key === undefined || declared === undefined || !BASE64URL.test(payload) || !BASE64URL.test(given)) {
|
|
130
|
+
throw invalid()
|
|
131
|
+
}
|
|
132
|
+
const expected = Buffer.from(signature(key, payload), 'base64url')
|
|
133
|
+
const presented = Buffer.from(given, 'base64url')
|
|
134
|
+
if (presented.byteLength !== expected.byteLength || !timingSafeEqual(presented, expected)) {
|
|
135
|
+
throw invalid()
|
|
136
|
+
}
|
|
137
|
+
const body = parsePayload(payload)
|
|
138
|
+
if (body === null || body.p !== purpose) {
|
|
139
|
+
throw invalid()
|
|
140
|
+
}
|
|
141
|
+
if (body.x <= Math.floor(this.#now() / 1000)) {
|
|
142
|
+
throw new BadRequestError('The signed token has expired')
|
|
143
|
+
}
|
|
144
|
+
const claims = z.safeParse(declared.claims, body.c)
|
|
145
|
+
if (!claims.success || !isRecord(claims.data)) {
|
|
146
|
+
throw invalid()
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze(claims.data)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Each top-level signed token field of `value` (by `shape`, the approved input or query) replaced by its verified
|
|
153
|
+
* claims, and every text those claims carry.
|
|
154
|
+
*/
|
|
155
|
+
verifyFields(shape: SchemaShape | null, value: Readonly<Record<string, unknown>>, into: Set<string>): Readonly<Record<string, unknown>> {
|
|
156
|
+
const fields = tokenFields(shape)
|
|
157
|
+
if (fields.size === 0) {
|
|
158
|
+
return value
|
|
159
|
+
}
|
|
160
|
+
const verified: Record<string, unknown> = { ...value }
|
|
161
|
+
for (const [field, purpose] of fields) {
|
|
162
|
+
const text = value[field]
|
|
163
|
+
if (typeof text === 'string') {
|
|
164
|
+
const claims = this.verify(purpose, text)
|
|
165
|
+
collectTexts(claims, into)
|
|
166
|
+
verified[field] = claims
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return Object.freeze(verified)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The top-level fields of an object shape that carry a signed token, optional or not, with their purposes. */
|
|
174
|
+
export function tokenFields(shape: SchemaShape | null): ReadonlyMap<string, string> {
|
|
175
|
+
const fields = new Map<string, string>()
|
|
176
|
+
if (shape?.type !== 'object') {
|
|
177
|
+
return fields
|
|
178
|
+
}
|
|
179
|
+
for (const [name, field] of Object.entries(shape.fields)) {
|
|
180
|
+
const inner = field.type === 'optional' ? field.inner : field
|
|
181
|
+
if (inner.type === 'token') {
|
|
182
|
+
fields.set(name, inner.purpose)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return fields
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function signature(key: TokenKey, payload: string): string {
|
|
189
|
+
return createHmac('sha256', key.secret).update(`${key.id}.${payload}`).digest('base64url')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function invalid(): BadRequestError {
|
|
193
|
+
return new BadRequestError('The signed token is not valid')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const payloadSchema = z.strictObject({ p: z.string(), x: z.int(), c: z.unknown() })
|
|
197
|
+
|
|
198
|
+
function parsePayload(payload: string): z.output<typeof payloadSchema> | null {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = z.safeParse(payloadSchema, JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')))
|
|
201
|
+
return parsed.success ? parsed.data : null
|
|
202
|
+
} catch {
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function collectTexts(value: unknown, into: Set<string>): void {
|
|
208
|
+
if (typeof value === 'string') {
|
|
209
|
+
into.add(value)
|
|
210
|
+
} else if (Array.isArray(value)) {
|
|
211
|
+
for (const item of value) collectTexts(item, into)
|
|
212
|
+
} else if (isRecord(value)) {
|
|
213
|
+
for (const item of Object.values(value)) collectTexts(item, into)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
218
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
219
|
+
}
|
package/src/implement/types.ts
CHANGED
|
@@ -10,17 +10,31 @@ import type { AppCapability, Ref, Within } from './capability'
|
|
|
10
10
|
import type { ConnectResult } from './connect'
|
|
11
11
|
import type { LinkIssuing, MemberDirectory, MemberLookup, MemberManagement } from './membership'
|
|
12
12
|
import type { AppPrincipal, LinkHolder, PrincipalOf, ServicePrincipal } from './principal'
|
|
13
|
+
import type { AppUrl } from './urls'
|
|
13
14
|
import type { WebhookDelivery, WebhookPrincipal } from './webhook'
|
|
14
15
|
|
|
15
|
-
/** What every handler gets: who called, this request's id, and the signal that ends it. */
|
|
16
|
+
/** What every handler gets: who called, this request's id, the client the proxy saw, and the signal that ends it. */
|
|
16
17
|
export interface RequestContext<P> {
|
|
17
18
|
readonly principal: P
|
|
18
19
|
readonly requestId: string
|
|
19
20
|
/**
|
|
20
|
-
* Aborts when the client leaves or the request outlives
|
|
21
|
-
* it to every outbound call, so the call stops with the request.
|
|
21
|
+
* Aborts when the client leaves or the request outlives its time limit (`limits.timeoutMs`, `REQUEST_BUDGET_MS` by
|
|
22
|
+
* default); its transaction then rolls back. Pass it to every outbound call, so the call stops with the request.
|
|
22
23
|
*/
|
|
23
24
|
readonly signal: AbortSignal
|
|
25
|
+
/** The client the proxy saw. Information to record, never a check. */
|
|
26
|
+
readonly client: RequestClient
|
|
27
|
+
/** An absolute link to a procedure of this application: `ctx.url('api.mail.unsubscribe', { query: { t } })`. */
|
|
28
|
+
readonly url: AppUrl
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The client of a request: its address as the proxy saw it, `null` where the platform cannot vouch for it (behind a
|
|
33
|
+
* balancer whose range it does not trust); its `User-Agent`, cut to `MAX_USER_AGENT_LENGTH`, `null` when it sends none.
|
|
34
|
+
*/
|
|
35
|
+
export interface RequestClient {
|
|
36
|
+
readonly ip: string | null
|
|
37
|
+
readonly userAgent: string | null
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
/** The capability an access form hands. `{ member: true }` hands none. */
|
|
@@ -30,6 +44,13 @@ export type AccessContext<Acc> = Acc extends MemberAccess ? unknown
|
|
|
30
44
|
: Acc extends { readonly action: infer A extends string; readonly on: infer D extends string } ? { readonly target: Ref<D, A> }
|
|
31
45
|
: Acc extends { readonly action: infer A extends string; readonly creates: infer D extends string } ? { readonly created: Ref<D, A> }
|
|
32
46
|
: Acc extends { readonly action: infer A extends string; readonly app: true } ? { readonly app: AppCapability<A> }
|
|
47
|
+
: Acc extends { readonly capabilities: infer C } ? { readonly capabilities: { readonly [N in keyof C]: CapabilityOf<C[N]> } }
|
|
48
|
+
: never
|
|
49
|
+
|
|
50
|
+
/** The capability one of several hands: a `Within` for `over`, a `Ref` for `on` and `in`. */
|
|
51
|
+
export type CapabilityOf<F> = F extends { readonly action: infer A extends string; readonly over: infer D extends string } ? Within<D, A>
|
|
52
|
+
: F extends { readonly action: infer A extends string; readonly in: infer D extends string } ? Ref<D, A>
|
|
53
|
+
: F extends { readonly action: infer A extends string; readonly on: infer D extends string } ? Ref<D, A>
|
|
33
54
|
: never
|
|
34
55
|
|
|
35
56
|
type DimensionsOf<C> = C extends { readonly dimensions: infer Dims } ? Dims : never
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `ctx.url('<endpoint id>', { input?, query? })`: an absolute link to one of the application's own procedures, for a
|
|
2
|
+
// mail, a redirect or an answer. The origin is the environment's canonical one (`FABRIKA_APP_ORIGIN`, which a deploy
|
|
3
|
+
// writes), the path the endpoint's, and the parameters what its HTTP shape reads from the URL: the input of a `GET`,
|
|
4
|
+
// the `query` of a `POST`. Each value is spelled as the procedure reads it back (`params.ts`).
|
|
5
|
+
|
|
6
|
+
import { type ContractArtifact, type EndpointRow, FABRIKA_APP_ORIGIN } from '@fabrika/auth-core'
|
|
7
|
+
import { ServerFault } from './faults'
|
|
8
|
+
|
|
9
|
+
/** A value a URL parameter carries; an absent one is left out. */
|
|
10
|
+
export type UrlValue = string | number | boolean | null | undefined
|
|
11
|
+
|
|
12
|
+
export interface UrlParameters {
|
|
13
|
+
/** A `GET` procedure's input, each field a parameter. */
|
|
14
|
+
readonly input?: Readonly<Record<string, UrlValue>>
|
|
15
|
+
/** A `POST` procedure's `http.query`. */
|
|
16
|
+
readonly query?: Readonly<Record<string, UrlValue>>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `ctx.url`: the absolute URL of a procedure of this contract, with the parameters its HTTP shape reads from the URL. */
|
|
20
|
+
export type AppUrl = (endpoint: string, parameters?: UrlParameters) => string
|
|
21
|
+
|
|
22
|
+
/** The environment's canonical origin, or `null` where the deploy wrote none; a malformed one is a boot problem. */
|
|
23
|
+
export function readAppOrigin(framework: Readonly<Record<string, string>>, problems: string[]): string | null {
|
|
24
|
+
const value = framework[FABRIKA_APP_ORIGIN]?.trim() ?? ''
|
|
25
|
+
if (value === '') {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(value)
|
|
30
|
+
if ((url.protocol === 'https:' || url.protocol === 'http:') && url.origin === value) {
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Reported below, as every other malformed value.
|
|
35
|
+
}
|
|
36
|
+
problems.push(`${FABRIKA_APP_ORIGIN} '${value}' is not an origin (allowed: https://<host>)`)
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `ctx.url` over the approved artifact's procedure rows. */
|
|
41
|
+
export function appUrl(artifact: ContractArtifact, origin: string | null): AppUrl {
|
|
42
|
+
const procedures = new Map(artifact.endpoints.filter(isProcedureRow).map((row) => [row.id, row]))
|
|
43
|
+
return (endpoint, parameters = {}) => {
|
|
44
|
+
const row = procedures.get(endpoint)
|
|
45
|
+
if (row === undefined) {
|
|
46
|
+
throw new ServerFault('url_unbuildable', `ctx.url names '${String(endpoint)}', which is no procedure of the contract`)
|
|
47
|
+
}
|
|
48
|
+
if (origin === null) {
|
|
49
|
+
throw new ServerFault('url_unbuildable', `ctx.url needs ${FABRIKA_APP_ORIGIN}, and the environment has no public origin`)
|
|
50
|
+
}
|
|
51
|
+
const reads = row.method === 'GET' ? 'input' : 'query'
|
|
52
|
+
const other = reads === 'input' ? parameters.query : parameters.input
|
|
53
|
+
const hasQuery = artifact.procedures?.[endpoint]?.query !== undefined
|
|
54
|
+
if (other !== undefined || (reads === 'query' && parameters.query !== undefined && !hasQuery)) {
|
|
55
|
+
throw new ServerFault(
|
|
56
|
+
'url_unbuildable',
|
|
57
|
+
`ctx.url('${endpoint}') passes parameters its HTTP shape does not read from the URL (allowed: ${
|
|
58
|
+
reads === 'input' ? 'input of a GET' : 'query of a POST that declares http.query'
|
|
59
|
+
})`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
const search = new URLSearchParams()
|
|
63
|
+
for (const [name, value] of Object.entries(parameters[reads] ?? {})) {
|
|
64
|
+
if (value === undefined) continue
|
|
65
|
+
if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
66
|
+
throw new ServerFault('url_unbuildable', `ctx.url('${endpoint}') parameter '${name}' is no scalar`)
|
|
67
|
+
}
|
|
68
|
+
search.set(name, value === null ? 'null' : String(value))
|
|
69
|
+
}
|
|
70
|
+
const text = search.toString()
|
|
71
|
+
return `${origin}${row.path}${text === '' ? '' : `?${text}`}`
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A row a procedure of an RPC or link mount answers: never a generated, file or platform row. */
|
|
76
|
+
function isProcedureRow(row: EndpointRow): boolean {
|
|
77
|
+
return (row.kind === 'query' || row.kind === 'mutation' || row.kind === 'public') && row.source.kind === 'mount'
|
|
78
|
+
}
|