@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.
- package/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- 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
|
+
}
|