@fabrika/app 0.4.0 → 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.
@@ -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 { credentialCallers, type KitMembership, kitMembership, type MintedToken, type TestMembership } from './membership'
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
- export function bootTestKit<C extends AnyContract>(app: ContractImplementation<C>, options: TestKitOptions): BootedTestKit<C> {
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
- return {
265
- ok: true,
266
- token: await new SignJWT({ ...claims }).setProtectedHeader({ alg: TOKEN_ALG, kid: KEY_ID }).sign(privateKey),
267
- expiresAt: claims.exp,
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(appId, byKey, byToken, () => [...memberships].map(([caller, held]) => ({ caller, memberships: held })))
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
- /** A share link, for a holder of the mount's `issuedBy` and of every action it grants on some value of the chain. */
642
- const issueLink = (input: IssueLinkInput): IssueLinkResult => {
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
- if (isFailure(caller)) return caller
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 = [...permsOf(caller)]
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
- /** A link this stand-in issued, by its issuer or a holder of `members.manage` on a value of its chain. */
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 = memberCaller(callers, input, false)
665
- if (isFailure(caller)) return caller
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
- const manager = link.chain.some((scope) => {
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([...permsOf(caller)], members.manage, scope)
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 })