@fabrika/app 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -8
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +14 -8
- package/skill/references/dev.md +29 -6
- package/skill/references/endpoint.md +35 -22
- package/skill/references/errors.md +1 -1
- package/skill/references/spa.md +61 -56
- package/skill/references/tests.md +5 -2
- package/src/client/contract.ts +30 -8
- package/src/contract/build.ts +12 -16
- package/src/contract/context.ts +7 -0
- package/src/contract/defaults.ts +5 -0
- package/src/implement/boot.ts +18 -2
- package/src/implement/challenge.ts +6 -1
- package/src/implement/dispatch.ts +14 -4
- package/src/jobs/runner.ts +12 -1
- package/src/react/hooks.ts +46 -4
- package/src/react/index.ts +3 -0
- package/src/react/provider.tsx +18 -4
- package/src/react/testing.ts +144 -0
- package/src/testing/challenge.ts +10 -0
- package/src/testing/dev.ts +30 -4
- package/src/testing/endpoint-access.ts +10 -7
- package/src/testing/index.ts +14 -1
- package/src/testing/kit.ts +42 -9
- package/src/testing/membership.ts +59 -9
package/src/testing/kit.ts
CHANGED
|
@@ -38,12 +38,13 @@ import { diskAssets } from '../disk-assets'
|
|
|
38
38
|
import type { RequestExecutionContext } from '../fetch'
|
|
39
39
|
import type { FILE_SWEEP_JOB } from '../files/collections'
|
|
40
40
|
import type { FILE_SCAN_JOB, FileScanner } from '../files/scan'
|
|
41
|
-
import { boot, type ContractApp } from '../implement/boot'
|
|
41
|
+
import { boot, type BootOptions, type ContractApp } from '../implement/boot'
|
|
42
42
|
import type { ChallengeFetch } from '../implement/challenge'
|
|
43
43
|
import { type ConnectFetch, connectVariables } from '../implement/connect'
|
|
44
44
|
import type { SpaAssets } from '../implement/dispatch'
|
|
45
45
|
import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
|
|
46
46
|
import type { WorkloadKindOf, WorkloadTokenSource } from '../implement/workload'
|
|
47
|
+
import type { HandlerErrorSink } from '../jobs/runner'
|
|
47
48
|
import type { JobRunOptions, JobRunResult } from '../jobs/runner'
|
|
48
49
|
import {
|
|
49
50
|
type LabelledTestCaller,
|
|
@@ -55,7 +56,15 @@ import {
|
|
|
55
56
|
type TestGrant,
|
|
56
57
|
type TestScope,
|
|
57
58
|
} from './caller'
|
|
58
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
credentialCallers,
|
|
61
|
+
type JobCredential,
|
|
62
|
+
type KitJobCaller,
|
|
63
|
+
type KitMembership,
|
|
64
|
+
kitMembership,
|
|
65
|
+
type MintedToken,
|
|
66
|
+
type TestMembership,
|
|
67
|
+
} from './membership'
|
|
59
68
|
import { endpointProxy } from './proxy'
|
|
60
69
|
|
|
61
70
|
export const TEST_ISSUER = 'https://iam.test'
|
|
@@ -95,6 +104,8 @@ export interface TestKitOptions {
|
|
|
95
104
|
readonly connect?: Readonly<Record<string, TestConnection>>
|
|
96
105
|
/** What answers a form's challenge verification in place of the provider: a stand-in for its siteverify. */
|
|
97
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
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
export interface TestConnection {
|
|
@@ -205,7 +216,14 @@ export interface BootedTestKit<C extends AnyContract> {
|
|
|
205
216
|
callerOf(value: unknown): TestCaller | undefined
|
|
206
217
|
}
|
|
207
218
|
|
|
208
|
-
|
|
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> {
|
|
209
227
|
refuseInProduction('@fabrika/app/testing')
|
|
210
228
|
const name = app.contract.vocabulary.app
|
|
211
229
|
const appId = `${name}@${TEST_ENVIRONMENT}`
|
|
@@ -231,6 +249,12 @@ export function bootTestKit<C extends AnyContract>(app: ContractImplementation<C
|
|
|
231
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.
|
|
232
250
|
const workloadVariables: Record<string, string> = {}
|
|
233
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
|
+
}
|
|
234
258
|
const declaredWorkloads = [
|
|
235
259
|
...Object.entries(artifact.jobs).map(([workload, job]) => ({ kind: 'job' as const, name: workload, actions: job.actions })),
|
|
236
260
|
...Object.entries(artifact.mounts).flatMap(([workload, mount]) =>
|
|
@@ -241,6 +265,8 @@ export function bootTestKit<C extends AnyContract>(app: ContractImplementation<C
|
|
|
241
265
|
const key = `${API_KEY_PREFIX}test_${crypto.randomUUID().replaceAll('-', '')}`
|
|
242
266
|
workloadVariables[workloadKeyVariable(workload.kind, workload.name)] = key
|
|
243
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 })
|
|
244
270
|
}
|
|
245
271
|
const workloadTokens: WorkloadTokenSource = {
|
|
246
272
|
async exchange({ app: audience, key }) {
|
|
@@ -261,18 +287,23 @@ export function bootTestKit<C extends AnyContract>(app: ContractImplementation<C
|
|
|
261
287
|
expiresAt: now + TOKEN_TTL_SECONDS,
|
|
262
288
|
})
|
|
263
289
|
const { privateKey } = await signing
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
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 }
|
|
269
294
|
},
|
|
270
295
|
}
|
|
271
296
|
const byKey = new Map<string, TestCaller>()
|
|
272
297
|
const bySession = new Map<string, TestCaller>()
|
|
273
298
|
const byToken = new Map<string, MintedToken>()
|
|
274
299
|
const memberships = new Map<TestCaller, readonly TestMembership[]>()
|
|
275
|
-
const directoryCallers = credentialCallers(
|
|
300
|
+
const directoryCallers = credentialCallers(
|
|
301
|
+
appId,
|
|
302
|
+
byKey,
|
|
303
|
+
byToken,
|
|
304
|
+
() => [...memberships].map(([caller, held]) => ({ caller, memberships: held })),
|
|
305
|
+
jobCredentials,
|
|
306
|
+
)
|
|
276
307
|
// The stand-in answers the member directory from the kit's own callers and the writes under IAM's rules; it also
|
|
277
308
|
// issues the proxy's delegation grants.
|
|
278
309
|
const standIn = options.membership === undefined ? kitMembership(artifact, directoryCallers) : null
|
|
@@ -295,6 +326,8 @@ export function bootTestKit<C extends AnyContract>(app: ContractImplementation<C
|
|
|
295
326
|
files: options.files,
|
|
296
327
|
...(options.scanner === undefined ? {} : { scanner: options.scanner }),
|
|
297
328
|
...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
|
|
329
|
+
...dev,
|
|
330
|
+
...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
|
|
298
331
|
membership,
|
|
299
332
|
connectBindings,
|
|
300
333
|
workloadTokens,
|
|
@@ -63,10 +63,25 @@ export interface DirectoryCallers {
|
|
|
63
63
|
* none for a credential of another application or an expired token.
|
|
64
64
|
*/
|
|
65
65
|
byCredential(app: string, credential: string): TestCaller | undefined
|
|
66
|
+
/** The job a link operation's credential (its workload key or token) belongs to, as IAM resolves it for `app`. */
|
|
67
|
+
jobByCredential(app: string, credential: string): KitJobCaller | undefined
|
|
66
68
|
/** Every caller with its role grants. */
|
|
67
69
|
all(): readonly { readonly caller: TestCaller; readonly memberships: readonly TestMembership[] }[]
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** A job's principal, as IAM holds it to its declaration: its actions app-wide, and the one link mount it issues. */
|
|
73
|
+
export interface KitJobCaller {
|
|
74
|
+
readonly id: string
|
|
75
|
+
readonly actions: readonly string[]
|
|
76
|
+
readonly issues: string | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A job's workload key (`expiresAt: null`) or a token the kit signed for it. */
|
|
80
|
+
export interface JobCredential {
|
|
81
|
+
readonly job: KitJobCaller
|
|
82
|
+
readonly expiresAt: number | null
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
/** A write `kitMembership` allowed, by the principal that asked; the credential and the delegation grant are left out. */
|
|
71
86
|
export type AskedWrite =
|
|
72
87
|
| { readonly operation: 'createScope'; readonly principalId: string; readonly scope: Scope; readonly role: string }
|
|
@@ -181,6 +196,7 @@ export function credentialCallers(
|
|
|
181
196
|
keys: ReadonlyMap<string, TestCaller>,
|
|
182
197
|
tokens: ReadonlyMap<string, MintedToken>,
|
|
183
198
|
memberships: () => ReturnType<DirectoryCallers['all']>,
|
|
199
|
+
jobs: ReadonlyMap<string, JobCredential> = new Map(),
|
|
184
200
|
): DirectoryCallers {
|
|
185
201
|
return {
|
|
186
202
|
byCredential(audience, credential) {
|
|
@@ -189,6 +205,10 @@ export function credentialCallers(
|
|
|
189
205
|
if (minted === undefined) return keys.get(credential)
|
|
190
206
|
return minted.expiresAt > now() ? minted.caller : undefined
|
|
191
207
|
},
|
|
208
|
+
jobByCredential(audience, credential) {
|
|
209
|
+
const held = audience === app ? jobs.get(credential) : undefined
|
|
210
|
+
return held !== undefined && (held.expiresAt === null || held.expiresAt > now()) ? held.job : undefined
|
|
211
|
+
},
|
|
192
212
|
all: memberships,
|
|
193
213
|
}
|
|
194
214
|
}
|
|
@@ -221,6 +241,13 @@ function memberCaller(callers: DirectoryCallers, input: MembershipCaller, userOn
|
|
|
221
241
|
|
|
222
242
|
const isFailure = (outcome: LabelledTestCaller | MembershipFailure): outcome is MembershipFailure => 'ok' in outcome
|
|
223
243
|
|
|
244
|
+
/** Who asks for a link operation: a member caller (`issues: null`), or a job and the mount it issues. */
|
|
245
|
+
interface LinkCaller {
|
|
246
|
+
readonly id: string
|
|
247
|
+
readonly perms: readonly PermissionEntry[]
|
|
248
|
+
readonly issues: string | null
|
|
249
|
+
}
|
|
250
|
+
|
|
224
251
|
function dimensionOf(artifact: ContractArtifact, type: string) {
|
|
225
252
|
return Object.hasOwn(artifact.dimensions, type) ? artifact.dimensions[type] : undefined
|
|
226
253
|
}
|
|
@@ -416,6 +443,7 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
|
|
|
416
443
|
const links = new Map<string, KitLink>()
|
|
417
444
|
const live: DirectoryCallers = {
|
|
418
445
|
byCredential: (app, credential) => callers.byCredential(app, credential),
|
|
446
|
+
jobByCredential: (app, credential) => callers.jobByCredential(app, credential),
|
|
419
447
|
all: () => callers.all().map(({ caller, memberships }) => ({ caller, memberships: changes.heldBy(caller.id, memberships) })),
|
|
420
448
|
}
|
|
421
449
|
|
|
@@ -638,10 +666,28 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
|
|
|
638
666
|
return expected === null ? null : `the link chain stops before '${expected}'`
|
|
639
667
|
}
|
|
640
668
|
|
|
641
|
-
/**
|
|
642
|
-
|
|
669
|
+
/**
|
|
670
|
+
* The caller of a link operation, as IAM resolves it: a member caller, or a job that declares `issues`, which holds
|
|
671
|
+
* its declared actions app-wide.
|
|
672
|
+
*/
|
|
673
|
+
const linkCaller = (input: MembershipCaller): LinkCaller | MembershipFailure => {
|
|
674
|
+
const job = input.credential === null ? undefined : callers.jobByCredential(input.app, input.credential)
|
|
675
|
+
if (job !== undefined) {
|
|
676
|
+
if (job.issues === null) return NOT_ALLOWED
|
|
677
|
+
return { id: job.id, perms: job.actions.map((action) => ({ action, scope: null, source: 'grant' })), issues: job.issues }
|
|
678
|
+
}
|
|
643
679
|
const caller = memberCaller(callers, input, false)
|
|
644
|
-
|
|
680
|
+
return isFailure(caller) ? caller : { id: caller.id, perms: permsOf(caller), issues: null }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* A share link, for a holder of the mount's `issuedBy` and of every action it grants on some value of the chain; a
|
|
685
|
+
* job issues only for the mount it declares.
|
|
686
|
+
*/
|
|
687
|
+
const issueLink = (input: IssueLinkInput): IssueLinkResult => {
|
|
688
|
+
const caller = linkCaller(input)
|
|
689
|
+
if ('ok' in caller) return caller
|
|
690
|
+
if (caller.issues !== null && caller.issues !== input.mount) return NOT_ALLOWED
|
|
645
691
|
const mount = Object.hasOwn(artifact.mounts, input.mount) ? artifact.mounts[input.mount] : undefined
|
|
646
692
|
if (mount?.kind !== 'link') return invalid(`unknown link mount '${input.mount}'`)
|
|
647
693
|
const problem = chainProblem(mount.dimension, input.chain)
|
|
@@ -649,7 +695,7 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
|
|
|
649
695
|
if (!Number.isSafeInteger(input.days) || input.days < 1 || input.days > mount.maxDays) {
|
|
650
696
|
return invalid(`a link of '${input.mount}' lives 1 to ${mount.maxDays} days`)
|
|
651
697
|
}
|
|
652
|
-
const perms = [...
|
|
698
|
+
const perms = [...caller.perms]
|
|
653
699
|
const heldOnChain = (action: string): boolean => input.chain.some((scope) => permits(perms, action, scope))
|
|
654
700
|
if (!heldOnChain(mount.issuedBy) || !mount.grants.every(heldOnChain)) return NOT_ALLOWED
|
|
655
701
|
const id = `link-${randomId()}`
|
|
@@ -659,15 +705,19 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
|
|
|
659
705
|
return { ok: true, key, id, expiresAt: now() + input.days * DAY_SECONDS }
|
|
660
706
|
}
|
|
661
707
|
|
|
662
|
-
/**
|
|
708
|
+
/**
|
|
709
|
+
* A link this stand-in issued, by its issuer or a holder of `members.manage` on a value of its chain; a job revokes
|
|
710
|
+
* only a link it issued, of the mount it declares.
|
|
711
|
+
*/
|
|
663
712
|
const revokeLink = (input: RevokeLinkInput): RevokeLinkResult => {
|
|
664
|
-
const caller =
|
|
665
|
-
if (
|
|
713
|
+
const caller = linkCaller(input)
|
|
714
|
+
if ('ok' in caller) return caller
|
|
666
715
|
const link = links.get(input.id)
|
|
667
716
|
if (link === undefined) return NOT_FOUND
|
|
668
|
-
|
|
717
|
+
if (caller.issues !== null && (link.issuer !== caller.id || link.mount !== caller.issues)) return NOT_ALLOWED
|
|
718
|
+
const manager = caller.issues === null && link.chain.some((scope) => {
|
|
669
719
|
const members = dimensionOf(artifact, scope.type)?.members
|
|
670
|
-
return members !== null && members !== undefined && permits([...
|
|
720
|
+
return members !== null && members !== undefined && permits([...caller.perms], members.manage, scope)
|
|
671
721
|
})
|
|
672
722
|
if (link.issuer !== caller.id && !manager) return NOT_ALLOWED
|
|
673
723
|
links.set(link.id, { ...link, revoked: true })
|