@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.
Files changed (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +387 -236
  10. package/skill/references/spa.md +5 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. 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
+ }
@@ -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 `REQUEST_BUDGET_MS`; its transaction then rolls back. Pass
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
+ }