@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.
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,299 @@
1
+ // A webhook delivery's signature, checked over the raw body before anything parses it. Each verifier is framework
2
+ // code: a preset reads its provider's headers, an `hmac` shape computes the declared HMAC, `basic` and `bearer` compare
3
+ // the secret itself. Every comparison is constant-time.
4
+
5
+ import { FORM_URLENCODED_TYPE, type HmacAlgorithm, type HmacScheme, type WebhookScheme } from '@fabrika/auth-core'
6
+
7
+ /** How far a signed timestamp may be from now: the window in which a captured delivery could be replayed. */
8
+ export const WEBHOOK_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000
9
+
10
+ /** The prefix of a Standard Webhooks secret, whose rest is the key in base64. */
11
+ const STANDARD_SECRET_PREFIX = 'whsec_'
12
+
13
+ const ALGORITHMS: Readonly<Record<HmacAlgorithm, string>> = { sha1: 'SHA-1', sha256: 'SHA-256', sha512: 'SHA-512' }
14
+
15
+ const encoder = new TextEncoder()
16
+
17
+ /** What a verifier reads of a delivery. */
18
+ export interface SignedDelivery {
19
+ readonly headers: Headers
20
+ readonly body: Uint8Array<ArrayBuffer>
21
+ /**
22
+ * The URL the provider posted to: `FABRIKA_APP_ORIGIN` and the request's path and query, which Twilio signs. `null`
23
+ * where the environment has no public origin.
24
+ */
25
+ readonly url: string | null
26
+ }
27
+
28
+ /** Whether the delivery proves it holds `secret` under `scheme`. `now` is the clock a signed timestamp is checked against. */
29
+ export async function verifyDelivery(scheme: WebhookScheme, delivery: SignedDelivery, secret: string, now: number): Promise<boolean> {
30
+ const { headers, body } = delivery
31
+ switch (scheme) {
32
+ case 'github-sha256':
33
+ return await verifyHmac(GITHUB, headers, encoder.encode(secret), body, now)
34
+ case 'slack':
35
+ return await verifySlack(headers, encoder.encode(secret), body, now)
36
+ case 'stripe':
37
+ return await verifyStripe(headers.get('Stripe-Signature'), encoder.encode(secret), body, now)
38
+ case 'standard-webhooks':
39
+ return await verifyStandard(headers, standardKey(secret), body, now)
40
+ case 'twilio':
41
+ return await verifyTwilio(delivery, encoder.encode(secret))
42
+ }
43
+ if ('hmac' in scheme) {
44
+ return await verifyHmac(scheme.hmac, headers, encoder.encode(secret), body, now)
45
+ }
46
+ const authorization = headers.get('Authorization')
47
+ if ('basic' in scheme) {
48
+ return await sameText(basicPassword(authorization), secret)
49
+ }
50
+ return await sameText(bearerToken(authorization), secret)
51
+ }
52
+
53
+ /** GitHub's `X-Hub-Signature-256: sha256=<hex>`, an HMAC-SHA256 of the body. */
54
+ const GITHUB: HmacScheme = { header: 'X-Hub-Signature-256', algorithm: 'sha256', encoding: 'hex', prefix: 'sha256=', signed: 'body' }
55
+
56
+ async function verifyHmac(
57
+ scheme: HmacScheme,
58
+ headers: Headers,
59
+ key: Uint8Array<ArrayBuffer>,
60
+ body: Uint8Array<ArrayBuffer>,
61
+ now: number,
62
+ ): Promise<boolean> {
63
+ const header = headers.get(scheme.header)
64
+ const prefix = scheme.prefix ?? ''
65
+ if (header === null || !header.startsWith(prefix)) {
66
+ return false
67
+ }
68
+ const signature = decode(header.slice(prefix.length), scheme.encoding)
69
+ if (signature === null) {
70
+ return false
71
+ }
72
+ const parts: string[] = []
73
+ if (scheme.id !== undefined) {
74
+ const id = headers.get(scheme.id)
75
+ if (id === null || id === '') {
76
+ return false
77
+ }
78
+ parts.push(id)
79
+ }
80
+ if (scheme.signed === 'timestamp.body') {
81
+ const timestamp = scheme.timestamp === undefined ? null : headers.get(scheme.timestamp)
82
+ if (timestamp === null || !fresh(timestamp, now)) {
83
+ return false
84
+ }
85
+ parts.push(timestamp)
86
+ }
87
+ return await hmacVerifies(scheme.algorithm, key, [signature], signedContent(parts, body))
88
+ }
89
+
90
+ /** Stripe's `Stripe-Signature: t=<seconds>,v1=<hex>[,v1=<hex>…]` over `<t>.<body>`; a rotated secret sends two `v1`. */
91
+ async function verifyStripe(header: string | null, key: Uint8Array<ArrayBuffer>, body: Uint8Array<ArrayBuffer>, now: number): Promise<boolean> {
92
+ if (header === null) {
93
+ return false
94
+ }
95
+ let timestamp: string | null = null
96
+ const signatures: Uint8Array<ArrayBuffer>[] = []
97
+ for (const item of header.split(',')) {
98
+ const separator = item.indexOf('=')
99
+ const name = item.slice(0, separator).trim()
100
+ const value = item.slice(separator + 1).trim()
101
+ if (separator > 0 && name === 't') {
102
+ timestamp = value
103
+ } else if (separator > 0 && name === 'v1') {
104
+ const signature = decode(value, 'hex')
105
+ if (signature !== null) {
106
+ signatures.push(signature)
107
+ }
108
+ }
109
+ }
110
+ if (timestamp === null || !fresh(timestamp, now)) {
111
+ return false
112
+ }
113
+ return await hmacVerifies('sha256', key, signatures, signedContent([timestamp], body))
114
+ }
115
+
116
+ /** Slack's `X-Slack-Signature: v0=<hex>`, an HMAC-SHA256 of `v0:<X-Slack-Request-Timestamp>:<body>`, within five minutes. */
117
+ async function verifySlack(headers: Headers, key: Uint8Array<ArrayBuffer>, body: Uint8Array<ArrayBuffer>, now: number): Promise<boolean> {
118
+ const timestamp = headers.get('X-Slack-Request-Timestamp')
119
+ const header = headers.get('X-Slack-Signature')
120
+ if (timestamp === null || header === null || !header.startsWith('v0=') || !fresh(timestamp, now)) {
121
+ return false
122
+ }
123
+ const signature = decode(header.slice(3), 'hex')
124
+ return signature !== null && (await hmacVerifies('sha256', key, [signature], prefixed(`v0:${timestamp}:`, body)))
125
+ }
126
+
127
+ /**
128
+ * Twilio's `X-Twilio-Signature`: the base64 HMAC-SHA1 of the URL it posted to, followed by a form's parameters sorted by
129
+ * name, each name then its value. A JSON body is signed through the URL instead: its `bodySHA256` parameter must be
130
+ * the body's SHA-256. Twilio signs no timestamp.
131
+ */
132
+ async function verifyTwilio(delivery: SignedDelivery, key: Uint8Array<ArrayBuffer>): Promise<boolean> {
133
+ const header = delivery.headers.get('X-Twilio-Signature')
134
+ const signature = header === null ? null : decode(header, 'base64')
135
+ const content = await twilioSigned(delivery)
136
+ return signature !== null && content !== null && (await hmacVerifies('sha1', key, [signature], encoder.encode(content)))
137
+ }
138
+
139
+ async function twilioSigned({ headers, body, url }: SignedDelivery): Promise<string | null> {
140
+ if (url === null) {
141
+ return null
142
+ }
143
+ if (headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase() === FORM_URLENCODED_TYPE) {
144
+ const text = utf8(body)
145
+ if (text === null) {
146
+ return null
147
+ }
148
+ const parameters = [...new URLSearchParams(text)].sort(([leftName, leftValue], [rightName, rightValue]) =>
149
+ compareText(leftName, rightName) || compareText(leftValue, rightValue)
150
+ )
151
+ return url + parameters.map(([name, value]) => name + value).join('')
152
+ }
153
+ const claimed = new URL(url).searchParams.get('bodySHA256')
154
+ const digest = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', body)), (byte) => byte.toString(16).padStart(2, '0')).join('')
155
+ return claimed?.toLowerCase() === digest ? url : null
156
+ }
157
+
158
+ function compareText(left: string, right: string): number {
159
+ return left < right ? -1 : left > right ? 1 : 0
160
+ }
161
+
162
+ /** Standard Webhooks: `webhook-signature: v1,<base64> …` over `<webhook-id>.<webhook-timestamp>.<body>`. */
163
+ async function verifyStandard(headers: Headers, key: Uint8Array<ArrayBuffer> | null, body: Uint8Array<ArrayBuffer>, now: number): Promise<boolean> {
164
+ const id = headers.get('webhook-id')
165
+ const timestamp = headers.get('webhook-timestamp')
166
+ const header = headers.get('webhook-signature')
167
+ if (key === null || id === null || id === '' || timestamp === null || header === null || !fresh(timestamp, now)) {
168
+ return false
169
+ }
170
+ const signatures = header.split(' ').flatMap((entry) => {
171
+ const signature = entry.startsWith('v1,') ? decode(entry.slice(3), 'base64') : null
172
+ return signature === null ? [] : [signature]
173
+ })
174
+ return await hmacVerifies('sha256', key, signatures, signedContent([id, timestamp], body))
175
+ }
176
+
177
+ /** A Standard Webhooks secret is `whsec_` and the key in base64; `null` when it is not. */
178
+ export function standardKey(secret: string): Uint8Array<ArrayBuffer> | null {
179
+ return secret.startsWith(STANDARD_SECRET_PREFIX) ? decode(secret.slice(STANDARD_SECRET_PREFIX.length), 'base64') : null
180
+ }
181
+
182
+ /** A timestamp in Unix seconds within the tolerance of `now`. */
183
+ function fresh(timestamp: string, now: number): boolean {
184
+ if (!/^[0-9]{1,12}$/.test(timestamp)) {
185
+ return false
186
+ }
187
+ return Math.abs(now - Number(timestamp) * 1000) <= WEBHOOK_TIMESTAMP_TOLERANCE_MS
188
+ }
189
+
190
+ /** `<part>.<part>.<body>`: what a timestamped or identified signature covers. */
191
+ function signedContent(parts: readonly string[], body: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
192
+ return parts.length === 0 ? body : prefixed(`${parts.join('.')}.`, body)
193
+ }
194
+
195
+ function prefixed(head: string, body: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
196
+ const bytes = encoder.encode(head)
197
+ const content = new Uint8Array(bytes.byteLength + body.byteLength)
198
+ content.set(bytes, 0)
199
+ content.set(body, bytes.byteLength)
200
+ return content
201
+ }
202
+
203
+ /** Whether one of `signatures` is the HMAC of `content`; `crypto.subtle.verify` compares in constant time. */
204
+ async function hmacVerifies(
205
+ algorithm: HmacAlgorithm,
206
+ key: Uint8Array<ArrayBuffer>,
207
+ signatures: readonly Uint8Array<ArrayBuffer>[],
208
+ content: Uint8Array<ArrayBuffer>,
209
+ ): Promise<boolean> {
210
+ if (signatures.length === 0 || key.byteLength === 0) {
211
+ return false
212
+ }
213
+ const imported = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: ALGORITHMS[algorithm] }, false, ['verify'])
214
+ let verified = false
215
+ for (const signature of signatures) {
216
+ verified = (await crypto.subtle.verify('HMAC', imported, signature, content)) || verified
217
+ }
218
+ return verified
219
+ }
220
+
221
+ /** The password of `Authorization: Basic <base64(user:password)>`; the user name is not checked. */
222
+ function basicPassword(authorization: string | null): string | null {
223
+ const match = authorization === null ? null : /^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(authorization)
224
+ const credentials = match?.[1] === undefined ? null : decode(match[1], 'base64')
225
+ const text = credentials === null ? null : utf8(credentials)
226
+ const separator = text?.indexOf(':') ?? -1
227
+ return text === null || separator < 0 ? null : text.slice(separator + 1)
228
+ }
229
+
230
+ function utf8(bytes: Uint8Array): string | null {
231
+ try {
232
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
233
+ } catch {
234
+ return null
235
+ }
236
+ }
237
+
238
+ function bearerToken(authorization: string | null): string | null {
239
+ const match = authorization === null ? null : /^Bearer (\S+)$/i.exec(authorization)
240
+ return match?.[1] ?? null
241
+ }
242
+
243
+ /** Whether two texts are equal, compared as SHA-256 digests in constant time. */
244
+ async function sameText(given: string | null, expected: string): Promise<boolean> {
245
+ if (given === null || expected === '') {
246
+ return false
247
+ }
248
+ const [left, right] = await Promise.all(
249
+ [given, expected].map(async (text) => new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(text)))),
250
+ )
251
+ if (left === undefined || right === undefined) {
252
+ return false
253
+ }
254
+ let difference = 0
255
+ for (let index = 0; index < left.byteLength; index++) {
256
+ difference |= (left[index] ?? 0) ^ (right[index] ?? 0)
257
+ }
258
+ return difference === 0
259
+ }
260
+
261
+ /** Bytes of a hex or base64 text; `null` when it is not one. */
262
+ function decode(text: string, encoding: 'hex' | 'base64'): Uint8Array<ArrayBuffer> | null {
263
+ if (encoding === 'hex') {
264
+ if (!/^(?:[0-9a-fA-F]{2})+$/.test(text)) {
265
+ return null
266
+ }
267
+ const bytes = new Uint8Array(text.length / 2)
268
+ for (let index = 0; index < bytes.byteLength; index++) {
269
+ bytes[index] = Number.parseInt(text.slice(index * 2, index * 2 + 2), 16)
270
+ }
271
+ return bytes
272
+ }
273
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text) || text === '') {
274
+ return null
275
+ }
276
+ const binary = atob(text)
277
+ const bytes = new Uint8Array(binary.length)
278
+ for (let index = 0; index < binary.length; index++) {
279
+ bytes[index] = binary.charCodeAt(index)
280
+ }
281
+ return bytes
282
+ }
283
+
284
+ /** The GitHub account a payload names, as a binding records it: `installation:<id>` or `repository:<id>`. */
285
+ export function githubAccounts(payload: unknown): readonly string[] {
286
+ const accounts: string[] = []
287
+ for (const kind of ['installation', 'repository'] as const) {
288
+ const holder = ownField(payload, kind)
289
+ const id = ownField(holder, 'id')
290
+ if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) {
291
+ accounts.push(`${kind}:${id}`)
292
+ }
293
+ }
294
+ return accounts
295
+ }
296
+
297
+ function ownField(value: unknown, name: string): unknown {
298
+ return typeof value === 'object' && value !== null && Object.hasOwn(value, name) ? Reflect.get(value, name) : undefined
299
+ }
@@ -1,24 +1,22 @@
1
- // `c.webhook` for GitHub (docs/reference/application-runtime.md). A holder of the bound dimension's `members.manage`
2
- // creates a binding through the generated `webhooks.<name>.bind` endpoint; the framework generates the secret and
3
- // keeps the binding in `fabrika_webhook_bindings`. A delivery is checked in a fixed order — the binding, the
4
- // signature over the raw body, the account, the input, the delivery id — before its handler sees a `Ref` of the
5
- // bound value only.
1
+ // A webhook's bindings and deliveries (docs/reference/application-runtime.md). A procedure that declares `binds`
2
+ // creates a binding through `ctx.webhooks('<name>')`. The framework generates the secret, or the procedure sets the one
3
+ // the provider issued, and keeps the binding in `fabrika_webhook_bindings`. A delivery is checked in a fixed order —
4
+ // the binding, the signature, the account, the input, the delivery key — before its handler sees a `Ref` of the bound
5
+ // value, or its application-wide capabilities when the webhook is bound to `'app'`.
6
6
 
