@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,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
|
+
}
|
package/src/implement/webhook.ts
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
//
|
|
2
|
-
// creates a binding through
|
|
3
|
-
// keeps the binding in `fabrika_webhook_bindings`. A delivery is checked in a fixed order —
|
|
4
|
-
//
|
|
5
|
-
// bound
|
|
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
|
|
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
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
/**
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
/**
|
|
81
|
-
export function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
|
|
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
|
|
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
|
|
2
|
-
// the approved snapshot and writes it
|
|
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 =
|
|
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
|
-
/** `
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|