@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
package/src/testing/kit.ts
CHANGED
|
@@ -9,9 +9,12 @@ import {
|
|
|
9
9
|
CLIENT_ADDRESS_HEADER,
|
|
10
10
|
type ContractArtifact,
|
|
11
11
|
contractDigest,
|
|
12
|
+
contractWorkloads,
|
|
13
|
+
FABRIKA_APP_ORIGIN,
|
|
12
14
|
FABRIKA_CONTRACT_DIGEST,
|
|
13
15
|
FABRIKA_IAM_APP_ID,
|
|
14
16
|
FABRIKA_IAM_ISSUER,
|
|
17
|
+
FABRIKA_TOKEN_KEYS,
|
|
15
18
|
type IamMembershipRpc,
|
|
16
19
|
isNormalizedMailbox,
|
|
17
20
|
type Jwks,
|
|
@@ -32,18 +35,21 @@ import { type IamGateway, REQUEST_ID_HEADER, stripFabrikaCookies } from '@fabrik
|
|
|
32
35
|
import { exportJWK, generateKeyPair, SignJWT } from 'jose'
|
|
33
36
|
import { existsSync } from 'node:fs'
|
|
34
37
|
import { resolve } from 'node:path'
|
|
38
|
+
import type { z } from 'zod/v4'
|
|
35
39
|
import type { RpcFetch } from '../client/transport'
|
|
36
40
|
import { compileContract } from '../contract/compile'
|
|
37
41
|
import type { AnyContract, DimensionOf } from '../contract/define'
|
|
42
|
+
import type { TokenPurpose } from '../contract/token'
|
|
38
43
|
import { diskAssets } from '../disk-assets'
|
|
39
44
|
import type { RequestExecutionContext } from '../fetch'
|
|
40
|
-
import type { FILE_SWEEP_JOB } from '../files/
|
|
45
|
+
import type { FILE_SWEEP_JOB } from '../files/kinds'
|
|
41
46
|
import type { FILE_SCAN_JOB, FileScanner } from '../files/scan'
|
|
42
47
|
import { boot, type BootOptions, type ContractApp } from '../implement/boot'
|
|
43
48
|
import type { ChallengeFetch } from '../implement/challenge'
|
|
44
49
|
import { type ConnectFetch, connectVariables } from '../implement/connect'
|
|
45
50
|
import type { SpaAssets } from '../implement/dispatch'
|
|
46
51
|
import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
|
|
52
|
+
import { readTokenKeys, SignedTokens } from '../implement/tokens'
|
|
47
53
|
import type { WorkloadKindOf, WorkloadTokenSource } from '../implement/workload'
|
|
48
54
|
import type { MailKeyScope } from '../jobs/mail'
|
|
49
55
|
import type { HandlerErrorSink } from '../jobs/runner'
|
|
@@ -80,6 +86,11 @@ type MintRefusal = { readonly ok: false; readonly reason: 'disabled' | 'token_to
|
|
|
80
86
|
export interface TestKitOptions {
|
|
81
87
|
/** The application's own variables, as `deps` receives them. */
|
|
82
88
|
readonly env?: ApplicationEnvironment
|
|
89
|
+
/**
|
|
90
|
+
* The address the kit's proxy reports for every request, as the platform's proxy reports the visitor's
|
|
91
|
+
* (`X-Fabrika-Client-Ip`): what `ctx.client.ip` reads and a per-client bucket counts. `192.0.2.1` by default.
|
|
92
|
+
*/
|
|
93
|
+
readonly clientIp?: string
|
|
83
94
|
/**
|
|
84
95
|
* The application root an SPA mount's `dir` is relative to; the working directory when omitted. A mount whose built
|
|
85
96
|
* directory exists there is served from it as the deploy serves it: a file by its path, every other path by the
|
|
@@ -90,9 +101,9 @@ export interface TestKitOptions {
|
|
|
90
101
|
readonly assets?: SpaAssets
|
|
91
102
|
/** The connection a `scoped(tables)` database is bound to, as the runtime adapter supplies it to `boot`. */
|
|
92
103
|
readonly database?: Database
|
|
93
|
-
/** The object storage of the
|
|
104
|
+
/** The object storage of the stored files, as the runtime adapter supplies it to `boot`. */
|
|
94
105
|
readonly files?: FileObjectStore
|
|
95
|
-
/** The scanner of the scan job of `scan: true`
|
|
106
|
+
/** The scanner of the scan job of `scan: true` file kinds; without it, the job leaves every such file quarantined. */
|
|
96
107
|
readonly scanner?: FileScanner
|
|
97
108
|
/**
|
|
98
109
|
* IAM's membership operations in place of the kit's own stand-in: `boot` asks it as the adapter's transport, and the
|
|
@@ -104,7 +115,7 @@ export interface TestKitOptions {
|
|
|
104
115
|
* framework variables, and how a request reaches it — usually the owner kit's `fetch`.
|
|
105
116
|
*/
|
|
106
117
|
readonly connect?: Readonly<Record<string, TestConnection>>
|
|
107
|
-
/** What answers a
|
|
118
|
+
/** What answers a public procedure's challenge verification in place of the provider: a stand-in for its siteverify. */
|
|
108
119
|
readonly challengeFetch?: ChallengeFetch
|
|
109
120
|
/** What a job's handler threw, shown on the developer's own machine: `fabrika app dev` sets it, a test need not. */
|
|
110
121
|
readonly revealHandlerErrors?: HandlerErrorSink
|
|
@@ -116,7 +127,7 @@ export interface TestKitOptions {
|
|
|
116
127
|
*/
|
|
117
128
|
readonly mail?: MemoryEmailSender | 'none'
|
|
118
129
|
/**
|
|
119
|
-
* The origins the environment lists for the contract's `
|
|
130
|
+
* The origins the environment lists for the contract's endpoints that admit `origins: 'listed'`, as an operator sets them in
|
|
120
131
|
* Control; a page on one of them may call such an endpoint with a key. None when omitted.
|
|
121
132
|
*/
|
|
122
133
|
readonly crossOrigins?: readonly string[]
|
|
@@ -161,12 +172,12 @@ export interface TestKit<C extends AnyContract> {
|
|
|
161
172
|
acceptInvitation(invitationId: string, person?: LabelledTestCaller): LabelledTestCaller
|
|
162
173
|
/**
|
|
163
174
|
* Runs the cron job `name` once, as `fabrika job run <name>` does: under its lease, with the actions IAM signs for
|
|
164
|
-
* its key, its writes fenced. It first waits for the runs a
|
|
165
|
-
* `FILE_SCAN_JOB` runs the scan of `scan: true`
|
|
166
|
-
*
|
|
175
|
+
* its key, its writes fenced. It first waits for the runs a site's `ctx.kick` sent, so it never races them.
|
|
176
|
+
* `FILE_SCAN_JOB` runs the scan of `scan: true` file kinds with the kit's `scanner`, `FILE_SWEEP_JOB` the sweep of
|
|
177
|
+
* unreferenced files. Needs the application's data layer on Postgres.
|
|
167
178
|
*/
|
|
168
179
|
runJob(name: JobName<C> | typeof FILE_SCAN_JOB | typeof FILE_SWEEP_JOB, options?: JobRunOptions): Promise<JobRunResult>
|
|
169
|
-
/** Waits for the work requests left running: the runs a
|
|
180
|
+
/** Waits for the work requests left running: the runs a site's `ctx.kick` sent, a failure's report. */
|
|
170
181
|
drain(): Promise<void>
|
|
171
182
|
/**
|
|
172
183
|
* The stand-in for every job's transport: what reached it (`accepted`, `handoffs`) and its faults (`refuse`,
|
|
@@ -176,11 +187,24 @@ export interface TestKit<C extends AnyContract> {
|
|
|
176
187
|
readonly mail: MemoryEmailSender
|
|
177
188
|
/** Whose keys this kit's prefix separates: `mailKeyPrefix(kit.mailKeyScope)` is what a job's key gets in front. */
|
|
178
189
|
readonly mailKeyScope: MailKeyScope
|
|
190
|
+
/**
|
|
191
|
+
* A token of `purpose`, signed with the key the kit handed the application, as a site's `ctx.tokens.sign` signs it:
|
|
192
|
+
* what a test sends where an input carries `signedToken('<purpose>')`.
|
|
193
|
+
*/
|
|
194
|
+
signToken<const P extends TokenPurposeName<C>>(purpose: P, claims: TokenClaimsInput<C, P>, options: { readonly days: number }): string
|
|
179
195
|
}
|
|
180
196
|
|
|
181
197
|
/** The names of the contract's cron jobs. */
|
|
182
198
|
export type JobName<C> = C extends { readonly surface: { readonly jobs: infer J } } ? keyof J & string : never
|
|
183
199
|
|
|
200
|
+
type TokensOf<C> = C extends { readonly surface: { readonly tokens: infer T } } ? T : Record<never, never>
|
|
201
|
+
|
|
202
|
+
/** The token purposes of the contract's kit. */
|
|
203
|
+
export type TokenPurposeName<C> = keyof TokensOf<C> & string
|
|
204
|
+
|
|
205
|
+
/** What a token of purpose `P` carries, as a site passes it to `sign`. */
|
|
206
|
+
export type TokenClaimsInput<C, P extends string> = TokensOf<C> extends { readonly [K in P]: TokenPurpose<infer Claims> } ? z.input<Claims> : never
|
|
207
|
+
|
|
184
208
|
/** Each SPA mount from its built directory under `root` when one exists, as the deploy serves it; an empty page otherwise. */
|
|
185
209
|
function builtAssets(root: string): SpaAssets {
|
|
186
210
|
const disk = diskAssets(root)
|
|
@@ -190,13 +214,27 @@ function builtAssets(root: string): SpaAssets {
|
|
|
190
214
|
}
|
|
191
215
|
}
|
|
192
216
|
|
|
193
|
-
function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Request {
|
|
217
|
+
function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Promise<Request> {
|
|
194
218
|
if (!(input instanceof Request)) {
|
|
195
|
-
return new Request(new URL(input, origin), init)
|
|
219
|
+
return withLength(new Request(new URL(input, origin), init), init?.body)
|
|
196
220
|
}
|
|
197
221
|
// Never `new Request(request, …)`: Bun would buffer the body the application must meet as a stream.
|
|
198
222
|
const base = { method: input.method, headers: input.headers, body: input.body, signal: input.signal, redirect: input.redirect }
|
|
199
|
-
return init === undefined ? input : new Request(input.url, { ...base, ...init })
|
|
223
|
+
return init === undefined ? Promise.resolve(input) : withLength(new Request(input.url, { ...base, ...init }), init.body)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `request` as a browser sends a body it knows the length of (a string, a form, bytes): with `Content-Length`, which a
|
|
228
|
+
* `bytesPerHour` budget charges. A stream goes without one, as `fetch` sends it chunked, and is charged the body limit.
|
|
229
|
+
*/
|
|
230
|
+
async function withLength(request: Request, body: BodyInit | null | undefined): Promise<Request> {
|
|
231
|
+
if (body === undefined || body === null || body instanceof ReadableStream || request.headers.has('Content-Length')) {
|
|
232
|
+
return request
|
|
233
|
+
}
|
|
234
|
+
const headers = new Headers(request.headers)
|
|
235
|
+
const bytes = await request.arrayBuffer()
|
|
236
|
+
headers.set('Content-Length', String(bytes.byteLength))
|
|
237
|
+
return new Request(request.url, { method: request.method, headers, body: bytes, signal: request.signal, redirect: request.redirect })
|
|
200
238
|
}
|
|
201
239
|
|
|
202
240
|
/**
|
|
@@ -207,6 +245,9 @@ function withHeaders(request: Request, headers: Headers): Request {
|
|
|
207
245
|
return new Request(request.url, { method: request.method, headers, body: request.body, signal: request.signal, redirect: request.redirect })
|
|
208
246
|
}
|
|
209
247
|
|
|
248
|
+
/** The client every request of a kit comes from, unless `clientIp` names another: TEST-NET-1 (RFC 5737). */
|
|
249
|
+
const TEST_CLIENT_IP = '192.0.2.1'
|
|
250
|
+
|
|
210
251
|
/** Every header only the proxy may assert, deleted before the decision as Caddy and the proxy Worker delete them. */
|
|
211
252
|
function stripClientAsserted(headers: Headers): void {
|
|
212
253
|
for (const header of [...headers.keys()]) {
|
|
@@ -283,7 +324,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
283
324
|
connectVariablesOf[variables.key] = connection.key
|
|
284
325
|
connectBindings[variables.binding] = connection.fetch
|
|
285
326
|
}
|
|
286
|
-
// The kit stands in for Control too: every
|
|
327
|
+
// The kit stands in for Control too: every workload gets a key, which its stand-in IAM signs the declared actions for.
|
|
287
328
|
const workloadVariables: Record<string, string> = {}
|
|
288
329
|
const workloadsByKey = new Map<string, { readonly kind: WorkloadKindOf; readonly name: string; readonly actions: readonly string[] }>()
|
|
289
330
|
// A job's key and the tokens signed for it are its credential at the stand-in IAM's link operations, as at IAM's.
|
|
@@ -292,13 +333,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
292
333
|
const job = Object.hasOwn(artifact.jobs, name) ? artifact.jobs[name] : undefined
|
|
293
334
|
return job === undefined ? undefined : { id: `job-${name}`, actions: job.actions, issues: artifact.declared?.[`job:${name}`]?.issues ?? null }
|
|
294
335
|
}
|
|
295
|
-
const
|
|
296
|
-
...Object.entries(artifact.jobs).map(([workload, job]) => ({ kind: 'job' as const, name: workload, actions: job.actions })),
|
|
297
|
-
...Object.entries(artifact.mounts).flatMap(([workload, mount]) =>
|
|
298
|
-
mount.kind === 'webhook' ? [{ kind: 'webhook' as const, name: workload, actions: mount.actions }] : []
|
|
299
|
-
),
|
|
300
|
-
]
|
|
301
|
-
for (const workload of declaredWorkloads) {
|
|
336
|
+
for (const workload of contractWorkloads(artifact)) {
|
|
302
337
|
const key = `${API_KEY_PREFIX}test_${crypto.randomUUID().replaceAll('-', '')}`
|
|
303
338
|
workloadVariables[workloadKeyVariable(workload.kind, workload.name)] = key
|
|
304
339
|
workloadsByKey.set(key, workload)
|
|
@@ -347,6 +382,19 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
347
382
|
const membership = options.membership ?? standIn?.rpc
|
|
348
383
|
const mail = options.mail === undefined || options.mail === 'none' ? new MemoryEmailSender() : options.mail
|
|
349
384
|
const mailKeyScope: MailKeyScope = Object.freeze({ issuer: TEST_ISSUER, appId })
|
|
385
|
+
// The kit stands in for Control's token key too: one random key, which signs and verifies.
|
|
386
|
+
const tokenKey = `${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}:${
|
|
387
|
+
Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
|
|
388
|
+
}`
|
|
389
|
+
const tokenVariables: Record<string, string> = artifact.tokens === undefined ? {} : { [FABRIKA_TOKEN_KEYS]: tokenKey }
|
|
390
|
+
const kitTokens = new SignedTokens(
|
|
391
|
+
new Map(
|
|
392
|
+
Object.entries(app.contract.surface.tokens ?? {}).map(([purpose, declared]) => [purpose, { claims: declared.claims, maxDays: declared.maxDays }]),
|
|
393
|
+
),
|
|
394
|
+
readTokenKeys({ [FABRIKA_TOKEN_KEYS]: tokenKey }, { ...artifact, tokens: artifact.tokens ?? {} }, []),
|
|
395
|
+
)
|
|
396
|
+
const signToken = (purpose: string, claims: unknown, signOptions: { readonly days: number }): string =>
|
|
397
|
+
kitTokens.context('the test kit', [purpose]).sign(purpose, claims, signOptions)
|
|
350
398
|
const application: ContractApp = boot(app, {
|
|
351
399
|
environment: {
|
|
352
400
|
// The deploy writes the approved contract's digest; the kit's contract is the approved one by definition.
|
|
@@ -354,8 +402,11 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
354
402
|
[FABRIKA_IAM_APP_ID]: appId,
|
|
355
403
|
[FABRIKA_IAM_ISSUER]: TEST_ISSUER,
|
|
356
404
|
[FABRIKA_CONTRACT_DIGEST]: contractDigest(artifact),
|
|
405
|
+
// The deploy writes the environment's public origin; the kit's is its own.
|
|
406
|
+
[FABRIKA_APP_ORIGIN]: origin,
|
|
357
407
|
...connectVariablesOf,
|
|
358
408
|
...workloadVariables,
|
|
409
|
+
...tokenVariables,
|
|
359
410
|
},
|
|
360
411
|
application: options.env ?? {},
|
|
361
412
|
},
|
|
@@ -371,6 +422,8 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
371
422
|
connectBindings,
|
|
372
423
|
workloadTokens,
|
|
373
424
|
mailStandIn: options.mail === 'none' ? 'none' : mail,
|
|
425
|
+
// The kit's proxy sets `X-Fabrika-Client-Ip` from the request it was handed, as the Worker sets it from Cloudflare's.
|
|
426
|
+
clientAddress: 'vouched',
|
|
374
427
|
})
|
|
375
428
|
|
|
376
429
|
let issued = 0
|
|
@@ -543,7 +596,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
543
596
|
...(options.crossOrigins === undefined ? {} : { crossOrigins: options.crossOrigins }),
|
|
544
597
|
})
|
|
545
598
|
|
|
546
|
-
// What requests leave running: a job a
|
|
599
|
+
// What requests leave running: a job a site's `ctx.kick` sent, or a failure's report.
|
|
547
600
|
const background = new Set<Promise<void>>()
|
|
548
601
|
const exec: RequestExecutionContext = {
|
|
549
602
|
waitUntil(promise) {
|
|
@@ -554,7 +607,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
554
607
|
}
|
|
555
608
|
|
|
556
609
|
const kitFetch: RpcFetch = async (input, init) => {
|
|
557
|
-
const request = toRequest(origin, input, init)
|
|
610
|
+
const request = await toRequest(origin, input, init)
|
|
558
611
|
if (new URL(request.url).origin !== origin) {
|
|
559
612
|
throw new TypeError(`the test kit serves only ${origin}, not ${new URL(request.url).origin}`)
|
|
560
613
|
}
|
|
@@ -572,6 +625,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
572
625
|
for (const [header, value] of outcome.asserted) {
|
|
573
626
|
headers.set(header, value)
|
|
574
627
|
}
|
|
628
|
+
headers.set(CLIENT_ADDRESS_HEADER, options.clientIp ?? TEST_CLIENT_IP)
|
|
575
629
|
const answer = await application.fetch(withHeaders(request, headers), exec)
|
|
576
630
|
const decorated = new Headers(answer.headers)
|
|
577
631
|
outcome.decorate(decorated)
|
|
@@ -582,8 +636,8 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
582
636
|
if (caller !== undefined && caller.cookie === null) {
|
|
583
637
|
throw new Error(`a browser carries a session; pass the ${caller.kind}'s key to createClient instead`)
|
|
584
638
|
}
|
|
585
|
-
return (input, init) => {
|
|
586
|
-
const request = toRequest(origin, input, init)
|
|
639
|
+
return async (input, init) => {
|
|
640
|
+
const request = await toRequest(origin, input, init)
|
|
587
641
|
// Bun's Request does not keep its credentials mode, so `init` is the only reliable reading of it.
|
|
588
642
|
const credentials = init?.credentials ?? (input instanceof Request ? input.credentials : undefined)
|
|
589
643
|
const headers = new Headers(request.headers)
|
|
@@ -647,6 +701,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
647
701
|
acceptInvitation,
|
|
648
702
|
mail,
|
|
649
703
|
mailKeyScope,
|
|
704
|
+
signToken,
|
|
650
705
|
}),
|
|
651
706
|
standIn,
|
|
652
707
|
callerOf: (value) => [...memberships.keys()].find((caller) => caller === value),
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
// application's handlers and stores never run. Each attempt seeds two fresh rows that share no scope value and asks as
|
|
6
6
|
// a caller holding every action of the table in the scope of one of them.
|
|
7
7
|
|
|
8
|
+
import { tableActions, type TablePolicyArtifact } from '@fabrika/auth-core'
|
|
8
9
|
import { scoped, type Tables } from '@fabrika/db'
|
|
9
10
|
import { tablePolicies } from '@fabrika/db/tables'
|
|
10
|
-
import { type IsolationWorlds, type SampleValue, tableProbe, type TestDatabase
|
|
11
|
+
import { type IsolationWorlds, type SampleValue, tableProbe, type TestDatabase } from '@fabrika/db/testing'
|
|
12
|
+
import { MemoryFileObjects } from '@fabrika/platform/testing'
|
|
11
13
|
import { z } from 'zod/v4'
|
|
12
14
|
import { createClient } from '../client/contract'
|
|
13
|
-
import { type AnyContract, defineContract, type
|
|
15
|
+
import { type AnyContract, defineContract, type FileKind } from '../contract/define'
|
|
14
16
|
import { implement } from '../implement/implement'
|
|
15
17
|
import type { TestGrant } from './caller'
|
|
16
18
|
import { testKit } from './kit'
|
|
@@ -141,6 +143,20 @@ interface ProbeActions {
|
|
|
141
143
|
readonly insert: string | null
|
|
142
144
|
readonly update: string | null
|
|
143
145
|
readonly delete: string | null
|
|
146
|
+
/** Every read action of the table: an action besides them is another action. */
|
|
147
|
+
readonly reads: readonly string[]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The actions a probe holds: the first of each operation, as the generated cases name them. */
|
|
151
|
+
function probeActions(actions: TablePolicyArtifact['actions']): ProbeActions {
|
|
152
|
+
const reads = tableActions(actions.read)
|
|
153
|
+
return {
|
|
154
|
+
read: reads[0] ?? '',
|
|
155
|
+
insert: tableActions(actions.insert)[0] ?? null,
|
|
156
|
+
update: tableActions(actions.update)[0] ?? null,
|
|
157
|
+
delete: tableActions(actions.delete)[0] ?? null,
|
|
158
|
+
reads,
|
|
159
|
+
}
|
|
144
160
|
}
|
|
145
161
|
|
|
146
162
|
function isCapabilityError(error: unknown): boolean {
|
|
@@ -155,14 +171,20 @@ async function attempt<T extends object>(run: () => Promise<T>, failed: (status:
|
|
|
155
171
|
}
|
|
156
172
|
}
|
|
157
173
|
|
|
174
|
+
/** The file kinds `contract` declares: each probe declares them too, so the file columns of the tables name declared kinds. */
|
|
175
|
+
function fileKindsOf(contract: AnyContract): Readonly<Record<string, FileKind>> {
|
|
176
|
+
return Object.fromEntries(Object.entries(contract.surface.files ?? {}).filter((entry): entry is [string, FileKind] => entry[1].kind === 'file-kind'))
|
|
177
|
+
}
|
|
178
|
+
|
|
158
179
|
/** A write's outcome: 200 when it ran, the status otherwise. */
|
|
159
180
|
async function wrote(run: () => Promise<unknown>): Promise<IsolationOutcome> {
|
|
160
181
|
return { status: await statusOf(run()) }
|
|
161
182
|
}
|
|
162
183
|
|
|
163
|
-
function dimensionProbe(
|
|
184
|
+
function dimensionProbe(contract: AnyContract, dimension: string, list: string, actions: ProbeActions) {
|
|
164
185
|
const { read } = actions
|
|
165
|
-
return defineContract(vocabulary, (c) => ({
|
|
186
|
+
return defineContract(contract.vocabulary, (c) => ({
|
|
187
|
+
files: fileKindsOf(contract),
|
|
166
188
|
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
167
189
|
get: c.query({ input: ById, output: Columns, access: { action: read, on: dimension, from: 'id' } }),
|
|
168
190
|
list: c.query({ output: Keys, access: { action: read, over: list } }),
|
|
@@ -176,9 +198,10 @@ function dimensionProbe(vocabulary: Vocabulary<string, string, string>, dimensio
|
|
|
176
198
|
}))
|
|
177
199
|
}
|
|
178
200
|
|
|
179
|
-
function childProbe(
|
|
201
|
+
function childProbe(contract: AnyContract, parent: string, list: string, actions: ProbeActions) {
|
|
180
202
|
const { read } = actions
|
|
181
|
-
return defineContract(vocabulary, (c) => ({
|
|
203
|
+
return defineContract(contract.vocabulary, (c) => ({
|
|
204
|
+
files: fileKindsOf(contract),
|
|
182
205
|
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
183
206
|
get: c.query({ input: ChildByKey, output: Columns, access: { action: read, on: parent, from: 'parent' } }),
|
|
184
207
|
list: c.query({ output: Keys, access: { action: read, over: list } }),
|
|
@@ -195,12 +218,13 @@ function childProbe(vocabulary: Vocabulary<string, string, string>, parent: stri
|
|
|
195
218
|
|
|
196
219
|
/** An action the table names besides its read action, or `null` when it names none. */
|
|
197
220
|
function otherAction(actions: ProbeActions): string | null {
|
|
198
|
-
return [actions.insert, actions.update, actions.delete].find((action) => action !== null &&
|
|
221
|
+
return [actions.insert, actions.update, actions.delete].find((action) => action !== null && !actions.reads.includes(action)) ?? null
|
|
199
222
|
}
|
|
200
223
|
|
|
201
|
-
function appProbe(
|
|
224
|
+
function appProbe(contract: AnyContract, actions: ProbeActions) {
|
|
202
225
|
const { read } = actions
|
|
203
|
-
return defineContract(vocabulary, (c) => ({
|
|
226
|
+
return defineContract(contract.vocabulary, (c) => ({
|
|
227
|
+
files: fileKindsOf(contract),
|
|
204
228
|
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
205
229
|
get: c.query({ input: ById, output: Columns, access: { action: read, app: true } }),
|
|
206
230
|
list: c.query({ output: Keys, access: { action: read, app: true } }),
|
|
@@ -214,8 +238,9 @@ function appProbe(vocabulary: Vocabulary<string, string, string>, actions: Probe
|
|
|
214
238
|
}
|
|
215
239
|
|
|
216
240
|
/** Scoped sites over an application table: `over` the vocabulary's first dimension, of its read action and of another. */
|
|
217
|
-
function scopedAppProbe(
|
|
218
|
-
return defineContract(vocabulary, (c) => ({
|
|
241
|
+
function scopedAppProbe(contract: AnyContract, actions: ProbeActions, dimension: string) {
|
|
242
|
+
return defineContract(contract.vocabulary, (c) => ({
|
|
243
|
+
files: fileKindsOf(contract),
|
|
219
244
|
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
220
245
|
list: c.query({ output: Keys, access: { action: actions.read, over: dimension } }),
|
|
221
246
|
listWithOther: c.query({ output: Refused, access: { action: otherAction(actions) ?? actions.read, over: dimension } }),
|
|
@@ -241,8 +266,8 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
241
266
|
if (scope.mode !== 'app') {
|
|
242
267
|
throw new Error(`${table} is a ${scope.mode} table; policyIsolationKit probes a dimension or child table`)
|
|
243
268
|
}
|
|
244
|
-
const actions
|
|
245
|
-
const layer = scoped(
|
|
269
|
+
const actions = probeActions(policy.actions)
|
|
270
|
+
const layer = scoped(tables)
|
|
246
271
|
const probe = tableProbe(layer, table)
|
|
247
272
|
const expectVersion = policy.version === null ? undefined : 1
|
|
248
273
|
const staged = new Map<string, Readonly<Record<string, SampleValue>>>()
|
|
@@ -275,7 +300,7 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
275
300
|
throw error
|
|
276
301
|
}
|
|
277
302
|
}
|
|
278
|
-
const probeContract = appProbe(contract
|
|
303
|
+
const probeContract = appProbe(contract, actions)
|
|
279
304
|
const kit = testKit(
|
|
280
305
|
implement(probeContract, {
|
|
281
306
|
database: layer,
|
|
@@ -301,7 +326,7 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
301
326
|
},
|
|
302
327
|
}),
|
|
303
328
|
}),
|
|
304
|
-
{ database: postgres.database },
|
|
329
|
+
{ database: postgres.database, files: new MemoryFileObjects() },
|
|
305
330
|
)
|
|
306
331
|
const held = [...new Set([actions.read, actions.insert, actions.update, actions.delete].filter((action) => action !== null))]
|
|
307
332
|
const dimension = scopeTypeOf(contract.vocabulary.dimensions)
|
|
@@ -310,7 +335,7 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
310
335
|
if (!Object.hasOwn(contract.vocabulary.dimensions, dimension)) {
|
|
311
336
|
throw new Error(`the vocabulary declares no dimension, so no scoped site reads ${table}`)
|
|
312
337
|
}
|
|
313
|
-
scopedSites ??= scopedAppKit(scopedAppProbe(contract
|
|
338
|
+
scopedSites ??= scopedAppKit(scopedAppProbe(contract, actions, dimension), tables, table, scope.key, postgres)
|
|
314
339
|
return scopedSites.as(action, dimension)
|
|
315
340
|
}
|
|
316
341
|
const grantsOf = (caller: AppCaller): TestGrant<AnyContract>[] =>
|
|
@@ -389,8 +414,9 @@ export function appPolicyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
389
414
|
}
|
|
390
415
|
|
|
391
416
|
/** `app` endpoints of one action over a dimension or child table: what its `appRead` and `appCount` reach. */
|
|
392
|
-
function appReachProbe(
|
|
393
|
-
return defineContract(vocabulary, (c) => ({
|
|
417
|
+
function appReachProbe(contract: AnyContract, action: string) {
|
|
418
|
+
return defineContract(contract.vocabulary, (c) => ({
|
|
419
|
+
files: fileKindsOf(contract),
|
|
394
420
|
probe: c.rpc('/probe', { callers: ['human'] }, {
|
|
395
421
|
list: c.query({ output: Keys, access: { action, app: true } }),
|
|
396
422
|
count: c.query({ output: Count, access: { action, app: true } }),
|
|
@@ -430,8 +456,8 @@ function appReachKit<DB, P>(
|
|
|
430
456
|
}
|
|
431
457
|
const kits = new Map<string, ReturnType<typeof appReachClient>>()
|
|
432
458
|
const appReachClient = (action: string) => {
|
|
433
|
-
const probeContract = appReachProbe(contract
|
|
434
|
-
const layer = scoped(
|
|
459
|
+
const probeContract = appReachProbe(contract, action)
|
|
460
|
+
const layer = scoped(tables)
|
|
435
461
|
const probe = tableProbe(layer, table)
|
|
436
462
|
const kit = testKit(
|
|
437
463
|
implement(probeContract, {
|
|
@@ -447,7 +473,7 @@ function appReachKit<DB, P>(
|
|
|
447
473
|
},
|
|
448
474
|
}),
|
|
449
475
|
}),
|
|
450
|
-
{ database: postgres.database },
|
|
476
|
+
{ database: postgres.database, files: new MemoryFileObjects() },
|
|
451
477
|
)
|
|
452
478
|
return createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller([{ action }])) })
|
|
453
479
|
}
|
|
@@ -505,7 +531,7 @@ function scopedAppKit<DB, P>(
|
|
|
505
531
|
key: string,
|
|
506
532
|
postgres: TestDatabase,
|
|
507
533
|
) {
|
|
508
|
-
const layer = scoped(
|
|
534
|
+
const layer = scoped(tables)
|
|
509
535
|
const probe = tableProbe(layer, table)
|
|
510
536
|
const kit = testKit(
|
|
511
537
|
implement(probeContract, {
|
|
@@ -528,7 +554,7 @@ function scopedAppKit<DB, P>(
|
|
|
528
554
|
},
|
|
529
555
|
}),
|
|
530
556
|
}),
|
|
531
|
-
{ database: postgres.database },
|
|
557
|
+
{ database: postgres.database, files: new MemoryFileObjects() },
|
|
532
558
|
)
|
|
533
559
|
return {
|
|
534
560
|
as: (action: string, dimension: string) =>
|
|
@@ -557,9 +583,9 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
557
583
|
if (scope.mode !== 'dimension' && scope.mode !== 'child') {
|
|
558
584
|
throw new Error(`${table} is a ${scope.mode} table; no store reaches it, so it has no isolation tests`)
|
|
559
585
|
}
|
|
560
|
-
const actions
|
|
586
|
+
const actions = probeActions(policy.actions)
|
|
561
587
|
// The probe serves no form, so it compiles against the policies without the forms' inboxes.
|
|
562
|
-
const layer = scoped(
|
|
588
|
+
const layer = scoped(tables)
|
|
563
589
|
const probe = tableProbe(layer, table)
|
|
564
590
|
const expectVersion = policy.version === null ? undefined : 1
|
|
565
591
|
const pending = new Map<string, Readonly<Record<string, SampleValue>>>()
|
|
@@ -603,7 +629,7 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
603
629
|
if (scope.mode === 'dimension') {
|
|
604
630
|
const dimension = scope.dimension
|
|
605
631
|
const list = scope.container?.dimension ?? dimension
|
|
606
|
-
const probeContract = dimensionProbe(contract
|
|
632
|
+
const probeContract = dimensionProbe(contract, dimension, list, actions)
|
|
607
633
|
const kit = testKit(
|
|
608
634
|
implement(probeContract, {
|
|
609
635
|
database: layer,
|
|
@@ -630,7 +656,7 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
630
656
|
},
|
|
631
657
|
}),
|
|
632
658
|
}),
|
|
633
|
-
{ database: postgres.database },
|
|
659
|
+
{ database: postgres.database, files: new MemoryFileObjects() },
|
|
634
660
|
)
|
|
635
661
|
const session = (worlds: IsolationWorlds) => createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller(grantsIn(worlds))) })
|
|
636
662
|
const keyOf = (worlds: IsolationWorlds, row: ChildIsolationRow): string => {
|
|
@@ -705,7 +731,7 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
705
731
|
}
|
|
706
732
|
const parent = parentPolicy.scope.dimension
|
|
707
733
|
const list = parentPolicy.scope.container?.dimension ?? parent
|
|
708
|
-
const probeContract = childProbe(contract
|
|
734
|
+
const probeContract = childProbe(contract, parent, list, actions)
|
|
709
735
|
const kit = testKit(
|
|
710
736
|
implement(probeContract, {
|
|
711
737
|
database: layer,
|
|
@@ -733,7 +759,7 @@ export function policyIsolationKit<C extends AnyContract, DB, P>(
|
|
|
733
759
|
},
|
|
734
760
|
}),
|
|
735
761
|
}),
|
|
736
|
-
{ database: postgres.database },
|
|
762
|
+
{ database: postgres.database, files: new MemoryFileObjects() },
|
|
737
763
|
)
|
|
738
764
|
const session = (worlds: IsolationWorlds) => createClient(probeContract, 'probe', { fetch: kit.browser(kit.testCaller(grantsIn(worlds))) })
|
|
739
765
|
/** The parent and key a request names: a crossed row is the other key under the caller's own parent. */
|
package/src/testing/proxy.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createVerifyService,
|
|
8
8
|
DEFAULT_VERIFY_PATH,
|
|
9
9
|
type DelegationIssuer,
|
|
10
|
+
FORWARD_AUTHORIZATION_HEADER,
|
|
10
11
|
FORWARDED_HOST_HEADER,
|
|
11
12
|
FORWARDED_METHOD_HEADER,
|
|
12
13
|
FORWARDED_PROTO_HEADER,
|
|
@@ -26,7 +27,10 @@ export type ProxyOutcome =
|
|
|
26
27
|
readonly outcome: 'forward'
|
|
27
28
|
/** Every header only the proxy may assert, with the value the decision gave it. */
|
|
28
29
|
readonly asserted: ReadonlyMap<string, string>
|
|
29
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Whether the request's own `Authorization` reaches the application: not on an anonymous endpoint, except a webhook
|
|
32
|
+
* row the decision marked to pass it on.
|
|
33
|
+
*/
|
|
30
34
|
readonly keepsAuthorization: boolean
|
|
31
35
|
/** What the application's answer is given for an admitted cross-origin request, over its own headers. */
|
|
32
36
|
readonly decorate: (headers: Headers) => void
|
|
@@ -104,7 +108,7 @@ export function endpointProxy(options: EndpointProxyOptions): TestProxy {
|
|
|
104
108
|
return {
|
|
105
109
|
outcome: 'forward',
|
|
106
110
|
asserted,
|
|
107
|
-
keepsAuthorization: asserted.has(PROXY_TOKEN_HEADER),
|
|
111
|
+
keepsAuthorization: asserted.has(PROXY_TOKEN_HEADER) || verification.headers.get(FORWARD_AUTHORIZATION_HEADER) === '1',
|
|
108
112
|
decorate: (answer) => {
|
|
109
113
|
if (allowOrigin !== null) applyCrossOriginHeaders(answer, crossOriginHeaders(allowOrigin))
|
|
110
114
|
},
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A small file the testing kits write: the leading bytes `files/sniff.ts` accepts for its type, then a text marker.
|
|
2
|
+
|
|
3
|
+
const randomHex = (): string => crypto.randomUUID().replaceAll('-', '').slice(0, 8)
|
|
4
|
+
|
|
5
|
+
const ascii = (text: string): number[] => [...text].map((char) => char.charCodeAt(0))
|
|
6
|
+
const ZIP_HEAD = [0x50, 0x4b, 0x03, 0x04]
|
|
7
|
+
|
|
8
|
+
/** The leading bytes `files/sniff.ts` accepts for each binary type it verifies; a text type needs none. */
|
|
9
|
+
const SAMPLE_HEADS: Readonly<Record<string, readonly number[]>> = {
|
|
10
|
+
'image/png': [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
11
|
+
'image/jpeg': [0xff, 0xd8, 0xff, 0xe0],
|
|
12
|
+
'image/gif': ascii('GIF89a'),
|
|
13
|
+
'image/webp': [...ascii('RIFF'), 0, 0, 0, 0, ...ascii('WEBP')],
|
|
14
|
+
'application/pdf': ascii('%PDF-1.7\n'),
|
|
15
|
+
'application/gzip': [0x1f, 0x8b, 0x08],
|
|
16
|
+
'application/zip': ZIP_HEAD,
|
|
17
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_HEAD,
|
|
18
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_HEAD,
|
|
19
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_HEAD,
|
|
20
|
+
}
|
|
21
|
+
const TEXT_TYPES: ReadonlySet<string> = new Set(['text/plain', 'text/csv', 'text/markdown', 'application/json', 'image/svg+xml'])
|
|
22
|
+
|
|
23
|
+
/** A file of the first of `types` the kit can write; `name` (a collection or a kind) names it in the error when none is. */
|
|
24
|
+
export function sampleFile(name: string, types: readonly string[]): { readonly type: string; readonly bytes: Uint8Array<ArrayBuffer> } {
|
|
25
|
+
for (const type of types) {
|
|
26
|
+
const head = SAMPLE_HEADS[type] ?? (TEXT_TYPES.has(type) ? [] : undefined)
|
|
27
|
+
if (head !== undefined) return { type, bytes: new Uint8Array([...head, ...ascii(`fabrika test file ${randomHex()}`)]) }
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`files.${name}: none of its types is one the testing kit can write a file of`)
|
|
30
|
+
}
|