@fabrika/app 0.6.0 → 0.8.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 +10 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +58 -36
- package/skill/references/dev.md +11 -9
- package/skill/references/endpoint.md +154 -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 +450 -255
- package/skill/references/spa.md +6 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +14 -8
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +221 -79
- package/src/client/index.ts +3 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +168 -14
- package/src/contract/build.ts +1282 -619
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +79 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +142 -60
- package/src/contract/define.ts +92 -121
- 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 +210 -157
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +6 -5
- package/src/implement/capability.ts +78 -2
- package/src/implement/challenge.ts +203 -47
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +831 -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 +167 -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/challenge.ts +35 -1
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +168 -128
- package/src/testing/kit.ts +90 -25
- 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,22 @@ 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'
|
|
39
|
+
import { challengeUrl } from '../client/contract'
|
|
35
40
|
import type { RpcFetch } from '../client/transport'
|
|
36
41
|
import { compileContract } from '../contract/compile'
|
|
37
42
|
import type { AnyContract, DimensionOf } from '../contract/define'
|
|
43
|
+
import type { TokenPurpose } from '../contract/token'
|
|
38
44
|
import { diskAssets } from '../disk-assets'
|
|
39
45
|
import type { RequestExecutionContext } from '../fetch'
|
|
40
|
-
import type { FILE_SWEEP_JOB } from '../files/
|
|
46
|
+
import type { FILE_SWEEP_JOB } from '../files/kinds'
|
|
41
47
|
import type { FILE_SCAN_JOB, FileScanner } from '../files/scan'
|
|
42
48
|
import { boot, type BootOptions, type ContractApp } from '../implement/boot'
|
|
43
|
-
import type
|
|
49
|
+
import { type ChallengeFetch, MINIMAL_CHALLENGE_WORK } from '../implement/challenge'
|
|
44
50
|
import { type ConnectFetch, connectVariables } from '../implement/connect'
|
|
45
51
|
import type { SpaAssets } from '../implement/dispatch'
|
|
46
52
|
import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
|
|
53
|
+
import { readTokenKeys, SignedTokens } from '../implement/tokens'
|
|
47
54
|
import type { WorkloadKindOf, WorkloadTokenSource } from '../implement/workload'
|
|
48
55
|
import type { MailKeyScope } from '../jobs/mail'
|
|
49
56
|
import type { HandlerErrorSink } from '../jobs/runner'
|
|
@@ -58,6 +65,7 @@ import {
|
|
|
58
65
|
type TestGrant,
|
|
59
66
|
type TestScope,
|
|
60
67
|
} from './caller'
|
|
68
|
+
import { solveChallengeAt } from './challenge'
|
|
61
69
|
import {
|
|
62
70
|
credentialCallers,
|
|
63
71
|
type JobCredential,
|
|
@@ -80,6 +88,11 @@ type MintRefusal = { readonly ok: false; readonly reason: 'disabled' | 'token_to
|
|
|
80
88
|
export interface TestKitOptions {
|
|
81
89
|
/** The application's own variables, as `deps` receives them. */
|
|
82
90
|
readonly env?: ApplicationEnvironment
|
|
91
|
+
/**
|
|
92
|
+
* The address the kit's proxy reports for every request, as the platform's proxy reports the visitor's
|
|
93
|
+
* (`X-Fabrika-Client-Ip`): what `ctx.client.ip` reads and a per-client bucket counts. `192.0.2.1` by default.
|
|
94
|
+
*/
|
|
95
|
+
readonly clientIp?: string
|
|
83
96
|
/**
|
|
84
97
|
* The application root an SPA mount's `dir` is relative to; the working directory when omitted. A mount whose built
|
|
85
98
|
* directory exists there is served from it as the deploy serves it: a file by its path, every other path by the
|
|
@@ -90,9 +103,9 @@ export interface TestKitOptions {
|
|
|
90
103
|
readonly assets?: SpaAssets
|
|
91
104
|
/** The connection a `scoped(tables)` database is bound to, as the runtime adapter supplies it to `boot`. */
|
|
92
105
|
readonly database?: Database
|
|
93
|
-
/** The object storage of the
|
|
106
|
+
/** The object storage of the stored files, as the runtime adapter supplies it to `boot`. */
|
|
94
107
|
readonly files?: FileObjectStore
|
|
95
|
-
/** The scanner of the scan job of `scan: true`
|
|
108
|
+
/** The scanner of the scan job of `scan: true` file kinds; without it, the job leaves every such file quarantined. */
|
|
96
109
|
readonly scanner?: FileScanner
|
|
97
110
|
/**
|
|
98
111
|
* 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 +117,7 @@ export interface TestKitOptions {
|
|
|
104
117
|
* framework variables, and how a request reaches it — usually the owner kit's `fetch`.
|
|
105
118
|
*/
|
|
106
119
|
readonly connect?: Readonly<Record<string, TestConnection>>
|
|
107
|
-
/** What answers a
|
|
120
|
+
/** What answers a public procedure's challenge verification in place of the provider: a stand-in for its siteverify. */
|
|
108
121
|
readonly challengeFetch?: ChallengeFetch
|
|
109
122
|
/** What a job's handler threw, shown on the developer's own machine: `fabrika app dev` sets it, a test need not. */
|
|
110
123
|
readonly revealHandlerErrors?: HandlerErrorSink
|
|
@@ -116,7 +129,7 @@ export interface TestKitOptions {
|
|
|
116
129
|
*/
|
|
117
130
|
readonly mail?: MemoryEmailSender | 'none'
|
|
118
131
|
/**
|
|
119
|
-
* The origins the environment lists for the contract's `
|
|
132
|
+
* The origins the environment lists for the contract's endpoints that admit `origins: 'listed'`, as an operator sets them in
|
|
120
133
|
* Control; a page on one of them may call such an endpoint with a key. None when omitted.
|
|
121
134
|
*/
|
|
122
135
|
readonly crossOrigins?: readonly string[]
|
|
@@ -161,12 +174,12 @@ export interface TestKit<C extends AnyContract> {
|
|
|
161
174
|
acceptInvitation(invitationId: string, person?: LabelledTestCaller): LabelledTestCaller
|
|
162
175
|
/**
|
|
163
176
|
* 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
|
-
*
|
|
177
|
+
* its key, its writes fenced. It first waits for the runs a site's `ctx.kick` sent, so it never races them.
|
|
178
|
+
* `FILE_SCAN_JOB` runs the scan of `scan: true` file kinds with the kit's `scanner`, `FILE_SWEEP_JOB` the sweep of
|
|
179
|
+
* unreferenced files. Needs the application's data layer on Postgres.
|
|
167
180
|
*/
|
|
168
181
|
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
|
|
182
|
+
/** Waits for the work requests left running: the runs a site's `ctx.kick` sent, a failure's report. */
|
|
170
183
|
drain(): Promise<void>
|
|
171
184
|
/**
|
|
172
185
|
* The stand-in for every job's transport: what reached it (`accepted`, `handoffs`) and its faults (`refuse`,
|
|
@@ -176,11 +189,30 @@ export interface TestKit<C extends AnyContract> {
|
|
|
176
189
|
readonly mail: MemoryEmailSender
|
|
177
190
|
/** Whose keys this kit's prefix separates: `mailKeyPrefix(kit.mailKeyScope)` is what a job's key gets in front. */
|
|
178
191
|
readonly mailKeyScope: MailKeyScope
|
|
192
|
+
/**
|
|
193
|
+
* A token of `purpose`, signed with the key the kit handed the application, as a site's `ctx.tokens.sign` signs it:
|
|
194
|
+
* what a test sends where an input carries `signedToken('<purpose>')`.
|
|
195
|
+
*/
|
|
196
|
+
signToken<const P extends TokenPurposeName<C>>(purpose: P, claims: TokenClaimsInput<C, P>, options: { readonly days: number }): string
|
|
197
|
+
/**
|
|
198
|
+
* A solved ALTCHA challenge of `procedure`, a procedure of a client over this kit whose challenge is
|
|
199
|
+
* `provider: 'altcha'`: what the page sends as `challengeToken`. Each one passes once. The kit issues the cheapest
|
|
200
|
+
* challenge whatever the contract's `cost`, so solving one takes milliseconds.
|
|
201
|
+
*/
|
|
202
|
+
solveChallenge(procedure: object): Promise<string>
|
|
179
203
|
}
|
|
180
204
|
|
|
181
205
|
/** The names of the contract's cron jobs. */
|
|
182
206
|
export type JobName<C> = C extends { readonly surface: { readonly jobs: infer J } } ? keyof J & string : never
|
|
183
207
|
|
|
208
|
+
type TokensOf<C> = C extends { readonly surface: { readonly tokens: infer T } } ? T : Record<never, never>
|
|
209
|
+
|
|
210
|
+
/** The token purposes of the contract's kit. */
|
|
211
|
+
export type TokenPurposeName<C> = keyof TokensOf<C> & string
|
|
212
|
+
|
|
213
|
+
/** What a token of purpose `P` carries, as a site passes it to `sign`. */
|
|
214
|
+
export type TokenClaimsInput<C, P extends string> = TokensOf<C> extends { readonly [K in P]: TokenPurpose<infer Claims> } ? z.input<Claims> : never
|
|
215
|
+
|
|
184
216
|
/** Each SPA mount from its built directory under `root` when one exists, as the deploy serves it; an empty page otherwise. */
|
|
185
217
|
function builtAssets(root: string): SpaAssets {
|
|
186
218
|
const disk = diskAssets(root)
|
|
@@ -190,13 +222,27 @@ function builtAssets(root: string): SpaAssets {
|
|
|
190
222
|
}
|
|
191
223
|
}
|
|
192
224
|
|
|
193
|
-
function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Request {
|
|
225
|
+
function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Promise<Request> {
|
|
194
226
|
if (!(input instanceof Request)) {
|
|
195
|
-
return new Request(new URL(input, origin), init)
|
|
227
|
+
return withLength(new Request(new URL(input, origin), init), init?.body)
|
|
196
228
|
}
|
|
197
229
|
// Never `new Request(request, …)`: Bun would buffer the body the application must meet as a stream.
|
|
198
230
|
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 })
|
|
231
|
+
return init === undefined ? Promise.resolve(input) : withLength(new Request(input.url, { ...base, ...init }), init.body)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* `request` as a browser sends a body it knows the length of (a string, a form, bytes): with `Content-Length`, which a
|
|
236
|
+
* `bytesPerHour` budget charges. A stream goes without one, as `fetch` sends it chunked, and is charged the body limit.
|
|
237
|
+
*/
|
|
238
|
+
async function withLength(request: Request, body: BodyInit | null | undefined): Promise<Request> {
|
|
239
|
+
if (body === undefined || body === null || body instanceof ReadableStream || request.headers.has('Content-Length')) {
|
|
240
|
+
return request
|
|
241
|
+
}
|
|
242
|
+
const headers = new Headers(request.headers)
|
|
243
|
+
const bytes = await request.arrayBuffer()
|
|
244
|
+
headers.set('Content-Length', String(bytes.byteLength))
|
|
245
|
+
return new Request(request.url, { method: request.method, headers, body: bytes, signal: request.signal, redirect: request.redirect })
|
|
200
246
|
}
|
|
201
247
|
|
|
202
248
|
/**
|
|
@@ -207,6 +253,9 @@ function withHeaders(request: Request, headers: Headers): Request {
|
|
|
207
253
|
return new Request(request.url, { method: request.method, headers, body: request.body, signal: request.signal, redirect: request.redirect })
|
|
208
254
|
}
|
|
209
255
|
|
|
256
|
+
/** The client every request of a kit comes from, unless `clientIp` names another: TEST-NET-1 (RFC 5737). */
|
|
257
|
+
const TEST_CLIENT_IP = '192.0.2.1'
|
|
258
|
+
|
|
210
259
|
/** Every header only the proxy may assert, deleted before the decision as Caddy and the proxy Worker delete them. */
|
|
211
260
|
function stripClientAsserted(headers: Headers): void {
|
|
212
261
|
for (const header of [...headers.keys()]) {
|
|
@@ -283,7 +332,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
283
332
|
connectVariablesOf[variables.key] = connection.key
|
|
284
333
|
connectBindings[variables.binding] = connection.fetch
|
|
285
334
|
}
|
|
286
|
-
// The kit stands in for Control too: every
|
|
335
|
+
// The kit stands in for Control too: every workload gets a key, which its stand-in IAM signs the declared actions for.
|
|
287
336
|
const workloadVariables: Record<string, string> = {}
|
|
288
337
|
const workloadsByKey = new Map<string, { readonly kind: WorkloadKindOf; readonly name: string; readonly actions: readonly string[] }>()
|
|
289
338
|
// 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 +341,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
292
341
|
const job = Object.hasOwn(artifact.jobs, name) ? artifact.jobs[name] : undefined
|
|
293
342
|
return job === undefined ? undefined : { id: `job-${name}`, actions: job.actions, issues: artifact.declared?.[`job:${name}`]?.issues ?? null }
|
|
294
343
|
}
|
|
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) {
|
|
344
|
+
for (const workload of contractWorkloads(artifact)) {
|
|
302
345
|
const key = `${API_KEY_PREFIX}test_${crypto.randomUUID().replaceAll('-', '')}`
|
|
303
346
|
workloadVariables[workloadKeyVariable(workload.kind, workload.name)] = key
|
|
304
347
|
workloadsByKey.set(key, workload)
|
|
@@ -347,6 +390,19 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
347
390
|
const membership = options.membership ?? standIn?.rpc
|
|
348
391
|
const mail = options.mail === undefined || options.mail === 'none' ? new MemoryEmailSender() : options.mail
|
|
349
392
|
const mailKeyScope: MailKeyScope = Object.freeze({ issuer: TEST_ISSUER, appId })
|
|
393
|
+
// The kit stands in for Control's token key too: one random key, which signs and verifies.
|
|
394
|
+
const tokenKey = `${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}:${
|
|
395
|
+
Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
|
|
396
|
+
}`
|
|
397
|
+
const tokenVariables: Record<string, string> = artifact.tokens === undefined ? {} : { [FABRIKA_TOKEN_KEYS]: tokenKey }
|
|
398
|
+
const kitTokens = new SignedTokens(
|
|
399
|
+
new Map(
|
|
400
|
+
Object.entries(app.contract.surface.tokens ?? {}).map(([purpose, declared]) => [purpose, { claims: declared.claims, maxDays: declared.maxDays }]),
|
|
401
|
+
),
|
|
402
|
+
readTokenKeys({ [FABRIKA_TOKEN_KEYS]: tokenKey }, { ...artifact, tokens: artifact.tokens ?? {} }, []),
|
|
403
|
+
)
|
|
404
|
+
const signToken = (purpose: string, claims: unknown, signOptions: { readonly days: number }): string =>
|
|
405
|
+
kitTokens.context('the test kit', [purpose]).sign(purpose, claims, signOptions)
|
|
350
406
|
const application: ContractApp = boot(app, {
|
|
351
407
|
environment: {
|
|
352
408
|
// The deploy writes the approved contract's digest; the kit's contract is the approved one by definition.
|
|
@@ -354,8 +410,11 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
354
410
|
[FABRIKA_IAM_APP_ID]: appId,
|
|
355
411
|
[FABRIKA_IAM_ISSUER]: TEST_ISSUER,
|
|
356
412
|
[FABRIKA_CONTRACT_DIGEST]: contractDigest(artifact),
|
|
413
|
+
// The deploy writes the environment's public origin; the kit's is its own.
|
|
414
|
+
[FABRIKA_APP_ORIGIN]: origin,
|
|
357
415
|
...connectVariablesOf,
|
|
358
416
|
...workloadVariables,
|
|
417
|
+
...tokenVariables,
|
|
359
418
|
},
|
|
360
419
|
application: options.env ?? {},
|
|
361
420
|
},
|
|
@@ -365,12 +424,15 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
365
424
|
files: options.files,
|
|
366
425
|
...(options.scanner === undefined ? {} : { scanner: options.scanner }),
|
|
367
426
|
...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
|
|
427
|
+
challengeWork: MINIMAL_CHALLENGE_WORK,
|
|
368
428
|
...dev,
|
|
369
429
|
...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
|
|
370
430
|
membership,
|
|
371
431
|
connectBindings,
|
|
372
432
|
workloadTokens,
|
|
373
433
|
mailStandIn: options.mail === 'none' ? 'none' : mail,
|
|
434
|
+
// The kit's proxy sets `X-Fabrika-Client-Ip` from the request it was handed, as the Worker sets it from Cloudflare's.
|
|
435
|
+
clientAddress: 'vouched',
|
|
374
436
|
})
|
|
375
437
|
|
|
376
438
|
let issued = 0
|
|
@@ -543,7 +605,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
543
605
|
...(options.crossOrigins === undefined ? {} : { crossOrigins: options.crossOrigins }),
|
|
544
606
|
})
|
|
545
607
|
|
|
546
|
-
// What requests leave running: a job a
|
|
608
|
+
// What requests leave running: a job a site's `ctx.kick` sent, or a failure's report.
|
|
547
609
|
const background = new Set<Promise<void>>()
|
|
548
610
|
const exec: RequestExecutionContext = {
|
|
549
611
|
waitUntil(promise) {
|
|
@@ -554,7 +616,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
554
616
|
}
|
|
555
617
|
|
|
556
618
|
const kitFetch: RpcFetch = async (input, init) => {
|
|
557
|
-
const request = toRequest(origin, input, init)
|
|
619
|
+
const request = await toRequest(origin, input, init)
|
|
558
620
|
if (new URL(request.url).origin !== origin) {
|
|
559
621
|
throw new TypeError(`the test kit serves only ${origin}, not ${new URL(request.url).origin}`)
|
|
560
622
|
}
|
|
@@ -572,6 +634,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
572
634
|
for (const [header, value] of outcome.asserted) {
|
|
573
635
|
headers.set(header, value)
|
|
574
636
|
}
|
|
637
|
+
headers.set(CLIENT_ADDRESS_HEADER, options.clientIp ?? TEST_CLIENT_IP)
|
|
575
638
|
const answer = await application.fetch(withHeaders(request, headers), exec)
|
|
576
639
|
const decorated = new Headers(answer.headers)
|
|
577
640
|
outcome.decorate(decorated)
|
|
@@ -582,8 +645,8 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
582
645
|
if (caller !== undefined && caller.cookie === null) {
|
|
583
646
|
throw new Error(`a browser carries a session; pass the ${caller.kind}'s key to createClient instead`)
|
|
584
647
|
}
|
|
585
|
-
return (input, init) => {
|
|
586
|
-
const request = toRequest(origin, input, init)
|
|
648
|
+
return async (input, init) => {
|
|
649
|
+
const request = await toRequest(origin, input, init)
|
|
587
650
|
// Bun's Request does not keep its credentials mode, so `init` is the only reliable reading of it.
|
|
588
651
|
const credentials = init?.credentials ?? (input instanceof Request ? input.credentials : undefined)
|
|
589
652
|
const headers = new Headers(request.headers)
|
|
@@ -647,6 +710,8 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
647
710
|
acceptInvitation,
|
|
648
711
|
mail,
|
|
649
712
|
mailKeyScope,
|
|
713
|
+
signToken,
|
|
714
|
+
solveChallenge: (procedure: object) => solveChallengeAt(kitFetch, new URL(challengeUrl(procedure), origin).href),
|
|
650
715
|
}),
|
|
651
716
|
standIn,
|
|
652
717
|
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
|
+
}
|