@fabrika/app 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +746 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +308 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +331 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +824 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +760 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. package/src/testing.ts +0 -119
@@ -0,0 +1,36 @@
1
+ // @fabrika/app/testing — test helpers with no test-runner dependency: the contract application test kit.
2
+
3
+ import { refuseInProduction } from '@fabrika/db/production'
4
+
5
+ refuseInProduction('@fabrika/app/testing')
6
+ export type { IamMembershipRpc } from '@fabrika/auth-core'
7
+ export type { FileScanner } from '../files/scan'
8
+ export type { ChallengeFetch } from '../implement/challenge'
9
+ export type { HandlerErrorSink } from '../jobs/runner'
10
+ export type {
11
+ LabelledTestCaller,
12
+ LinkTestCaller,
13
+ PersonTestCaller,
14
+ TestCaller,
15
+ TestCallerKind,
16
+ TestCallerOptions,
17
+ TestGrant,
18
+ TestLink,
19
+ TestScope,
20
+ } from './caller'
21
+ export { passingChallenge } from './challenge'
22
+ export {
23
+ DEV_OUTBOX_ORIGIN,
24
+ type DevKit,
25
+ devKit,
26
+ type DevLink,
27
+ type DevOutboxMessage,
28
+ type DevOutside,
29
+ type DevPersona,
30
+ type DevScopeValue,
31
+ type DevSeed,
32
+ type DevSeedResult,
33
+ } from './dev'
34
+ export { type JobName, TEST_ENVIRONMENT, TEST_ISSUER, type TestConnection, type TestKit, testKit, type TestKitOptions } from './kit'
35
+ export type { SentInvitation, TestMembership } from './membership'
36
+ export { statusOf } from './status'
@@ -0,0 +1,25 @@
1
+ // @fabrika/app/testing/isolation — the kits behind the tests `fabrika db test-scaffold` generates: the policy isolation
2
+ // kit (the data layer enforces each table policy) and the endpoint access kit (the application's own endpoints refuse
3
+ // another scope). A subpath of its own: it loads `@fabrika/db/testing`, which needs Bun and Postgres.
4
+
5
+ import { refuseInProduction } from '@fabrika/db/production'
6
+
7
+ refuseInProduction('@fabrika/app/testing/isolation')
8
+
9
+ export { type DeniedOutcome, type EndpointAccessKit, endpointAccessKit, type LeakOutcome, type ReachOutcome } from './endpoint-access'
10
+ export {
11
+ type AppCaller,
12
+ type AppCountOutcome,
13
+ type AppIsolationKit,
14
+ appPolicyIsolationKit,
15
+ type ChildIsolationRow,
16
+ type CountOutcome,
17
+ type IsolationOutcome,
18
+ type IsolationRow,
19
+ type ListOutcome,
20
+ type MisuseOutcome,
21
+ type PolicyIsolationKit,
22
+ policyIsolationKit,
23
+ type ReadOutcome,
24
+ } from './policy-isolation'
25
+ export { afterAll, describe, expect, test } from './runner'
@@ -0,0 +1,585 @@
1
+ // The test kit of a contract application: the application booted in-process behind a stand-in for IAM and the
2
+ // production proxy decision. A request takes the production path — proxy-core's verify service over the contract's
3
+ // endpoint table, a token signed the way IAM signs it, the proxy's header stripping, `X-Fabrika-Endpoint` — into the
4
+ // real `boot` and its dispatcher.
5
+
6
+ import {
7
+ API_KEY_PREFIX,
8
+ buildAccessClaims,
9
+ CLIENT_ADDRESS_HEADER,
10
+ type ContractArtifact,
11
+ contractDigest,
12
+ FABRIKA_CONTRACT_DIGEST,
13
+ FABRIKA_IAM_APP_ID,
14
+ FABRIKA_IAM_ISSUER,
15
+ type IamMembershipRpc,
16
+ isNormalizedMailbox,
17
+ type Jwks,
18
+ MAX_ACCESS_TOKEN_BYTES,
19
+ normalizeEmailIdentity,
20
+ type PermissionEntry,
21
+ PROXY_TOKEN_HEADER,
22
+ SESSION_COOKIE,
23
+ TOKEN_ALG,
24
+ UNTRUSTED_FORWARD_HEADERS,
25
+ workloadKeyVariable,
26
+ } from '@fabrika/auth-core'
27
+ import type { Database } from '@fabrika/db'
28
+ import { refuseInProduction } from '@fabrika/db/production'
29
+ import type { FileObjectStore } from '@fabrika/platform'
30
+ import { type IamGateway, REQUEST_ID_HEADER, stripFabrikaCookies } from '@fabrika/proxy-core'
31
+ import { exportJWK, generateKeyPair, SignJWT } from 'jose'
32
+ import { existsSync } from 'node:fs'
33
+ import { resolve } from 'node:path'
34
+ import type { RpcFetch } from '../client/transport'
35
+ import { compileContract } from '../contract/compile'
36
+ import type { AnyContract, DimensionOf } from '../contract/define'
37
+ import { diskAssets } from '../disk-assets'
38
+ import type { RequestExecutionContext } from '../fetch'
39
+ import type { FILE_SWEEP_JOB } from '../files/collections'
40
+ import type { FILE_SCAN_JOB, FileScanner } from '../files/scan'
41
+ import { boot, type BootOptions, type ContractApp } from '../implement/boot'
42
+ import type { ChallengeFetch } from '../implement/challenge'
43
+ import { type ConnectFetch, connectVariables } from '../implement/connect'
44
+ import type { SpaAssets } from '../implement/dispatch'
45
+ import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
46
+ import type { WorkloadKindOf, WorkloadTokenSource } from '../implement/workload'
47
+ import type { HandlerErrorSink } from '../jobs/runner'
48
+ import type { JobRunOptions, JobRunResult } from '../jobs/runner'
49
+ import {
50
+ type LabelledTestCaller,
51
+ type LinkTestCaller,
52
+ type PersonTestCaller,
53
+ resolveGrants,
54
+ type TestCaller,
55
+ type TestCallerOptions,
56
+ type TestGrant,
57
+ type TestScope,
58
+ } from './caller'
59
+ import {
60
+ credentialCallers,
61
+ type JobCredential,
62
+ type KitJobCaller,
63
+ type KitMembership,
64
+ kitMembership,
65
+ type MintedToken,
66
+ type TestMembership,
67
+ } from './membership'
68
+ import { endpointProxy } from './proxy'
69
+
70
+ export const TEST_ISSUER = 'https://iam.test'
71
+ export const TEST_ENVIRONMENT = 'test'
72
+ const KEY_ID = 'test-key'
73
+ const TOKEN_TTL_SECONDS = 300
74
+
75
+ /** Why the kit's IAM signs no token for a caller, as IAM answers the proxy. */
76
+ type MintRefusal = { readonly ok: false; readonly reason: 'disabled' | 'token_too_large' }
77
+
78
+ export interface TestKitOptions {
79
+ /** The application's own variables, as `deps` receives them. */
80
+ readonly env?: ApplicationEnvironment
81
+ /**
82
+ * The application root an SPA mount's `dir` is relative to; the working directory when omitted. A mount whose built
83
+ * directory exists there is served from it as the deploy serves it: a file by its path, every other path by the
84
+ * directory's `index.html`. A mount not built is an empty HTML document.
85
+ */
86
+ readonly root?: string
87
+ /** Serves the SPA mounts in place of their built directories under `root`. */
88
+ readonly assets?: SpaAssets
89
+ /** The connection a `scoped(tables)` database is bound to, as the runtime adapter supplies it to `boot`. */
90
+ readonly database?: Database
91
+ /** The object storage of the file collections, as the runtime adapter supplies it to `boot`. */
92
+ readonly files?: FileObjectStore
93
+ /** The scanner of the scan job of `scan: true` collections; without it, the job leaves every upload quarantined. */
94
+ readonly scanner?: FileScanner
95
+ /**
96
+ * IAM's membership operations in place of the kit's own stand-in: `boot` asks it as the adapter's transport, and the
97
+ * kit's proxy obtains each delegation grant from it. Without it the kit's IAM answers them under IAM's rules.
98
+ */
99
+ readonly membership?: IamMembershipRpc
100
+ /**
101
+ * Each import's owner, by the import's name: its origin and edge key, which the platform writes as the import's
102
+ * framework variables, and how a request reaches it — usually the owner kit's `fetch`.
103
+ */
104
+ readonly connect?: Readonly<Record<string, TestConnection>>
105
+ /** What answers a form's challenge verification in place of the provider: a stand-in for its siteverify. */
106
+ readonly challengeFetch?: ChallengeFetch
107
+ /** What a job's handler threw, shown on the developer's own machine: `fabrika app dev` sets it, a test need not. */
108
+ readonly revealHandlerErrors?: HandlerErrorSink
109
+ }
110
+
111
+ export interface TestConnection {
112
+ readonly origin: string
113
+ readonly key: string
114
+ readonly fetch: ConnectFetch
115
+ }
116
+
117
+ export interface TestKit<C extends AnyContract> {
118
+ /** The origin requests are resolved against, `https://<app>.test`. */
119
+ readonly origin: string
120
+ /** The token audience, `<app>@test`. */
121
+ readonly appId: string
122
+ /** The contract as compiled for `boot`. */
123
+ readonly artifact: ContractArtifact
124
+ /** A new principal of the test IAM holding `grants`, with a fresh credential; only a link holder has no label. */
125
+ testCaller(grants: readonly TestGrant<C>[], options: TestCallerOptions & { readonly kind: 'link' }): LinkTestCaller
126
+ testCaller(grants: readonly TestGrant<C>[], options?: TestCallerOptions & { readonly kind?: 'human' }): PersonTestCaller
127
+ testCaller(grants: readonly TestGrant<C>[], options?: TestCallerOptions & { readonly kind?: LabelledTestCaller['kind'] }): LabelledTestCaller
128
+ testCaller(grants: readonly TestGrant<C>[], options?: TestCallerOptions): TestCaller
129
+ /**
130
+ * One value of the contract's dimension `dimension`, for a grant's `scope`. Stored in a variable it keeps its
131
+ * dimension, where an object literal's `type` widens to `string`: `const acme = kit.scope('org', 'acme')`.
132
+ */
133
+ scope<D extends DimensionOf<C>>(dimension: D, value: string): TestScope<D>
134
+ /** A request as it reaches the proxy, credentials as written: a script, a `px_` key, or another site. */
135
+ readonly fetch: RpcFetch
136
+ /** `fetch` from a page of the application: same-origin, with the session cookie of `caller` when given. */
137
+ browser(caller?: TestCaller): RpcFetch
138
+ /** The test IAM as the proxy calls it: it mints for the credentials `testCaller` issued and serves their key set. */
139
+ readonly iam: IamGateway
140
+ /**
141
+ * The addressee of an invitation the kit's IAM holds open accepts it, as IAM's onboarding does: `person` holds the
142
+ * invited role in its scope from now on, or, without one, a new person labelled by the invited address does. Answers
143
+ * that person. Throws, leaving the invitation open, as IAM refuses: an external `person` and a role not declared
144
+ * external (`not_eligible`), an inviter that can no longer give the role (`not_allowed`). Throws for an invitation
145
+ * that is not open, and in a kit given its own `membership`.
146
+ */
147
+ acceptInvitation(invitationId: string, person?: LabelledTestCaller): LabelledTestCaller
148
+ /**
149
+ * Runs the cron job `name` once, as `fabrika job run <name>` does: under its lease, with the actions IAM signs for
150
+ * its key, its writes fenced. It first waits for the runs a form's `then` kicked, so it never races them.
151
+ * `FILE_SCAN_JOB` runs the scan of `scan: true` collections with the kit's `scanner`, `FILE_SWEEP_JOB` the sweep of
152
+ * removed and orphaned files. Needs the application's data layer on Postgres.
153
+ */
154
+ runJob(name: JobName<C> | typeof FILE_SCAN_JOB | typeof FILE_SWEEP_JOB, options?: JobRunOptions): Promise<JobRunResult>
155
+ }
156
+
157
+ /** The names of the contract's cron jobs. */
158
+ export type JobName<C> = C extends { readonly surface: { readonly jobs: infer J } } ? keyof J & string : never
159
+
160
+ /** Each SPA mount from its built directory under `root` when one exists, as the deploy serves it; an empty page otherwise. */
161
+ function builtAssets(root: string): SpaAssets {
162
+ const disk = diskAssets(root)
163
+ const empty = () => new Response('<!doctype html><title></title>', { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
164
+ return {
165
+ serve: (mount, request) => (existsSync(resolve(root, mount.dir)) ? disk.serve(mount, request) : Promise.resolve(empty())),
166
+ }
167
+ }
168
+
169
+ function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Request {
170
+ return input instanceof Request ? new Request(input, init) : new Request(new URL(input, origin), init)
171
+ }
172
+
173
+ /** Every header only the proxy may assert, deleted before the decision as Caddy and the proxy Worker delete them. */
174
+ function stripClientAsserted(headers: Headers): void {
175
+ for (const header of [...headers.keys()]) {
176
+ if (header.toLowerCase().startsWith('x-fabrika-')) {
177
+ headers.delete(header)
178
+ }
179
+ }
180
+ headers.delete(PROXY_TOKEN_HEADER)
181
+ headers.delete(CLIENT_ADDRESS_HEADER)
182
+ headers.delete(REQUEST_ID_HEADER)
183
+ for (const header of UNTRUSTED_FORWARD_HEADERS) {
184
+ headers.delete(header)
185
+ }
186
+ }
187
+
188
+ /** Boots `app` the way its generated entrypoint would, behind the test proxy and IAM. Throws `BootError` like `boot`. */
189
+ export function testKit<C extends AnyContract>(app: ContractImplementation<C>, options: TestKitOptions = {}): TestKit<C> {
190
+ return bootTestKit(app, options).kit
191
+ }
192
+
193
+ /** The kit of the generated tests, and its stand-in IAM, which seeds the invitations and links a case names. */
194
+ export interface StandInIamKit<C extends AnyContract> {
195
+ readonly kit: TestKit<C>
196
+ readonly standIn: KitMembership
197
+ }
198
+
199
+ /** The kit of the generated tests, with a handle on its stand-in IAM to seed what a case names. */
200
+ export function standInIamKit<C extends AnyContract>(
201
+ app: ContractImplementation<C>,
202
+ options: Omit<TestKitOptions, 'membership'> = {},
203
+ ): StandInIamKit<C> {
204
+ const { kit, standIn } = bootTestKit(app, options)
205
+ if (standIn === null) {
206
+ throw new Error('a kit without its own membership transport stands in for IAM')
207
+ }
208
+ return { kit, standIn }
209
+ }
210
+
211
+ /** A booted kit, and its stand-in IAM unless it was given a membership transport. */
212
+ export interface BootedTestKit<C extends AnyContract> {
213
+ readonly kit: TestKit<C>
214
+ readonly standIn: KitMembership | null
215
+ /** The caller `value` is when `testCaller` or `acceptInvitation` of this kit made it; `undefined` for any other value. */
216
+ callerOf(value: unknown): TestCaller | undefined
217
+ }
218
+
219
+ /** What only `fabrika app dev`'s kit boots with, never a test's. */
220
+ export type DevBootOptions = Pick<BootOptions, 'repeatableChallengeToken'>
221
+
222
+ export function bootTestKit<C extends AnyContract>(
223
+ app: ContractImplementation<C>,
224
+ options: TestKitOptions,
225
+ dev: DevBootOptions = {},
226
+ ): BootedTestKit<C> {
227
+ refuseInProduction('@fabrika/app/testing')
228
+ const name = app.contract.vocabulary.app
229
+ const appId = `${name}@${TEST_ENVIRONMENT}`
230
+ const host = `${name}.test`
231
+ const origin = `https://${host}`
232
+ const artifact = compileContract(app.contract, app.database?.tables ?? {})
233
+ const signing = generateKeyPair(TOKEN_ALG)
234
+ const jwks: Promise<Jwks> = signing.then(async ({ publicKey }) => {
235
+ const { kty, crv, x, y } = await exportJWK(publicKey)
236
+ if (kty === undefined) {
237
+ throw new Error('the generated signing key exported no key type')
238
+ }
239
+ return { keys: [{ kty, crv, x, y, kid: KEY_ID, alg: TOKEN_ALG, use: 'sig' }] }
240
+ })
241
+ const connectVariablesOf: Record<string, string> = {}
242
+ const connectBindings: Record<string, ConnectFetch> = {}
243
+ for (const [name, connection] of Object.entries(options.connect ?? {})) {
244
+ const variables = connectVariables(name)
245
+ connectVariablesOf[variables.url] = connection.origin
246
+ connectVariablesOf[variables.key] = connection.key
247
+ connectBindings[variables.binding] = connection.fetch
248
+ }
249
+ // The kit stands in for Control too: every job and webhook gets a key, which its stand-in IAM signs the declared actions for.
250
+ const workloadVariables: Record<string, string> = {}
251
+ const workloadsByKey = new Map<string, { readonly kind: WorkloadKindOf; readonly name: string; readonly actions: readonly string[] }>()
252
+ // 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.
253
+ const jobCredentials = new Map<string, JobCredential>()
254
+ const jobCallerOf = (name: string): KitJobCaller | undefined => {
255
+ const job = Object.hasOwn(artifact.jobs, name) ? artifact.jobs[name] : undefined
256
+ return job === undefined ? undefined : { id: `job-${name}`, actions: job.actions, issues: artifact.declared?.[`job:${name}`]?.issues ?? null }
257
+ }
258
+ const declaredWorkloads = [
259
+ ...Object.entries(artifact.jobs).map(([workload, job]) => ({ kind: 'job' as const, name: workload, actions: job.actions })),
260
+ ...Object.entries(artifact.mounts).flatMap(([workload, mount]) =>
261
+ mount.kind === 'webhook' ? [{ kind: 'webhook' as const, name: workload, actions: mount.actions }] : []
262
+ ),
263
+ ]
264
+ for (const workload of declaredWorkloads) {
265
+ const key = `${API_KEY_PREFIX}test_${crypto.randomUUID().replaceAll('-', '')}`
266
+ workloadVariables[workloadKeyVariable(workload.kind, workload.name)] = key
267
+ workloadsByKey.set(key, workload)
268
+ const job = workload.kind === 'job' ? jobCallerOf(workload.name) : undefined
269
+ if (job !== undefined) jobCredentials.set(key, { job, expiresAt: null })
270
+ }
271
+ const workloadTokens: WorkloadTokenSource = {
272
+ async exchange({ app: audience, key }) {
273
+ const workload = audience === appId ? workloadsByKey.get(key) : undefined
274
+ if (workload === undefined) {
275
+ return { ok: false, reason: 'invalid_key' }
276
+ }
277
+ const now = Math.floor(Date.now() / 1000)
278
+ const claims = buildAccessClaims({
279
+ iss: TEST_ISSUER,
280
+ app: appId,
281
+ subject: `${workload.kind}-${workload.name}`,
282
+ type: workload.kind,
283
+ workload: workload.name,
284
+ label: `${workload.kind}:${workload.name}`,
285
+ permissions: workload.actions.map((action) => ({ action, scope: null, source: 'grant' })),
286
+ issuedAt: now,
287
+ expiresAt: now + TOKEN_TTL_SECONDS,
288
+ })
289
+ const { privateKey } = await signing
290
+ const token = await new SignJWT({ ...claims }).setProtectedHeader({ alg: TOKEN_ALG, kid: KEY_ID }).sign(privateKey)
291
+ const job = jobCredentials.get(key)?.job
292
+ if (job !== undefined) jobCredentials.set(token, { job, expiresAt: claims.exp })
293
+ return { ok: true, token, expiresAt: claims.exp }
294
+ },
295
+ }
296
+ const byKey = new Map<string, TestCaller>()
297
+ const bySession = new Map<string, TestCaller>()
298
+ const byToken = new Map<string, MintedToken>()
299
+ const memberships = new Map<TestCaller, readonly TestMembership[]>()
300
+ const directoryCallers = credentialCallers(
301
+ appId,
302
+ byKey,
303
+ byToken,
304
+ () => [...memberships].map(([caller, held]) => ({ caller, memberships: held })),
305
+ jobCredentials,
306
+ )
307
+ // The stand-in answers the member directory from the kit's own callers and the writes under IAM's rules; it also
308
+ // issues the proxy's delegation grants.
309
+ const standIn = options.membership === undefined ? kitMembership(artifact, directoryCallers) : null
310
+ const membership = options.membership ?? standIn?.rpc
311
+ const application: ContractApp = boot(app, {
312
+ environment: {
313
+ // The deploy writes the approved contract's digest; the kit's contract is the approved one by definition.
314
+ framework: {
315
+ [FABRIKA_IAM_APP_ID]: appId,
316
+ [FABRIKA_IAM_ISSUER]: TEST_ISSUER,
317
+ [FABRIKA_CONTRACT_DIGEST]: contractDigest(artifact),
318
+ ...connectVariablesOf,
319
+ ...workloadVariables,
320
+ },
321
+ application: options.env ?? {},
322
+ },
323
+ iam: { getJwks: () => jwks },
324
+ assets: options.assets ?? builtAssets(options.root ?? process.cwd()),
325
+ database: options.database,
326
+ files: options.files,
327
+ ...(options.scanner === undefined ? {} : { scanner: options.scanner }),
328
+ ...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
329
+ ...dev,
330
+ ...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
331
+ membership,
332
+ connectBindings,
333
+ workloadTokens,
334
+ })
335
+
336
+ let issued = 0
337
+
338
+ function testCaller(grants: readonly TestGrant<C>[], options: TestCallerOptions & { readonly kind: 'link' }): LinkTestCaller
339
+ function testCaller(grants: readonly TestGrant<C>[], options?: TestCallerOptions & { readonly kind?: 'human' }): PersonTestCaller
340
+ function testCaller(
341
+ grants: readonly TestGrant<C>[],
342
+ options?: TestCallerOptions & { readonly kind?: LabelledTestCaller['kind'] },
343
+ ): LabelledTestCaller
344
+ function testCaller(grants: readonly TestGrant<C>[], options?: TestCallerOptions): TestCaller
345
+ function testCaller(grants: readonly TestGrant<C>[], callerOptions: TestCallerOptions = {}): TestCaller {
346
+ return issue(grants, callerOptions)
347
+ }
348
+
349
+ /** `testCaller` over the contract's names as plain strings, which `acceptInvitation` reads from its stand-in IAM. */
350
+ function issue(grants: readonly TestGrant<AnyContract>[], callerOptions: TestCallerOptions): TestCaller {
351
+ const kind = callerOptions.kind ?? 'human'
352
+ const via = kind === 'human' ? (callerOptions.via ?? 'session') : 'key'
353
+ issued += 1
354
+ const credential = crypto.randomUUID().replaceAll('-', '')
355
+ const consumer = kind === 'app' ? (callerOptions.app ?? '') : null
356
+ if (consumer === '') {
357
+ throw new Error("an app caller names the consuming application: testCaller(grants, { kind: 'app', app: '<id>' })")
358
+ }
359
+ if (callerOptions.link !== undefined && kind !== 'link') {
360
+ throw new Error("only a link holder's key is bound to an object: testCaller(grants, { kind: 'link', link: { mount, chain } })")
361
+ }
362
+ if (callerOptions.email !== undefined && kind !== 'human') {
363
+ throw new Error('only a person has an address: testCaller(grants, { email }) is a human')
364
+ }
365
+ const email = kind === 'human' ? normalizeEmailIdentity(callerOptions.email ?? `${kind}-${issued}@${name}.test`) : null
366
+ if (email !== null && !isNormalizedMailbox(email)) {
367
+ throw new Error(`a test caller's email is an address, not '${email}'`)
368
+ }
369
+ const affiliation = callerOptions.affiliation ?? 'internal'
370
+ if (affiliation === 'external') {
371
+ if (kind !== 'human') {
372
+ throw new Error("only a human is external: testCaller(grants, { affiliation: 'external' })")
373
+ }
374
+ const roles = app.contract.vocabulary.roles
375
+ for (const grant of grants) {
376
+ if (!('role' in grant) || !Object.hasOwn(roles, grant.role) || roles[grant.role]?.external !== true) {
377
+ throw new Error('an external caller holds only roles the contract declares external: { member: { … }, external: true }')
378
+ }
379
+ }
380
+ }
381
+ const disabled = callerOptions.disabled ?? false
382
+ if (disabled && kind === 'link') {
383
+ throw new Error('a link holder is no principal, so no operator disables it: disable a person, a script or a connector')
384
+ }
385
+ if (callerOptions.lastSignInAt !== undefined && kind !== 'human') {
386
+ throw new Error('only a person signs in: testCaller(grants, { lastSignInAt }) is a human')
387
+ }
388
+ const credentials = {
389
+ id: callerOptions.id ?? `${kind}-${issued}`,
390
+ affiliation,
391
+ disabled,
392
+ lastSignInAt: callerOptions.lastSignInAt ?? null,
393
+ perms: Object.freeze(resolveGrants<AnyContract>(app.contract, grants)),
394
+ key: via === 'key' ? `${API_KEY_PREFIX}test_${credential}` : null,
395
+ cookie: via === 'session' ? `${SESSION_COOKIE}=${credential}` : null,
396
+ }
397
+ const link = callerOptions.link
398
+ const caller: TestCaller = kind === 'link'
399
+ ? Object.freeze({
400
+ ...credentials,
401
+ kind,
402
+ app: null,
403
+ label: null,
404
+ email: null,
405
+ link: link === undefined
406
+ ? null
407
+ : Object.freeze({ mount: link.mount, chain: Object.freeze(link.chain.map((scope) => Object.freeze({ ...scope }))) }),
408
+ })
409
+ : Object.freeze({ ...credentials, kind, app: consumer, label: callerOptions.label ?? `${kind}-${issued}@${name}.test`, email, link: null })
410
+ memberships.set(
411
+ caller,
412
+ grants.flatMap((grant) => ('role' in grant ? [{ role: grant.role, scope: grant.scope ?? null }] : [])),
413
+ )
414
+ if (caller.key !== null) {
415
+ byKey.set(caller.key, caller)
416
+ } else {
417
+ bySession.set(credential, caller)
418
+ }
419
+ return caller
420
+ }
421
+
422
+ const scope = <D extends DimensionOf<C>>(dimension: D, value: string): TestScope<D> => Object.freeze({ type: dimension, value })
423
+
424
+ /** The caller's token, or why IAM signs none: a disabled principal, or a token above `MAX_ACCESS_TOKEN_BYTES`. */
425
+ async function mint(caller: TestCaller): Promise<{ readonly ok: true; readonly token: string; readonly expiresAt: number } | MintRefusal> {
426
+ if (caller.disabled) return { ok: false, reason: 'disabled' }
427
+ const now = Math.floor(Date.now() / 1000)
428
+ const claims = buildAccessClaims({
429
+ iss: TEST_ISSUER,
430
+ app: appId,
431
+ subject: caller.id,
432
+ ...(caller.kind === 'link' ? {} : { type: caller.kind === 'human' ? 'user' : caller.kind === 'app' ? 'app' : 'service' }),
433
+ ...(caller.app === null ? {} : { workload: caller.app }),
434
+ ...(caller.link === null ? {} : { link: { mount: caller.link.mount, chain: [...caller.link.chain] } }),
435
+ label: caller.label,
436
+ ...(caller.email === null ? {} : { email: caller.email }),
437
+ permissions: [...(standIn?.permsOf(caller) ?? caller.perms)],
438
+ issuedAt: now,
439
+ expiresAt: now + TOKEN_TTL_SECONDS,
440
+ })
441
+ const { privateKey } = await signing
442
+ const token = await new SignJWT({ ...claims }).setProtectedHeader({ alg: TOKEN_ALG, kid: KEY_ID }).sign(privateKey)
443
+ if (token.length > MAX_ACCESS_TOKEN_BYTES) return { ok: false, reason: 'token_too_large' }
444
+ byToken.set(token, { caller, expiresAt: claims.exp })
445
+ return { ok: true, token, expiresAt: claims.exp }
446
+ }
447
+
448
+ /**
449
+ * The holder of a key the stand-in IAM issued through `ctx.links`, as IAM resolves a link key: the mount's grants on
450
+ * the link's object, bound to its chain; nothing once the link is revoked.
451
+ */
452
+ function linkHolderOf(key: string): LinkTestCaller | undefined {
453
+ const link = standIn?.linkOfKey(key)
454
+ const mount = link === undefined ? undefined : artifact.mounts[link.mount]
455
+ const object = link?.chain[0]
456
+ if (link === undefined || mount?.kind !== 'link' || object === undefined) return undefined
457
+ const chain = Object.freeze(link.chain.map((scope) => Object.freeze({ type: scope.type, value: scope.value })))
458
+ return Object.freeze({
459
+ id: link.id,
460
+ affiliation: 'internal',
461
+ disabled: false,
462
+ perms: Object.freeze(
463
+ mount.grants.map((action): PermissionEntry => ({ action, scope: { type: object.type, value: object.value }, source: 'grant' })),
464
+ ),
465
+ key,
466
+ cookie: null,
467
+ kind: 'link',
468
+ app: null,
469
+ label: null,
470
+ email: null,
471
+ lastSignInAt: null,
472
+ link: Object.freeze({ mount: link.mount, chain }),
473
+ })
474
+ }
475
+
476
+ const iam: IamGateway = {
477
+ async mintToken({ app: audience, session }) {
478
+ const caller = session === null || audience !== appId ? undefined : bySession.get(session)
479
+ if (caller === undefined) {
480
+ return { ok: false, reason: session === null ? 'no_session' : 'invalid_session' }
481
+ }
482
+ return mint(caller)
483
+ },
484
+ async mintFromKey({ app: audience, key }) {
485
+ const caller = audience === appId ? (byKey.get(key) ?? linkHolderOf(key)) : undefined
486
+ if (caller === undefined) {
487
+ return { ok: false, reason: 'invalid_key' }
488
+ }
489
+ return mint(caller)
490
+ },
491
+ // The kit issues sessions directly, so no handoff code exists to redeem.
492
+ exchangeAuthCode: () => Promise.resolve({ ok: false, reason: 'invalid_code' }),
493
+ getJwks: () => jwks,
494
+ }
495
+
496
+ const proxy = endpointProxy({ artifact, appId, host, iam, issuer: TEST_ISSUER, delegations: membership ?? null })
497
+
498
+ // What requests leave running: a job a form's `then` kicked, or a failure's report.
499
+ const background = new Set<Promise<void>>()
500
+ const exec: RequestExecutionContext = {
501
+ waitUntil(promise) {
502
+ const tracked = promise.then(() => undefined, () => undefined)
503
+ background.add(tracked)
504
+ void tracked.then(() => background.delete(tracked))
505
+ },
506
+ }
507
+
508
+ const kitFetch: RpcFetch = async (input, init) => {
509
+ const request = toRequest(origin, input, init)
510
+ if (new URL(request.url).origin !== origin) {
511
+ throw new TypeError(`the test kit serves only ${origin}, not ${new URL(request.url).origin}`)
512
+ }
513
+ const headers = new Headers(request.headers)
514
+ stripClientAsserted(headers)
515
+ const outcome = await proxy({ method: request.method, url: request.url, headers: new Headers(headers) })
516
+ if (outcome.outcome === 'answer') {
517
+ // The proxy's own answer carries its header policy, or the file treatment, as in production.
518
+ return outcome.response
519
+ }
520
+ stripFabrikaCookies(headers)
521
+ if (!outcome.keepsAuthorization) {
522
+ headers.delete('Authorization')
523
+ }
524
+ for (const [header, value] of outcome.asserted) {
525
+ headers.set(header, value)
526
+ }
527
+ return application.fetch(new Request(request, { headers }), exec)
528
+ }
529
+
530
+ function browser(caller?: TestCaller): RpcFetch {
531
+ if (caller !== undefined && caller.cookie === null) {
532
+ throw new Error(`a browser carries a session; pass the ${caller.kind}'s key to createClient instead`)
533
+ }
534
+ return (input, init) => {
535
+ const request = toRequest(origin, input, init)
536
+ // Bun's Request does not keep its credentials mode, so `init` is the only reliable reading of it.
537
+ const credentials = init?.credentials ?? (input instanceof Request ? input.credentials : undefined)
538
+ const headers = new Headers(request.headers)
539
+ if (!headers.has('Sec-Fetch-Site')) {
540
+ headers.set('Sec-Fetch-Site', 'same-origin')
541
+ }
542
+ if (!headers.has('Sec-Fetch-Mode')) {
543
+ headers.set('Sec-Fetch-Mode', 'cors')
544
+ }
545
+ if (caller !== undefined && caller.cookie !== null && credentials !== 'omit') {
546
+ const cookies = headers.get('Cookie')
547
+ headers.set('Cookie', cookies === null || cookies === '' ? caller.cookie : `${cookies}; ${caller.cookie}`)
548
+ }
549
+ return kitFetch(new Request(request, { headers }))
550
+ }
551
+ }
552
+
553
+ const runJob = async (name: string, runOptions?: JobRunOptions): Promise<JobRunResult> => {
554
+ while (background.size > 0) {
555
+ await Promise.all(background)
556
+ }
557
+ return application.jobs.run(name, runOptions)
558
+ }
559
+
560
+ const acceptInvitation = (invitationId: string, person?: LabelledTestCaller): LabelledTestCaller => {
561
+ if (standIn === null) {
562
+ throw new Error('this kit was given its own membership transport, so its IAM holds no invitation to accept')
563
+ }
564
+ if (person !== undefined && (person.kind !== 'human' || !memberships.has(person))) {
565
+ throw new Error('an invitation is accepted by a person of this kit: kit.testCaller(grants)')
566
+ }
567
+ const accepted = standIn.accept(invitationId, person)
568
+ if (person !== undefined) return person
569
+ const external = app.contract.vocabulary.roles[accepted.role]?.external === true
570
+ const invitee = issue([{ role: accepted.role, scope: accepted.scope }], {
571
+ label: accepted.email,
572
+ email: accepted.email,
573
+ affiliation: external ? 'external' : 'internal',
574
+ })
575
+ if (invitee.kind === 'link') {
576
+ throw new Error('an invitation is accepted by a person')
577
+ }
578
+ return invitee
579
+ }
580
+ return {
581
+ kit: Object.freeze({ origin, appId, artifact, testCaller, scope, fetch: kitFetch, browser, iam, runJob, acceptInvitation }),
582
+ standIn,
583
+ callerOf: (value) => [...memberships.keys()].find((caller) => caller === value),
584
+ }
585
+ }