@fabrika/app 0.3.3 → 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,142 @@
1
+ // `serve(contract, { database, deps })`: how `src/app.ts` serves a contract of the staged kit. Its handlers sit
2
+ // beside their endpoints, so there is no handler tree to pass: `serve` collects the one `boot` binds by endpoint id.
3
+
4
+ import { AsyncLocalStorage } from 'node:async_hooks'
5
+ import { isProcedure, type ProcedureTreeDeclaration, SURFACE_SECTIONS } from '../contract/declaration'
6
+ import type { AnyContract, Schema } from '../contract/define'
7
+ import { handlerOf, kitOriginOf, snapshotsOf } from '../contract/handled'
8
+ import { ContractError } from '../contract/problems'
9
+ import {
10
+ type ApplicationEnvironment,
11
+ type ContractImplementation,
12
+ contractImplementation,
13
+ type DepsRuntime,
14
+ type ImplementationDatabase,
15
+ } from './implement'
16
+ import { type SiteHandler, siteHandler } from './invocation'
17
+
18
+ /**
19
+ * The application's dependencies, built at each `boot` from its own environment. A handler imports the module that
20
+ * defines them and reads `get()` while it runs: it answers what the boot serving that invocation built. At module load
21
+ * there is nothing to read yet.
22
+ */
23
+ export interface AppDeps<D> {
24
+ get(): D
25
+ }
26
+
27
+ /** Runs `work` with one boot's dependencies as the ones `get()` answers. */
28
+ type WithDeps = <R>(work: () => R) => R
29
+
30
+ /** Builds one boot's dependencies; what `serve` runs at `boot`, since `AppDeps` itself exposes only `get`. */
31
+ type DepsBuild = (env: ApplicationEnvironment, runtime: DepsRuntime) => WithDeps
32
+
33
+ const builds = new WeakMap<AppDeps<unknown>, DepsBuild>()
34
+
35
+ class Deps<D> implements AppDeps<D> {
36
+ /** The dependencies of the boot whose handler is running; two runtimes of one process each keep their own. */
37
+ readonly #current = new AsyncLocalStorage<{ readonly value: D }>()
38
+
39
+ constructor(build: (env: ApplicationEnvironment, runtime: DepsRuntime) => D) {
40
+ builds.set(this, (env, runtime) => {
41
+ const built = { value: build(env, runtime) }
42
+ return (work) => this.#current.run(built, work)
43
+ })
44
+ }
45
+
46
+ get(): D {
47
+ const built = this.#current.getStore()
48
+ if (built === undefined) {
49
+ throw new Error('the dependencies are built at boot: read deps.get() inside a handler, not at module load')
50
+ }
51
+ return built.value
52
+ }
53
+ }
54
+
55
+ /** Declares how each `boot` builds the dependencies; `serve(contract, { deps })` takes the result. */
56
+ export function defineDeps<D>(build: (env: ApplicationEnvironment, runtime: DepsRuntime) => D): AppDeps<D> {
57
+ return new Deps(build)
58
+ }
59
+
60
+ export interface ServeOptions {
61
+ /** `scoped(tables)` of `@fabrika/db`, which `boot` binds, or `null` for a kit without tables (`defineApp(vocabulary)`). */
62
+ readonly database: ImplementationDatabase | null
63
+ readonly deps?: AppDeps<unknown>
64
+ }
65
+
66
+ /**
67
+ * The contract with the handlers its endpoints carry, ready for `boot`. The default export of `src/app.ts`. Throws
68
+ * `ContractError` naming every endpoint, job and webhook that took no `.handle(…)`.
69
+ */
70
+ export function serve<const C extends AnyContract>(contract: C, options: ServeOptions): ContractImplementation<C> {
71
+ const origin = kitOriginOf(contract)
72
+ if (origin === undefined) {
73
+ throw new ContractError(['serve: takes the contract app.contract(…) returned, itself; a copy or another object loses what the kit keeps beside it'])
74
+ }
75
+ handlerTree(contract, (work) => work())
76
+ if ((origin === 'tableless') !== (options.database === null)) {
77
+ throw new ContractError([
78
+ origin === 'tableless'
79
+ ? 'serve: defineApp(vocabulary) declares no tables, so it takes database: null'
80
+ : 'serve: the kit declares tables, so it takes database: scoped(tables) from @fabrika/db',
81
+ ])
82
+ }
83
+ const build = options.deps === undefined ? undefined : builds.get(options.deps)
84
+ return contractImplementation({
85
+ contract,
86
+ database: options.database,
87
+ connect: snapshotsOf(contract),
88
+ handlers: (env, runtime) => handlerTree(contract, build?.(env, runtime) ?? ((work) => work())),
89
+ })
90
+ }
91
+
92
+ /**
93
+ * The tree `boot` reads: one handler per procedure of each RPC and link mount, per webhook, job and export procedure,
94
+ * each run with one boot's dependencies.
95
+ */
96
+ function handlerTree(contract: AnyContract, withDeps: WithDeps): Record<string, unknown> {
97
+ const missing: string[] = []
98
+ const served = (site: { readonly declared?: unknown }, path: string): SiteHandler | undefined => {
99
+ const handler = handlerOf(site)
100
+ if (typeof handler !== 'function') {
101
+ missing.push(path)
102
+ return undefined
103
+ }
104
+ return siteHandler((args) => withDeps(() => Reflect.apply(handler, undefined, [args])), site.declared ?? {})
105
+ }
106
+ const procedureHandlers = <Access>(procedures: ProcedureTreeDeclaration<Schema, Access>, path: string): Record<string, unknown> =>
107
+ Object.fromEntries(
108
+ Object.entries(procedures).map(([name, node]) => [
109
+ name,
110
+ isProcedure(node) ? served(node, `${path}.${name}`) : procedureHandlers(node, `${path}.${name}`),
111
+ ]),
112
+ )
113
+ const tree: Record<string, unknown> = {}
114
+ for (const [name, entry] of Object.entries(contract.surface)) {
115
+ if (SURFACE_SECTIONS.some((section) => section === name) || typeof entry !== 'object' || !('kind' in entry)) {
116
+ continue
117
+ }
118
+ if (entry.kind === 'rpc') {
119
+ tree[name] = procedureHandlers(entry.procedures, name)
120
+ } else if (entry.kind === 'link') {
121
+ tree[name] = procedureHandlers(entry.procedures, name)
122
+ } else if (entry.kind === 'webhook') {
123
+ tree[name] = served(entry, name)
124
+ }
125
+ }
126
+ const jobs = Object.entries(contract.surface.jobs ?? {})
127
+ if (jobs.length > 0) {
128
+ tree['jobs'] = Object.fromEntries(jobs.map(([name, job]) => [name, served(job, `jobs.${name}`)]))
129
+ }
130
+ const exports = Object.entries(contract.surface.exports ?? {})
131
+ if (exports.length > 0) {
132
+ tree['exports'] = Object.fromEntries(
133
+ exports.map(([name, declaration]) => [name, procedureHandlers(declaration.procedures, `exports.${name}`)]),
134
+ )
135
+ }
136
+ if (missing.length > 0) {
137
+ throw new ContractError(
138
+ missing.map((site) => `${site}: add .handle(…) beside its declaration; serve binds every endpoint, job and webhook to its own`),
139
+ )
140
+ }
141
+ return tree
142
+ }
@@ -0,0 +1,215 @@
1
+ // The handler types `implement` checks against the contract: one handler per declared procedure, nothing else, each
2
+ // with the context its access form grants and returning only the fields its output declares.
3
+
4
+ import type { z } from 'zod/v4'
5
+ import type { ConnectSnapshot } from '../contract/connect'
6
+ import type { SurfaceSection } from '../contract/declaration'
7
+ import type { Export, InputOf, InputSchema, LinkMount, MemberAccess, Procedure, RoleOf, RpcMount, Schema, WebhookMount } from '../contract/define'
8
+ import type { JobHandlersOf } from '../jobs/types'
9
+ import type { AppCapability, Ref, Within } from './capability'
10
+ import type { ConnectResult } from './connect'
11
+ import type { LinkIssuing, MemberDirectory, MemberLookup, MemberManagement } from './membership'
12
+ import type { AppPrincipal, LinkHolder, PrincipalOf, ServicePrincipal } from './principal'
13
+ import type { WebhookDelivery, WebhookPrincipal } from './webhook'
14
+
15
+ /** What every handler gets: who called, this request's id, and the signal that ends it. */
16
+ export interface RequestContext<P> {
17
+ readonly principal: P
18
+ readonly requestId: string
19
+ /**
20
+ * Aborts when the client leaves or the request outlives `REQUEST_BUDGET_MS`; its transaction then rolls back. Pass
21
+ * it to every outbound call, so the call stops with the request.
22
+ */
23
+ readonly signal: AbortSignal
24
+ }
25
+
26
+ /** The capability an access form hands. `{ member: true }` hands none. */
27
+ export type AccessContext<Acc> = Acc extends MemberAccess ? unknown
28
+ : Acc extends { readonly action: infer A extends string; readonly over: infer D extends string } ? { readonly within: Within<D, A> }
29
+ : Acc extends { readonly action: infer A extends string; readonly in: infer D extends string } ? { readonly target: Ref<D, A> }
30
+ : Acc extends { readonly action: infer A extends string; readonly on: infer D extends string } ? { readonly target: Ref<D, A> }
31
+ : Acc extends { readonly action: infer A extends string; readonly creates: infer D extends string } ? { readonly created: Ref<D, A> }
32
+ : Acc extends { readonly action: infer A extends string; readonly app: true } ? { readonly app: AppCapability<A> }
33
+ : never
34
+
35
+ type DimensionsOf<C> = C extends { readonly dimensions: infer Dims } ? Dims : never
36
+
37
+ /** The dimension and action of an access form that hands a `Ref` of an existing object. */
38
+ type TargetOf<Acc> = Acc extends { readonly action: infer A extends string; readonly in: infer D extends string } ? readonly [D, A]
39
+ : Acc extends { readonly action: infer A extends string; readonly on: infer D extends string } ? readonly [D, A]
40
+ : never
41
+
42
+ /** The nearest dimension, `D` or one containing it, that declares members; `never` when none does. */
43
+ type DirectoryOf<C, D extends string> = D extends keyof DimensionsOf<C> ? DimensionsOf<C>[D] extends { readonly members: object } ? D
44
+ : DimensionsOf<C>[D] extends { readonly in: infer P extends string } ? DirectoryOf<C, P>
45
+ : never
46
+ : never
47
+
48
+ /**
49
+ * `ctx.members` (ADR-0012 §6): management where the action is the dimension's `members.manage`; otherwise the directory
50
+ * of the nearest dimension that declares members, which lists them only for its `members.list`.
51
+ */
52
+ type MembersContext<C, Acc> = TargetOf<Acc> extends readonly [infer D extends string, infer A extends string]
53
+ ? DimensionsOf<C>[D & keyof DimensionsOf<C>] extends { readonly members: { readonly manage: A } }
54
+ ? { readonly members: MemberManagement<D, A, RoleOf<C>> }
55
+ : [DirectoryOf<C, D>] extends [never] ? unknown
56
+ : DimensionsOf<C>[DirectoryOf<C, D>] extends { readonly members: { readonly list: A } } ? { readonly members: MemberDirectory }
57
+ : { readonly members: MemberLookup }
58
+ : unknown
59
+
60
+ /** The link mounts whose dimension and issuing action are `D` and `A`. */
61
+ type IssuingMounts<C, D extends string, A extends string> =
62
+ & {
63
+ [M in keyof SurfaceOf<C>]: SurfaceOf<C>[M] extends { readonly kind: 'link'; readonly dimension: D; readonly issuedBy: A } ? M : never
64
+ }[keyof SurfaceOf<C>]
65
+ & string
66
+
67
+ /** `ctx.links`, only where the `Ref` carries a link mount's issuing action for its dimension. */
68
+ type LinksContext<C, Acc> = TargetOf<Acc> extends readonly [infer D extends string, infer A extends string]
69
+ ? [IssuingMounts<C, D, A>] extends [never] ? unknown : { readonly links: LinkIssuing<D, A, IssuingMounts<C, D, A>> }
70
+ : unknown
71
+
72
+ /** What a procedure's context carries for its access form in this contract. */
73
+ export type ContractContext<C, Acc> = AccessContext<Acc> & MembersContext<C, Acc> & LinksContext<C, Acc>
74
+
75
+ /** The input without the `from` field: the only copy of that id a handler has is the checked capability. */
76
+ export type HandlerInput<I, Acc> = Acc extends { readonly from: infer F extends string }
77
+ ? { readonly [K in keyof InputOf<I> as K extends F ? never : K]: InputOf<I>[K] }
78
+ : InputOf<I>
79
+
80
+ export type Handler<In, Ctx, O extends Schema> = (args: { readonly input: In; readonly ctx: Ctx }) => z.input<O> | Promise<z.input<O>>
81
+
82
+ // ── Connectors: `ctx.connect` in the handlers an import's `usedBy` names, typed by the vendored snapshot ────────
83
+
84
+ type ImportsOf<C> = SurfaceOf<C> extends { readonly imports: infer I } ? I : Record<never, never>
85
+
86
+ /** What `implement({ connect })` takes: the vendored snapshot of each import, by the import's name. */
87
+ export type ConnectSnapshots<C> = { readonly [K in keyof ImportsOf<C>]: ConnectSnapshot }
88
+
89
+ type ImportsUsedBy<C, Id extends string> = {
90
+ [K in keyof ImportsOf<C>]: ImportsOf<C>[K] extends { readonly usedBy: readonly (infer U)[] } ? Id extends U ? K : never : never
91
+ }[keyof ImportsOf<C>]
92
+
93
+ type ImportedProcedures<C, K> = K extends keyof ImportsOf<C>
94
+ ? ImportsOf<C>[K] extends { readonly procedures: readonly (infer P extends string)[] } ? P : never
95
+ : never
96
+
97
+ /** A connector call never throws for the owner's answer: it resolves to a typed outcome. */
98
+ export type ConnectCall<I, O extends Schema> = [I] extends [InputSchema] ? (input: z.input<I>) => Promise<ConnectResult<z.output<O>>>
99
+ : (input?: Record<never, never>) => Promise<ConnectResult<z.output<O>>>
100
+
101
+ type ConnectorOf<S, P extends string> = {
102
+ readonly [Path in P]: S extends
103
+ { readonly procedures: { readonly [Q in Path]: { readonly input: infer I; readonly output: infer O extends Schema } } } ? ConnectCall<I, O>
104
+ : never
105
+ }
106
+
107
+ /** `ctx.connect` with the imports whose `usedBy` names endpoint `Id`; nothing for any other endpoint. */
108
+ export type ConnectContext<C, Snap, Id extends string> = [ImportsUsedBy<C, Id>] extends [never] ? unknown
109
+ : { readonly connect: { readonly [K in ImportsUsedBy<C, Id> & keyof Snap]: ConnectorOf<Snap[K], ImportedProcedures<C, K>> } }
110
+
111
+ type RpcHandlers<C, P, T, Snap, Prefix extends string> = {
112
+ readonly [K in keyof T]: T[K] extends Procedure<'query' | 'mutation', infer I, infer O, infer Acc> ? Handler<
113
+ HandlerInput<I, Acc>,
114
+ RequestContext<P> & ContractContext<C, Acc> & ConnectContext<C, Snap, `${Prefix}${K & string}`>,
115
+ O
116
+ >
117
+ : RpcHandlers<C, P, T[K], Snap, `${Prefix}${K & string}.`>
118
+ }
119
+
120
+ /** An export handler runs read-only: its context carries its capability and nothing that writes. */
121
+ type ExportHandlers<T> = {
122
+ readonly [K in keyof T]: T[K] extends Procedure<'query', infer I, infer O, infer Acc>
123
+ ? Handler<HandlerInput<I, Acc>, RequestContext<ServicePrincipal | AppPrincipal> & AccessContext<Acc>, O>
124
+ : ExportHandlers<T[K]>
125
+ }
126
+
127
+ type ExportsOf<C> = SurfaceOf<C> extends { readonly exports: infer E } ? E : Record<never, never>
128
+
129
+ type ExportHandlersOf<C> = [keyof ExportsOf<C>] extends [never] ? unknown
130
+ : { readonly exports: { readonly [E in keyof ExportsOf<C>]: ExportsOf<C>[E] extends Export<infer T> ? ExportHandlers<T> : never } }
131
+
132
+ /** A link procedure acts on the object its key is bound to. */
133
+ type LinkHandlers<D extends string, T> = {
134
+ readonly [K in keyof T]: T[K] extends Procedure<'query' | 'mutation', infer I, infer O, { readonly action: infer A extends string }>
135
+ ? Handler<InputOf<I>, RequestContext<LinkHolder> & { readonly target: Ref<D, A> }, O>
136
+ : LinkHandlers<D, T[K]>
137
+ }
138
+
139
+ /**
140
+ * A delivery's handler: the declared input, a `Ref` of the bound value for each declared action, and the delivery's
141
+ * stable key. It runs at least once per delivery: a failed run is run again on GitHub's redelivery.
142
+ */
143
+ export type WebhookHandler<B extends string, Acts extends string, I> = (args: {
144
+ readonly input: z.output<I>
145
+ readonly ctx: RequestContext<WebhookPrincipal> & { readonly refs: { readonly [A in Acts]: Ref<B, A> }; readonly delivery: WebhookDelivery }
146
+ }) => void | Promise<void>
147
+
148
+ type MountHandlers<C, M, Name extends string, Snap> = M extends RpcMount<infer Callers, infer T>
149
+ ? RpcHandlers<C, PrincipalOf<Callers[number]>, T, Snap, `${Name}.`>
150
+ : M extends LinkMount<infer D, string, string, infer T> ? LinkHandlers<D, T>
151
+ : M extends WebhookMount<infer B, infer Acts, infer I> ? WebhookHandler<B, Acts, I>
152
+ : never
153
+
154
+ type ServedMount<M> = M extends { readonly kind: 'rpc' | 'link' | 'webhook' } ? true : false
155
+
156
+ type SurfaceOf<C> = C extends { readonly surface: infer S } ? S : never
157
+
158
+ /**
159
+ * One handler tree per procedure mount and one handler per webhook, keyed like the contract, one per job under `jobs`,
160
+ * and the export handlers under `exports`.
161
+ */
162
+ export type HandlersOf<C, Snap = unknown> =
163
+ & {
164
+ readonly [M in keyof SurfaceOf<C> as M extends SurfaceSection ? never : ServedMount<SurfaceOf<C>[M]> extends true ? M : never]: MountHandlers<
165
+ C,
166
+ SurfaceOf<C>[M],
167
+ M & string,
168
+ Snap
169
+ >
170
+ }
171
+ & JobHandlersOf<C>
172
+ & ExportHandlersOf<C>
173
+
174
+ // ── Exactness: no handler outside the contract, no field outside the declared output ────────────
175
+
176
+ /** An `any` comes from an error elsewhere in the handler; reporting it here too would only add noise. */
177
+ export type IsAny<T> = 0 extends 1 & T ? true : false
178
+
179
+ /** Fields of `R` that the output `O` does not declare. When `O` is a union, a value that fits one branch has none. */
180
+ export type ExtraKeys<R, O> = IsAny<R> extends true ? never
181
+ : true extends (O extends unknown ? ([ExtraKeysOf<R, O>] extends [never] ? true : false) : never) ? never
182
+ : ExtraKeysOf<R, O>
183
+
184
+ type ExtraKeysOf<R, O> = R extends readonly (infer RE)[] ? (O extends readonly (infer OE)[] ? ExtraKeys<RE, OE> : never)
185
+ : R extends object ? O extends object ? Exclude<keyof R, keyof O> | { [Q in keyof R & keyof O]: ExtraKeys<R[Q], O[Q]> }[keyof R & keyof O]
186
+ : never
187
+ : never
188
+
189
+ type Returned<F> = F extends (args: never) => infer R ? Awaited<R> : never
190
+
191
+ type Undeclared<F, Expected> = ExtraKeys<Returned<F>, Returned<Expected>>
192
+
193
+ /** No undeclared field, or a field whose type is an error reported where it arose. */
194
+ type NoneOrError<U> = [U] extends [never] ? true : IsAny<U>
195
+
196
+ type ExactTree<H, E, Path extends string> = {
197
+ readonly [K in keyof H]: K extends keyof E ? E[K] extends (args: never) => unknown ? NoneOrError<Undeclared<H[K], E[K]>> extends true ? H[K]
198
+ : {
199
+ readonly [_ in `handler '${Path}${K & string}' returns fields the contract output does not declare. Return only declared fields`]: Undeclared<
200
+ H[K],
201
+ E[K]
202
+ >
203
+ }
204
+ : ExactTree<H[K], E[K], `${Path}${K & string}.`>
205
+ : `'${Path}${K & string}' is not in the contract. Declare the endpoint in fabrika.contract.ts first.`
206
+ }
207
+
208
+ type KindOf<M> = M extends { readonly kind: infer K extends string } ? K : 'section'
209
+
210
+ export type ExactHandlers<H, C> = {
211
+ readonly [M in keyof H]: M extends keyof HandlersOf<C> ? HandlersOf<C>[M] extends (args: never) => unknown ? H[M]
212
+ : ExactTree<H[M], HandlersOf<C>[M], `${M & string}.`>
213
+ : M extends keyof SurfaceOf<C> ? `'${M & string}' is a ${KindOf<SurfaceOf<C>[M]>} mount; it takes no handler. Remove it from handlers.`
214
+ : `'${M & string}' is not in the contract. Declare the mount in fabrika.contract.ts first.`
215
+ }
@@ -0,0 +1,108 @@
1
+ // `c.webhook` for GitHub (docs/reference/application-runtime.md). A holder of the bound dimension's `members.manage`
2
+ // creates a binding through the generated `webhooks.<name>.bind` endpoint; the framework generates the secret and
3
+ // keeps the binding in `fabrika_webhook_bindings`. A delivery is checked in a fixed order — the binding, the
4
+ // signature over the raw body, the account, the input, the delivery id — before its handler sees a `Ref` of the
5
+ // bound value only.
6
+
7
+ import type { WebhookTables } from '@fabrika/db/framework'
8
+ import { uuidv7 } from '@fabrika/platform'
9
+ import { z } from 'zod/v4'
10
+ import { joinPath, WEBHOOK_ACCOUNT_PATTERN, WEBHOOK_SCOPE_FIELD } from '../contract/defaults'
11
+ import { NotFoundError } from '../errors'
12
+ import { Ref } from './capability'
13
+
14
+ /** The input and output of the generated binding endpoints; `build.ts`'s `WEBHOOK_BIND_*` shapes describe them. */
15
+ export const webhookBindInput = z.strictObject({
16
+ [WEBHOOK_SCOPE_FIELD]: z.string().min(1),
17
+ account: z.string().regex(new RegExp(WEBHOOK_ACCOUNT_PATTERN)),
18
+ })
19
+ export const webhookBindOutput = z.strictObject({ id: z.uuid(), url: z.string().min(1), secret: z.string().min(1) })
20
+ export const webhookUnbindInput = z.strictObject({ [WEBHOOK_SCOPE_FIELD]: z.string().min(1), id: z.uuid() })
21
+ export const webhookUnbindOutput = z.strictObject({ id: z.uuid() })
22
+
23
+ /** The workload a delivery runs as: no person, only the binding. It creates no scope and manages no member. */
24
+ export interface WebhookPrincipal {
25
+ readonly kind: 'webhook'
26
+ readonly bindingId: string
27
+ }
28
+
29
+ /** The principal id a delivery's capabilities are granted to. */
30
+ export function webhookSubject(bindingId: string): string {
31
+ return `webhook:${bindingId}`
32
+ }
33
+
34
+ /**
35
+ * A delivery key is kept this long per binding. GitHub signs no timestamp, so a body replayed after the window
36
+ * runs again; GitHub itself redelivers for three days.
37
+ */
38
+ export const WEBHOOK_REPLAY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
39
+
40
+ /**
41
+ * `sha256:<hex>` of the binding id and the signed body: what replay protection and an idempotent handler key on.
42
+ * A redelivery of one event has the same key, whatever its `X-GitHub-Delivery` says.
43
+ */
44
+ export async function deliveryKey(bindingId: string, body: Uint8Array<ArrayBuffer>): Promise<string> {
45
+ const id = new TextEncoder().encode(bindingId)
46
+ const content = new Uint8Array(id.byteLength + body.byteLength)
47
+ content.set(id, 0)
48
+ content.set(body, id.byteLength)
49
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', content))
50
+ return `sha256:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`
51
+ }
52
+
53
+ /** What a handler knows of its delivery. Handlers run at least once: write idempotently, keyed on `key`. */
54
+ export interface WebhookDelivery {
55
+ readonly key: string
56
+ }
57
+
58
+ /** 32 random bytes as hex: the HMAC key GitHub signs with, shown once to the member who binds. */
59
+ function generateSecret(): string {
60
+ const bytes = crypto.getRandomValues(new Uint8Array(32))
61
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
62
+ }
63
+
64
+ export interface BindingHandlers {
65
+ bind(args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object }): Promise<z.output<typeof webhookBindOutput>>
66
+ unbind(args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object }): Promise<z.output<typeof webhookUnbindOutput>>
67
+ }
68
+
69
+ /** What the `in` access form handed a binding endpoint: the checked `Ref` of the value, and the person. */
70
+ function accessOf(ctx: object): { readonly scope: Ref<string, string>; readonly principal: string } {
71
+ const target: unknown = Reflect.get(ctx, 'target')
72
+ const principal: unknown = Reflect.get(ctx, 'principal')
73
+ const id: unknown = typeof principal === 'object' && principal !== null ? Reflect.get(principal, 'id') : undefined
74
+ if (!(target instanceof Ref) || typeof id !== 'string') {
75
+ throw new TypeError('a binding endpoint runs for a person, with the Ref of its in access form')
76
+ }
77
+ return { scope: target, principal: id }
78
+ }
79
+
80
+ /** The framework's handlers of `webhooks.<name>.bind` and `.unbind`. */
81
+ export function bindingHandlers(webhook: string, path: string, tables: () => WebhookTables): BindingHandlers {
82
+ return {
83
+ async bind({ input, ctx }) {
84
+ const { scope, principal } = accessOf(ctx)
85
+ const account = z.parse(webhookBindInput.shape.account, input['account'])
86
+ const id = uuidv7()
87
+ const secret = generateSecret()
88
+ await tables().createBinding({
89
+ id,
90
+ webhook,
91
+ scope: { type: scope.dimension, value: scope.value },
92
+ account,
93
+ secret,
94
+ createdBy: principal,
95
+ createdAt: Date.now(),
96
+ })
97
+ return { id, url: joinPath(path, id), secret }
98
+ },
99
+ async unbind({ input, ctx }) {
100
+ const { scope } = accessOf(ctx)
101
+ const id = z.parse(webhookUnbindInput.shape.id, input['id'])
102
+ if (!(await tables().removeBinding(webhook, id, { type: scope.dimension, value: scope.value }))) {
103
+ throw new NotFoundError('Not found')
104
+ }
105
+ return { id }
106
+ },
107
+ }
108
+ }
@@ -0,0 +1,160 @@
1
+ // A job's and a webhook's authority (ADR-0022 "Principal kinds"): Control reconciles one `px_` key per workload from
2
+ // the approved snapshot and writes it as `FABRIKA_<KIND>_<NAME>_KEY`; IAM signs a token from it carrying the actions
3
+ // the reconciled schema declares. The framework mints a capability only for an action that token carries, so the
4
+ // authority comes from IAM, not from the application's own copy of the contract.
5
+
6
+ import {
7
+ API_KEY_PREFIX,
8
+ type ContractArtifact,
9
+ type MintFromKeyResult,
10
+ type PermissionEntry,
11
+ TOKEN_REFRESH_SKEW_SECONDS,
12
+ WORKLOAD_TOKEN_PATH,
13
+ workloadKeyVariable,
14
+ } from '@fabrika/auth-core'
15
+ import type { TokenVerifier } from '@fabrika/jwks'
16
+ import { z } from 'zod/v4'
17
+
18
+ export type WorkloadKindOf = 'job' | 'webhook'
19
+
20
+ /** Where a workload exchanges its key for a token: IAM's public `WORKLOAD_TOKEN_PATH`, or a stand-in in a test. */
21
+ export interface WorkloadTokenSource {
22
+ exchange(input: { readonly app: string; readonly key: string }): Promise<MintFromKeyResult>
23
+ }
24
+
25
+ /** The token exchange is a cold path, once per token lifetime; it is still bounded. */
26
+ const EXCHANGE_TIMEOUT_MS = 5_000
27
+
28
+ const mintResultSchema = z.union([
29
+ z.strictObject({ ok: z.literal(true), token: z.string().min(1), expiresAt: z.number() }),
30
+ z.strictObject({ ok: z.literal(false), reason: z.enum(['invalid_key', 'unknown_principal', 'disabled', 'token_too_large']) }),
31
+ ])
32
+
33
+ /** IAM's token exchange at its public origin. An answer that is not IAM's decided outcome throws: "IAM could not be asked". */
34
+ export function issuerWorkloadTokens(
35
+ issuer: string,
36
+ fetcher: (request: Request) => Promise<Response> = (request) => fetch(request),
37
+ ): WorkloadTokenSource {
38
+ return {
39
+ async exchange(input) {
40
+ const response = await fetcher(
41
+ new Request(new URL(WORKLOAD_TOKEN_PATH, issuer), {
42
+ method: 'POST',
43
+ headers: { authorization: `Bearer ${input.key}`, 'content-type': 'application/json' },
44
+ body: JSON.stringify({ app: input.app }),
45
+ redirect: 'manual',
46
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
47
+ }),
48
+ )
49
+ const parsed = response.status === 200 ? mintResultSchema.safeParse(await response.json()) : null
50
+ if (parsed === null || !parsed.success) {
51
+ throw new Error(`IAM answered the workload token exchange with ${response.status}`)
52
+ }
53
+ return parsed.data
54
+ },
55
+ }
56
+ }
57
+
58
+ /** What the workload may do now, or why it may do nothing. */
59
+ export type WorkloadAuthority =
60
+ /** `token` is the bearer IAM signed: what a job that issues links presents to IAM. */
61
+ | { readonly ok: true; readonly perms: readonly PermissionEntry[]; readonly token: string }
62
+ /** IAM refused the key, or signed a token that is not this workload's: a decided negative. */
63
+ | { readonly ok: false; readonly reason: 'refused' }
64
+ /** IAM or its keys could not be reached. */
65
+ | { readonly ok: false; readonly reason: 'unavailable' }
66
+
67
+ export interface WorkloadCredentialOptions {
68
+ readonly kind: WorkloadKindOf
69
+ readonly name: string
70
+ readonly key: string
71
+ /** This application's IAM id, `<app>@<env>`: the token's audience. */
72
+ readonly audience: string
73
+ readonly source: WorkloadTokenSource
74
+ readonly verifier: TokenVerifier
75
+ }
76
+
77
+ /** One workload's key, exchanged for a token when none is cached or the cached one is about to expire. */
78
+ export class WorkloadCredential {
79
+ readonly #options: WorkloadCredentialOptions
80
+ #cached: { readonly perms: readonly PermissionEntry[]; readonly token: string; readonly exp: number } | null = null
81
+ #pending: Promise<WorkloadAuthority> | null = null
82
+
83
+ constructor(options: WorkloadCredentialOptions) {
84
+ this.#options = options
85
+ }
86
+
87
+ authority(): Promise<WorkloadAuthority> {
88
+ const cached = this.#cached
89
+ if (cached !== null && cached.exp - Math.floor(Date.now() / 1000) > TOKEN_REFRESH_SKEW_SECONDS) {
90
+ return Promise.resolve({ ok: true, perms: cached.perms, token: cached.token })
91
+ }
92
+ this.#pending ??= this.#exchange().finally(() => {
93
+ this.#pending = null
94
+ })
95
+ return this.#pending
96
+ }
97
+
98
+ async #exchange(): Promise<WorkloadAuthority> {
99
+ const { kind, name, key, audience, source, verifier } = this.#options
100
+ let minted: MintFromKeyResult
101
+ try {
102
+ minted = await source.exchange({ app: audience, key })
103
+ } catch {
104
+ // The error may quote IAM's URL or a transport detail; the line names the workload only.
105
+ console.error(`fabrika: the ${kind} '${name}' could not exchange its key at IAM: iam_unavailable`)
106
+ return { ok: false, reason: 'unavailable' }
107
+ }
108
+ if (!minted.ok) {
109
+ console.error(`fabrika: IAM refused the key of the ${kind} '${name}': ${minted.reason}`)
110
+ return { ok: false, reason: 'refused' }
111
+ }
112
+ const verified = await verifier.verify(minted.token, audience)
113
+ if (!verified.ok) {
114
+ return verified.reason === 'unavailable' ? { ok: false, reason: 'unavailable' } : { ok: false, reason: 'refused' }
115
+ }
116
+ const { claims } = verified
117
+ if (claims.ptype !== kind || claims.workload !== name) {
118
+ console.error(`fabrika: IAM signed a token for the key of the ${kind} '${name}' that is not this workload's`)
119
+ return { ok: false, reason: 'refused' }
120
+ }
121
+ const perms = Object.freeze(claims.perms.map((entry) => Object.freeze({ ...entry })))
122
+ this.#cached = { perms, token: minted.token, exp: claims.exp }
123
+ return { ok: true, perms, token: minted.token }
124
+ }
125
+ }
126
+
127
+ export interface WorkloadCredentialsInput {
128
+ readonly artifact: ContractArtifact
129
+ readonly framework: Readonly<Record<string, string>>
130
+ readonly audience: string
131
+ readonly source: WorkloadTokenSource | undefined
132
+ readonly verifier: TokenVerifier
133
+ }
134
+
135
+ /** `job:<name>` and `webhook:<name>` → the workload's credential, or why one cannot start to `problems`. */
136
+ export function workloadCredentials(input: WorkloadCredentialsInput, problems: string[]): ReadonlyMap<string, WorkloadCredential> {
137
+ const workloads: [WorkloadKindOf, string][] = [
138
+ ...Object.keys(input.artifact.jobs).map((name): [WorkloadKindOf, string] => ['job', name]),
139
+ ...Object.entries(input.artifact.mounts).flatMap((
140
+ [name, mount],
141
+ ): [WorkloadKindOf, string][] => (mount.kind === 'webhook' ? [['webhook', name]] : [])),
142
+ ]
143
+ const credentials = new Map<string, WorkloadCredential>()
144
+ if (workloads.length > 0 && input.source === undefined) {
145
+ problems.push('the contract declares jobs or webhooks, and the runtime adapter supplies no IAM token exchange for their keys')
146
+ }
147
+ for (const [kind, name] of workloads) {
148
+ const variable = workloadKeyVariable(kind, name)
149
+ const key = input.framework[variable]?.trim() ?? ''
150
+ if (!key.startsWith(API_KEY_PREFIX) || key.length <= API_KEY_PREFIX.length) {
151
+ problems.push(`${variable} is not set to a px_ key; Control writes the credential of the ${kind} '${name}' into every deploy`)
152
+ } else if (input.source !== undefined) {
153
+ credentials.set(
154
+ `${kind}:${name}`,
155
+ new WorkloadCredential({ kind, name, key, audience: input.audience, source: input.source, verifier: input.verifier }),
156
+ )
157
+ }
158
+ }
159
+ return credentials
160
+ }