@fabrika/app 0.3.2 → 0.4.0

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