7
- import type { WebhookTables } from '@fabrika/db/framework'
7
+ import { API_KEY_PREFIX, type ScopeBinding, type WebhookScheme } from '@fabrika/auth-core'
8
+ import { type CapabilityAuthority, describeGrant } from '@fabrika/db/capability'
9
+ import type { Session, WebhookTables } from '@fabrika/db/framework'
8
10
  import { uuidv7 } from '@fabrika/platform'
9
11
  import { z } from 'zod/v4'
10
- import { joinPath, WEBHOOK_ACCOUNT_PATTERN, WEBHOOK_SCOPE_FIELD } from '../contract/defaults'
11
- import { NotFoundError } from '../errors'
12
- import { Ref } from './capability'
13
-
14
- /** The input and output of the generated binding endpoints; `build.ts`'s `WEBHOOK_BIND_*` shapes describe them. */
15
- export const webhookBindInput = z.strictObject({
16
- [WEBHOOK_SCOPE_FIELD]: z.string().min(1),
17
- account: z.string().regex(new RegExp(WEBHOOK_ACCOUNT_PATTERN)),
18
- })
19
- export const webhookBindOutput = z.strictObject({ id: z.uuid(), url: z.string().min(1), secret: z.string().min(1) })
20
- export const webhookUnbindInput = z.strictObject({ [WEBHOOK_SCOPE_FIELD]: z.string().min(1), id: z.uuid() })
21
- export const webhookUnbindOutput = z.strictObject({ id: z.uuid() })
12
+ import { joinPath } from '../contract/defaults'
13
+ import type { WebhookBindings, WebhookSecret } from '../contract/webhook-binding'
14
+ import { BadRequestError } from '../errors'
15
+ import { CapabilityRefusedError } from './capability'
16
+ import { standardKey } from './verifiers'
17
+
18
+ /** A GitHub account a binding accepts: an App installation or a repository, by numeric id. */
19
+ const GITHUB_ACCOUNT = /^(?:installation|repository):[1-9][0-9]{0,19}$/
22
20
 
23
21
  /** The workload a delivery runs as: no person, only the binding. It creates no scope and manages no member. */
24
22
  export interface WebhookPrincipal {
@@ -37,6 +35,12 @@ export function webhookSubject(bindingId: string): string {
37
35
  */
38
36
  export const WEBHOOK_REPLAY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
39
37
 
38
+ /**
39
+ * How long a delivery's claim outlasts its handler's time limit: a copy that arrives later finds the claim held and is
40
+ * answered as received; once the lease passed without a completion (a crash, a lost connection), the redelivery runs.
41
+ */
42
+ export const WEBHOOK_CLAIM_GRACE_MS = 30_000
43
+
40
44
  /**
41
45
  * `sha256:<hex>` of the binding id and the signed body: what replay protection and an idempotent handler key on.
42
46
  * A redelivery of one event has the same key, whatever its `X-GitHub-Delivery` says.
@@ -55,54 +59,131 @@ export interface WebhookDelivery {
55
59
  readonly key: string
56
60
  }
57
61
 
58
- /** 32 random bytes as hex: the HMAC key GitHub signs with, shown once to the member who binds. */
59
- function generateSecret(): string {
62
+ /**
63
+ * 32 random bytes, shown once to the member who binds: as hex, the key a provider signs with or sends as its password
64
+ * or token; for Standard Webhooks, `whsec_` and the bytes in base64, as its senders expect.
65
+ */
66
+ function generateSecret(scheme: WebhookScheme): string {
60
67
  const bytes = crypto.getRandomValues(new Uint8Array(32))
68
+ if (scheme === 'standard-webhooks') {
69
+ return `whsec_${btoa(String.fromCharCode(...bytes))}`
70
+ }
61
71
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
62
72
  }
63
73
 
64
- export interface BindingHandlers {
65
- bind(args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object }): Promise<z.output<typeof webhookBindOutput>>
66
- unbind(args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object }): Promise<z.output<typeof webhookUnbindOutput>>
74
+ /** The scope a binding of a webhook bound to `'app'` records: the application, which has no value. */
75
+ export const APP_BINDING_SCOPE = Object.freeze({ type: 'app', value: '' })
76
+
77
+ /** The longest account a binding records for a provider other than GitHub. */
78
+ const MAX_ACCOUNT_LENGTH = 200
79
+
80
+ /** The longest secret a provider may issue for a binding. */
81
+ const MAX_SECRET_LENGTH = 512
82
+
83
+ /** What a procedure that binds needs of its webhook: where deliveries arrive, how they are signed, what they reach. */
84
+ export interface BindableWebhook {
85
+ readonly name: string
86
+ readonly path: string
87
+ readonly scheme: WebhookScheme
88
+ readonly boundTo: ScopeBinding
67
89
  }
68
90
 
69
- /** What the `in` access form handed a binding endpoint: the checked `Ref` of the value, and the person. */
70
- function accessOf(ctx: object): { readonly scope: Ref<string, string>; readonly principal: string } {
71
- const target: unknown = Reflect.get(ctx, 'target')
72
- const principal: unknown = Reflect.get(ctx, 'principal')
73
- const id: unknown = typeof principal === 'object' && principal !== null ? Reflect.get(principal, 'id') : undefined
74
- if (!(target instanceof Ref) || typeof id !== 'string') {
75
- throw new TypeError('a binding endpoint runs for a person, with the Ref of its in access form')
76
- }
77
- return { scope: target, principal: id }
91
+ /** Where `ctx.webhooks` runs: the procedure's transaction, bound to its request like every handle. */
92
+ export interface BindingSite {
93
+ readonly site: string
94
+ readonly webhook: BindableWebhook
95
+ readonly tables: () => WebhookTables
96
+ readonly authority: CapabilityAuthority
97
+ readonly session: () => Promise<Session>
98
+ readonly check: () => void
99
+ /** Who binds: the caller's subject, recorded with the binding. */
100
+ readonly principal: string
78
101
  }
79
102
 
80
- /** The framework's handlers of `webhooks.<name>.bind` and `.unbind`. */
81
- export function bindingHandlers(webhook: string, path: string, tables: () => WebhookTables): BindingHandlers {
82
- return {
83
- async bind({ input, ctx }) {
84
- const { scope, principal } = accessOf(ctx)
85
- const account = z.parse(webhookBindInput.shape.account, input['account'])
103
+ /** `ctx.webhooks` of a procedure that declares `binds`: only its declared webhook, only on the value its capability names. */
104
+ export function webhookBindings(site: BindingSite): (name: string) => WebhookBindings {
105
+ const { webhook } = site
106
+ const scopeOf = (capability: unknown): { readonly type: string; readonly value: string } => {
107
+ site.check()
108
+ const grant = site.authority.resolve(capability)
109
+ if (webhook.boundTo.kind === 'app' && grant.kind === 'app') {
110
+ return APP_BINDING_SCOPE
111
+ }
112
+ if (webhook.boundTo.kind === 'dimension' && grant.kind === 'ref' && grant.dimension === webhook.boundTo.dimension) {
113
+ return { type: grant.dimension, value: grant.id }
114
+ }
115
+ const wanted = webhook.boundTo.kind === 'app' ? 'an app capability (ctx.app)' : `a Ref of ${webhook.boundTo.dimension}`
116
+ throw new CapabilityRefusedError(
117
+ 'wrong_binding',
118
+ `${site.site}: webhook '${webhook.name}' binds with ${wanted}, and it was handed ${describeGrant(grant)}`,
119
+ )
120
+ }
121
+ const bindings: WebhookBindings = Object.freeze({
122
+ async bind(capability: unknown, account: unknown): Promise<WebhookSecret> {
123
+ const scope = scopeOf(capability)
124
+ const recorded = accountOf(webhook.scheme, account)
86
125
  const id = uuidv7()
87
- const secret = generateSecret()
88
- await tables().createBinding({
89
- id,
90
- webhook,
91
- scope: { type: scope.dimension, value: scope.value },
92
- account,
93
- secret,
94
- createdBy: principal,
95
- createdAt: Date.now(),
96
- })
97
- return { id, url: joinPath(path, id), secret }
126
+ const secret = generateSecret(webhook.scheme)
127
+ await site.tables().createBinding(
128
+ { id, webhook: webhook.name, scope, account: recorded, secret, createdBy: site.principal, createdAt: Date.now() },
129
+ await site.session(),
130
+ )
131
+ return { id, url: joinPath(webhook.path, id), secret }
98
132
  },
99
- async unbind({ input, ctx }) {
100
- const { scope } = accessOf(ctx)
101
- const id = z.parse(webhookUnbindInput.shape.id, input['id'])
102
- if (!(await tables().removeBinding(webhook, id, { type: scope.dimension, value: scope.value }))) {
103
- throw new NotFoundError('Not found')
133
+ async setSecret(capability: unknown, id: unknown, secret: unknown): Promise<boolean> {
134
+ const scope = scopeOf(capability)
135
+ const issued = issuedSecret(webhook.scheme, secret)
136
+ if (typeof id !== 'string' || !z.uuid().safeParse(id).success) {
137
+ return false
104
138
  }
105
- return { id }
139
+ return await site.tables().replaceSecret(webhook.name, id, scope, issued, await site.session())
106
140
  },
141
+ async unbind(capability: unknown, id: unknown): Promise<boolean> {
142
+ const scope = scopeOf(capability)
143
+ if (typeof id !== 'string' || !z.uuid().safeParse(id).success) {
144
+ return false
145
+ }
146
+ return await site.tables().removeBinding(webhook.name, id, scope, await site.session())
147
+ },
148
+ })
149
+ return (name) => {
150
+ if (name !== webhook.name) {
151
+ throw new CapabilityRefusedError('undeclared_binding', `${site.site} does not declare binds: '${String(name)}'`)
152
+ }
153
+ return bindings
154
+ }
155
+ }
156
+
157
+ /** The account a binding records: GitHub's `installation:<id>` or `repository:<id>`, another provider's own label. */
158
+ function accountOf(scheme: WebhookScheme, account: unknown): string {
159
+ if (scheme === 'github-sha256') {
160
+ if (typeof account !== 'string' || !GITHUB_ACCOUNT.test(account)) {
161
+ throw new BadRequestError("A GitHub binding's account is installation:<id> or repository:<id>")
162
+ }
163
+ return account
164
+ }
165
+ const printable = (text: string) => [...text].every((char) => char >= ' ' && char !== '\u007f')
166
+ if (typeof account !== 'string' || account === '' || account.length > MAX_ACCOUNT_LENGTH || !printable(account)) {
167
+ throw new BadRequestError(`A binding's account is a text of 1 to ${MAX_ACCOUNT_LENGTH} printable characters`)
168
+ }
169
+ return account
170
+ }
171
+
172
+ /**
173
+ * A secret the provider issued, as the binding's verifier can use it: printable text of 1 to 512 characters; a Standard
174
+ * Webhooks one is `whsec_` and base64; a Basic password or bearer token never carries a platform key, which the proxy
175
+ * removes from every request (ADR-0022 rule 3).
176
+ */
177
+ function issuedSecret(scheme: WebhookScheme, secret: unknown): string {
178
+ const printable = (text: string) => [...text].every((char) => char > ' ' && char !== '\u007f')
179
+ if (typeof secret !== 'string' || secret === '' || secret.length > MAX_SECRET_LENGTH || !printable(secret)) {
180
+ throw new BadRequestError(`A webhook secret is a text of 1 to ${MAX_SECRET_LENGTH} printable characters without spaces`)
181
+ }
182
+ if (scheme === 'standard-webhooks' && standardKey(secret) === null) {
183
+ throw new BadRequestError('A Standard Webhooks secret is whsec_ and the key in base64')
184
+ }
185
+ if (typeof scheme === 'object' && !('hmac' in scheme) && secret.includes(API_KEY_PREFIX)) {
186
+ throw new BadRequestError(`A Basic password or bearer token cannot contain ${API_KEY_PREFIX}: the proxy removes a platform key from every request`)
107
187
  }
188
+ return secret
108
189
  }
@@ -1,12 +1,14 @@
1
- // A job's and a webhook's authority (ADR-0022 "Principal kinds"): Control reconciles one `px_` key per workload from
2
- // the approved snapshot and writes it as `FABRIKA_<KIND>_<NAME>_KEY`; IAM signs a token from it carrying the actions
3
- // the reconciled schema declares. The framework mints a capability only for an action that token carries, so the
1
+ // A job's, a webhook's and a public procedure's authority (ADR-0022 "Principal kinds"): Control reconciles one `px_` key
2
+ // per workload from the approved snapshot and writes it under `workloadKeyVariable`; IAM signs a token from it carrying
3
+ // the actions the reconciled schema declares. The framework mints a capability only for an action that token carries, so the
4
4
  // authority comes from IAM, not from the application's own copy of the contract.
5
5
 
6
6
  import {
7
7
  API_KEY_PREFIX,
8
8
  type ContractArtifact,
9
+ contractWorkloads,
9
10
  type MintFromKeyResult,
11
+ type OwnWorkloadKind,
10
12
  type PermissionEntry,
11
13
  TOKEN_REFRESH_SKEW_SECONDS,
12
14
  WORKLOAD_TOKEN_PATH,
@@ -15,7 +17,7 @@ import {
15
17
  import type { TokenVerifier } from '@fabrika/jwks'
16
18
  import { z } from 'zod/v4'
17
19
 
18
- export type WorkloadKindOf = 'job' | 'webhook'
20
+ export type WorkloadKindOf = OwnWorkloadKind
19
21
 
20
22
  /** Where a workload exchanges its key for a token: IAM's public `WORKLOAD_TOKEN_PATH`, or a stand-in in a test. */
21
23
  export interface WorkloadTokenSource {
@@ -124,6 +126,11 @@ export class WorkloadCredential {
124
126
  }
125
127
  }
126
128
 
129
+ /** The key a workload's credential is found under: `job:<name>`, `webhook:<name>`, `public:<endpoint id>`. */
130
+ export function workloadSite(kind: WorkloadKindOf, name: string): string {
131
+ return `${kind}:${name}`
132
+ }
133
+
127
134
  export interface WorkloadCredentialsInput {
128
135
  readonly artifact: ContractArtifact
129
136
  readonly framework: Readonly<Record<string, string>>
@@ -132,26 +139,21 @@ export interface WorkloadCredentialsInput {
132
139
  readonly verifier: TokenVerifier
133
140
  }
134
141
 
135
- /** `job:<name>` and `webhook:<name>` → the workload's credential, or why one cannot start to `problems`. */
142
+ /** `workloadSite(kind, name)` → the workload's credential, or why one cannot start to `problems`. */
136
143
  export function workloadCredentials(input: WorkloadCredentialsInput, problems: string[]): ReadonlyMap<string, WorkloadCredential> {
137
- const workloads: [WorkloadKindOf, string][] = [
138
- ...Object.keys(input.artifact.jobs).map((name): [WorkloadKindOf, string] => ['job', name]),
139
- ...Object.entries(input.artifact.mounts).flatMap((
140
- [name, mount],
141
- ): [WorkloadKindOf, string][] => (mount.kind === 'webhook' ? [['webhook', name]] : [])),
142
- ]
144
+ const workloads = contractWorkloads(input.artifact)
143
145
  const credentials = new Map<string, WorkloadCredential>()
144
146
  if (workloads.length > 0 && input.source === undefined) {
145
- problems.push('the contract declares jobs or webhooks, and the runtime adapter supplies no IAM token exchange for their keys')
147
+ problems.push('the contract declares jobs, webhooks or public procedures, and the runtime adapter supplies no IAM token exchange for their keys')
146
148
  }
147
- for (const [kind, name] of workloads) {
149
+ for (const { kind, name } of workloads) {
148
150
  const variable = workloadKeyVariable(kind, name)
149
151
  const key = input.framework[variable]?.trim() ?? ''
150
152
  if (!key.startsWith(API_KEY_PREFIX) || key.length <= API_KEY_PREFIX.length) {
151
153
  problems.push(`${variable} is not set to a px_ key; Control writes the credential of the ${kind} '${name}' into every deploy`)
152
154
  } else if (input.source !== undefined) {
153
155
  credentials.set(
154
- `${kind}:${name}`,
156
+ workloadSite(kind, name),
155
157
  new WorkloadCredential({ kind, name, key, audience: input.audience, source: input.source, verifier: input.verifier }),
156
158
  )
157
159
  }
package/src/index.ts CHANGED
@@ -30,6 +30,9 @@ export {
30
30
  // ─── Rows ────────────────────────────────────────────────────────────
31
31
  export { fromPage, fromRow, type RowInput } from './from-row.js'
32
32
 
33
+ // ─── Public procedures ───────────────────────────────────────────────
34
+ export { honeypot } from './honeypot.js'
35
+
33
36
  // ─── Auth contract ───────────────────────────────────────────────────
34
37
  export type { AuthContext, Scope } from '@fabrika/auth'
35
38