@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,165 @@
1
+ // `FabrikaProvider`: the page's clients, the cache of their answers, and the caller document of the SPA mount. The
2
+ // hooks take a procedure of a client as the typed name of a call; the provider runs it on the client it holds for
3
+ // that mount, so a test hands in a client over the test kit and the page's own module-level client stays as it is.
4
+
5
+ import { createContext, type ReactNode, useContext, useState } from 'react'
6
+ import { z } from 'zod/v4'
7
+ import { type PageCaller, parsePageCaller } from '../client/caller'
8
+ import { type ClientNode, clientNodeOf } from '../client/contract'
9
+ import { RpcError, type RpcFetch } from '../client/transport'
10
+ import { joinPath, PAGE_CALLER_FILE } from '../contract/defaults'
11
+ import { type FabrikaError, toFabrikaError } from './errors'
12
+ import { QueryStore, type StoreTarget } from './store'
13
+
14
+ export type CallerState =
15
+ | { readonly status: 'loading' }
16
+ | { readonly status: 'ready'; readonly caller: PageCaller }
17
+ | { readonly status: 'error'; readonly error: FabrikaError }
18
+
19
+ /** The caller document, read once per provider when a view first asks for it. */
20
+ export class CallerSource {
21
+ state: CallerState = { status: 'loading' }
22
+ #started = false
23
+ readonly #listeners = new Set<() => void>()
24
+
25
+ constructor(private readonly url: string, private readonly fetch: RpcFetch) {}
26
+
27
+ subscribe(listener: () => void): () => void {
28
+ this.#listeners.add(listener)
29
+ if (!this.#started) {
30
+ this.#started = true
31
+ void this.#load()
32
+ }
33
+ return () => this.#listeners.delete(listener)
34
+ }
35
+
36
+ async #load(): Promise<void> {
37
+ this.state = await this.#read()
38
+ for (const listener of [...this.#listeners]) listener()
39
+ }
40
+
41
+ async #read(): Promise<CallerState> {
42
+ try {
43
+ const response = await this.fetch(this.url, { credentials: 'same-origin' })
44
+ if (!response.ok) {
45
+ return {
46
+ status: 'error',
47
+ error: toFabrikaError(new RpcError({ type: 'caller', message: `HTTP ${response.status}`, httpStatus: response.status })),
48
+ }
49
+ }
50
+ return { status: 'ready', caller: parsePageCaller(await response.json()) }
51
+ } catch (error) {
52
+ return { status: 'error', error: toFabrikaError(error) }
53
+ }
54
+ }
55
+ }
56
+
57
+ export interface Fabrika {
58
+ readonly store: QueryStore
59
+ readonly caller: CallerSource
60
+ /** The call of `procedure` on the provider's client of its mount. */
61
+ target(procedure: unknown, hook: string): ResolvedProcedure
62
+ }
63
+
64
+ export interface ResolvedProcedure extends StoreTarget {
65
+ readonly node: ClientNode
66
+ }
67
+
68
+ const FabrikaContext = createContext<Fabrika | null>(null)
69
+
70
+ /** The `{ path, code, message }` issues of a refused input, as the application answers them. */
71
+ function issuesOf(error: z.core.$ZodError): readonly { readonly path: string; readonly code: string; readonly message: string }[] {
72
+ return error.issues.map((issue) => ({ path: issue.path.map(String).join('.'), code: issue.code, message: issue.message }))
73
+ }
74
+
75
+ /** Checks the input with the procedure's own schema first: a refused input is the application's 400, unsent. */
76
+ function validated(node: ClientNode, input: unknown): unknown {
77
+ const schema = node.procedure?.input ?? null
78
+ if (schema === null) return input
79
+ const parsed = z.safeParse(schema, input ?? {})
80
+ if (parsed.success) return input
81
+ const issues = issuesOf(parsed.error)
82
+ const first = issues[0]
83
+ throw new RpcError({
84
+ type: 'validation',
85
+ message: first === undefined ? 'Invalid input' : `${first.path || 'input'}: ${first.message}`,
86
+ issues,
87
+ httpStatus: 400,
88
+ })
89
+ }
90
+
91
+ function idOf(node: ClientNode): string {
92
+ return node.path === '' ? node.mount : `${node.mount}.${node.path}`
93
+ }
94
+
95
+ function procedureAt(client: object, path: string): unknown {
96
+ let value: unknown = client
97
+ for (const segment of path.split('.')) {
98
+ value = typeof value === 'object' && value !== null ? Reflect.get(value, segment) : undefined
99
+ }
100
+ return value
101
+ }
102
+
103
+ function fabrika(clients: readonly object[], spa: string, fetch: RpcFetch): Fabrika {
104
+ const byMount = new Map<string, object>()
105
+ for (const client of clients) {
106
+ const node = clientNodeOf(client)
107
+ if (node === null || node.path !== '') throw new Error('FabrikaProvider takes the clients createClient made')
108
+ if (byMount.has(node.mount)) throw new Error(`FabrikaProvider holds two clients of mount '${node.mount}'`)
109
+ byMount.set(node.mount, client)
110
+ }
111
+ return {
112
+ store: new QueryStore(toFabrikaError),
113
+ caller: new CallerSource(joinPath(spa, PAGE_CALLER_FILE), fetch),
114
+ target(procedure, hook) {
115
+ const node = clientNodeOf(procedure)
116
+ if (node?.procedure == null) throw new Error(`${hook} takes a procedure of a client createClient made`)
117
+ const client = byMount.get(node.mount)
118
+ if (client === undefined) throw new Error(`${hook}: the FabrikaProvider holds no client of mount '${node.mount}'`)
119
+ const call = procedureAt(client, node.path)
120
+ if (typeof call !== 'function') throw new Error(`${hook}: '${idOf(node)}' is not a procedure of the provider's client`)
121
+ return {
122
+ id: idOf(node),
123
+ node,
124
+ run: async (input) => {
125
+ const answer: unknown = await Reflect.apply(call, undefined, [validated(node, input)])
126
+ return answer
127
+ },
128
+ }
129
+ },
130
+ }
131
+ }
132
+
133
+ export interface FabrikaProviderProps {
134
+ /** The clients of the mounts the page calls, as `createClient` made them; read at the first render. */
135
+ readonly clients: readonly object[]
136
+ /** The prefix of the SPA mount the page is served from: the caller document is `<spa>/__fabrika-me.json`. */
137
+ readonly spa?: string
138
+ /** What the caller document is fetched with; the global `fetch` by default (a test passes the kit's). */
139
+ readonly fetch?: RpcFetch
140
+ readonly children?: ReactNode
141
+ }
142
+
143
+ export function FabrikaProvider({ clients, spa = '/', fetch: fetchCaller, children }: FabrikaProviderProps): ReactNode {
144
+ const [value] = useState(() => fabrika(clients, spa, fetchCaller ?? ((input, init) => fetch(input, init))))
145
+ return <FabrikaContext.Provider value={value}>{children}</FabrikaContext.Provider>
146
+ }
147
+
148
+ export function useFabrika(hook: string): Fabrika {
149
+ const value = useContext(FabrikaContext)
150
+ if (value === null) throw new Error(`${hook} is used outside a FabrikaProvider`)
151
+ return value
152
+ }
153
+
154
+ /** The prefix a mutation invalidates by default: the group it lies in (`api.deals` for `api.deals.create`). */
155
+ export function parentOf(node: ClientNode): string {
156
+ const segments = node.path.split('.')
157
+ return segments.length <= 1 ? node.mount : `${node.mount}.${segments.slice(0, -1).join('.')}`
158
+ }
159
+
160
+ /** The prefix of a procedure or a group, for `invalidate`. */
161
+ export function prefixOf(target: unknown): string {
162
+ const node = clientNodeOf(target)
163
+ if (node === null) throw new Error('invalidate takes a procedure or a group of a client createClient made')
164
+ return idOf(node)
165
+ }
@@ -0,0 +1,215 @@
1
+ // The provider's cache, without React: one entry per procedure and input (and one per paged list), each with the
2
+ // pages it holds, a snapshot the hooks subscribe to, and a sequence number so a slower, older answer never overwrites
3
+ // a newer one. `invalidate` refetches the entries a view shows and drops the others.
4
+
5
+ import type { FabrikaError } from './errors'
6
+
7
+ /** A procedure as the store calls it: its id (`<mount>.<path>`) and the call of the provider's client. */
8
+ export interface StoreTarget {
9
+ readonly id: string
10
+ readonly run: (input: unknown) => Promise<unknown>
11
+ }
12
+
13
+ export type QueryStatus = 'loading' | 'success' | 'error'
14
+
15
+ export interface QuerySnapshot {
16
+ readonly status: QueryStatus
17
+ readonly data: unknown
18
+ readonly error: FabrikaError | null
19
+ /** A request of this entry is in flight: the first load, a refetch or the next page. */
20
+ readonly fetching: boolean
21
+ }
22
+
23
+ export interface PagesSnapshot {
24
+ readonly status: QueryStatus
25
+ readonly items: readonly unknown[]
26
+ readonly next: string | null
27
+ readonly error: FabrikaError | null
28
+ readonly fetching: boolean
29
+ readonly loadingMore: boolean
30
+ }
31
+
32
+ /** One page of a cursor list, as every list procedure of the paved road answers it. */
33
+ export interface Page {
34
+ readonly items: readonly unknown[]
35
+ readonly next: string | null
36
+ }
37
+
38
+ type Listener = () => void
39
+
40
+ /** JSON with sorted keys, so two inputs that are the same value are the same key. */
41
+ export function stableKey(value: unknown): string {
42
+ return JSON.stringify(value, (_key, field: unknown) => {
43
+ if (typeof field !== 'object' || field === null || Array.isArray(field)) return field
44
+ return Object.fromEntries(Object.entries(field).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
45
+ }) ?? 'null'
46
+ }
47
+
48
+ /** `<mount>.<path>` lies under `prefix` when it is the prefix itself or one of its descendants. */
49
+ export function under(id: string, prefix: string): boolean {
50
+ return id === prefix || id.startsWith(`${prefix}.`)
51
+ }
52
+
53
+ abstract class Entry<S> {
54
+ snapshot: S
55
+ protected sequence = 0
56
+ readonly #listeners = new Set<Listener>()
57
+
58
+ constructor(readonly target: StoreTarget, readonly input: unknown, initial: S) {
59
+ this.snapshot = initial
60
+ }
61
+
62
+ get watched(): boolean {
63
+ return this.#listeners.size > 0
64
+ }
65
+
66
+ subscribe(listener: Listener): () => void {
67
+ this.#listeners.add(listener)
68
+ return () => this.#listeners.delete(listener)
69
+ }
70
+
71
+ protected publish(snapshot: S): void {
72
+ this.snapshot = snapshot
73
+ for (const listener of [...this.#listeners]) listener()
74
+ }
75
+
76
+ abstract load(): Promise<void>
77
+ }
78
+
79
+ export class QueryEntry extends Entry<QuerySnapshot> {
80
+ constructor(target: StoreTarget, input: unknown, private readonly mapError: (error: unknown) => FabrikaError) {
81
+ super(target, input, { status: 'loading', data: undefined, error: null, fetching: false })
82
+ }
83
+
84
+ async load(): Promise<void> {
85
+ const sequence = ++this.sequence
86
+ this.publish({ ...this.snapshot, fetching: true })
87
+ try {
88
+ const data = await this.target.run(this.input)
89
+ if (sequence !== this.sequence) return
90
+ this.publish({ status: 'success', data, error: null, fetching: false })
91
+ } catch (error) {
92
+ if (sequence !== this.sequence) return
93
+ this.publish({ ...this.snapshot, status: 'error', error: this.mapError(error), fetching: false })
94
+ }
95
+ }
96
+ }
97
+
98
+ /** `null` when the answer is no page: a hook over a procedure that does not page reports it instead of looping. */
99
+ function pageOf(value: unknown): Page | null {
100
+ if (typeof value !== 'object' || value === null) return null
101
+ const items: unknown = Reflect.get(value, 'items')
102
+ const next: unknown = Reflect.get(value, 'next')
103
+ return Array.isArray(items) && (typeof next === 'string' || next === null) ? { items, next } : null
104
+ }
105
+
106
+ const NOT_A_PAGE: FabrikaError = { kind: 'other', message: 'the procedure answered no { items, next } page', status: 0 }
107
+
108
+ export class PagesEntry extends Entry<PagesSnapshot> {
109
+ /** How many pages the view has asked for; a refetch loads as many again. `Infinity` follows every cursor. */
110
+ #pages: number
111
+
112
+ constructor(target: StoreTarget, input: unknown, pages: number, private readonly mapError: (error: unknown) => FabrikaError) {
113
+ super(target, input, { status: 'loading', items: [], next: null, error: null, fetching: false, loadingMore: false })
114
+ this.#pages = pages
115
+ }
116
+
117
+ #request(after: string | null): Promise<unknown> {
118
+ const input = typeof this.input === 'object' && this.input !== null ? this.input : {}
119
+ return this.target.run(after === null ? input : { ...input, after })
120
+ }
121
+
122
+ /** Loads the first `#pages` pages again, from the first cursor. */
123
+ async load(): Promise<void> {
124
+ const sequence = ++this.sequence
125
+ this.publish({ ...this.snapshot, fetching: true })
126
+ try {
127
+ const items: unknown[] = []
128
+ let next: string | null = null
129
+ let loaded = 0
130
+ do {
131
+ const page = pageOf(await this.#request(next))
132
+ if (sequence !== this.sequence) return
133
+ if (page === null) {
134
+ this.#refuse(NOT_A_PAGE)
135
+ return
136
+ }
137
+ items.push(...page.items)
138
+ next = page.next
139
+ loaded++
140
+ } while (next !== null && loaded < this.#pages)
141
+ this.publish({ status: 'success', items, next, error: null, fetching: false, loadingMore: false })
142
+ } catch (error) {
143
+ if (sequence !== this.sequence) return
144
+ this.#refuse(this.mapError(error))
145
+ }
146
+ }
147
+
148
+ #refuse(error: FabrikaError): void {
149
+ this.publish({ ...this.snapshot, status: 'error', error, fetching: false, loadingMore: false })
150
+ }
151
+
152
+ /** Appends the next page; nothing while a request is in flight or when the list has no more. */
153
+ async loadMore(): Promise<void> {
154
+ const after = this.snapshot.next
155
+ if (after === null || this.snapshot.fetching) return
156
+ const sequence = ++this.sequence
157
+ this.publish({ ...this.snapshot, fetching: true, loadingMore: true })
158
+ try {
159
+ const page = pageOf(await this.#request(after))
160
+ if (sequence !== this.sequence) return
161
+ if (page === null) {
162
+ this.#refuse(NOT_A_PAGE)
163
+ return
164
+ }
165
+ this.#pages++
166
+ this.publish({
167
+ status: 'success',
168
+ items: [...this.snapshot.items, ...page.items],
169
+ next: page.next,
170
+ error: null,
171
+ fetching: false,
172
+ loadingMore: false,
173
+ })
174
+ } catch (error) {
175
+ if (sequence !== this.sequence) return
176
+ // The loaded rows stay; the next `loadMore` asks for the same cursor again.
177
+ this.publish({ ...this.snapshot, status: 'error', error: this.mapError(error), fetching: false, loadingMore: false })
178
+ }
179
+ }
180
+ }
181
+
182
+ export class QueryStore {
183
+ readonly #entries = new Map<string, QueryEntry | PagesEntry>()
184
+
185
+ constructor(private readonly mapError: (error: unknown) => FabrikaError) {}
186
+
187
+ query(target: StoreTarget, input: unknown): QueryEntry {
188
+ const key = `query\u0000${target.id}\u0000${stableKey(input)}`
189
+ const found = this.#entries.get(key)
190
+ if (found instanceof QueryEntry) return found
191
+ const entry = new QueryEntry(target, input, this.mapError)
192
+ this.#entries.set(key, entry)
193
+ return entry
194
+ }
195
+
196
+ pages(target: StoreTarget, input: unknown, pages: number): PagesEntry {
197
+ const key = `pages\u0000${target.id}\u0000${stableKey(input)}\u0000${pages}`
198
+ const found = this.#entries.get(key)
199
+ if (found instanceof PagesEntry) return found
200
+ const entry = new PagesEntry(target, input, pages, this.mapError)
201
+ this.#entries.set(key, entry)
202
+ return entry
203
+ }
204
+
205
+ /** Refetches every entry under one of `prefixes` that a view shows, and forgets the rest, so their next view loads. */
206
+ async invalidate(prefixes: readonly string[]): Promise<void> {
207
+ const loads: Promise<void>[] = []
208
+ for (const [key, entry] of this.#entries) {
209
+ if (!prefixes.some((prefix) => under(entry.target.id, prefix))) continue
210
+ if (entry.watched) loads.push(entry.load())
211
+ else this.#entries.delete(key)
212
+ }
213
+ await Promise.all(loads)
214
+ }
215
+ }
@@ -0,0 +1,40 @@
1
+ // The types the hooks read off a client procedure: its input as the caller writes it, and its answer as the output
2
+ // schema parses it.
3
+
4
+ import type { z } from 'zod/v4'
5
+ import type { Schema } from '../contract/define'
6
+
7
+ /** A query or mutation of a client that `createClient` built. */
8
+ export type ClientProcedure = ((input: never) => Promise<unknown>) & { readonly output: Schema }
9
+
10
+ /** A group of a client tree (`api.deals`), or the client itself: every procedure under it. */
11
+ export interface ClientGroup {
12
+ readonly [name: string]: unknown
13
+ }
14
+
15
+ /** What `invalidate` takes: one procedure, or a group for every procedure under it. */
16
+ export type InvalidateTarget = ClientProcedure | ClientGroup
17
+
18
+ /** The input as the caller writes it; `undefined` admitted for a procedure that takes none. */
19
+ export type ProcedureInput<F extends ClientProcedure> = F extends (input: infer I) => Promise<unknown> ? I : never
20
+
21
+ /** A procedure by its input `I` and output schema `O`: what the hooks take, so both are inferred from the call. */
22
+ export type TypedProcedure<I, O extends Schema> = ((input: I) => Promise<unknown>) & { readonly output: O }
23
+
24
+ /** An input argument that may be left out when the procedure takes no input. */
25
+ export type InputArgument<I> = undefined extends I ? I | void : I
26
+
27
+ /** The answer, as the procedure's output schema parses it. */
28
+ export type ProcedureResult<F extends ClientProcedure> = z.output<F['output']>
29
+
30
+ /** The answer of a cursor list: `{ items, next }`. */
31
+ export interface PageAnswer<Item> {
32
+ readonly items: readonly Item[]
33
+ readonly next: string | null
34
+ }
35
+
36
+ /** A procedure that pages: input `after`, answer `{ items, next }`. */
37
+ export type PagedProcedure<I, Item> = ((input: I) => Promise<unknown>) & { readonly output: z.core.$ZodType<PageAnswer<Item>> }
38
+
39
+ /** The input of a paged procedure without its cursor: the hook sends `after` itself. */
40
+ export type PageInput<I> = undefined extends I ? Omit<NonNullable<I>, 'after'> | void : Omit<I, 'after'>
@@ -114,9 +114,9 @@ async function dispatchOne(router: AnyRouter, ctx: unknown, call: SingleCall): P
114
114
  }
115
115
 
116
116
  /**
117
- * Dispatch an RPC POST. The `ctx` is already built (and decorated by middleware,
118
- * e.g. `ctx.auth`) by the server before this is called. Handles a single call
119
- * `{ method, input }` or a `{ batch: [...] }`.
117
+ * Dispatch an RPC POST. The `ctx` is already built (with its `ctx.auth`, for example)
118
+ * by the server before this is called. One request is one call,
119
+ * `{ method, input }`.
120
120
  */
121
121
  export async function dispatchRpcRequest(args: { router: AnyRouter; ctx: unknown; request: Request }): Promise<Response> {
122
122
  let rawBody: unknown
@@ -126,25 +126,10 @@ export async function dispatchRpcRequest(args: { router: AnyRouter; ctx: unknown
126
126
  return jsonResponse({ error: { type: 'validation', message: 'Invalid JSON body' } }, 400)
127
127
  }
128
128
 
129
- if (isRecord(rawBody) && Array.isArray(rawBody['batch'])) {
130
- const results: SingleResult[] = []
131
- for (const call of rawBody['batch']) {
132
- results.push((await dispatchOne(args.router, args.ctx, normalizeCall(call))).body)
133
- }
134
- return jsonResponse({ batch: results }, 200)
135
- }
136
-
137
129
  if (!isRecord(rawBody) || typeof rawBody['method'] !== 'string') {
138
- return jsonResponse({ error: { type: 'validation', message: 'Body must be { method, input } or { batch: [...] }' } }, 400)
130
+ return jsonResponse({ error: { type: 'validation', message: 'Body must be { method, input }' } }, 400)
139
131
  }
140
132
 
141
133
  const single = await dispatchOne(args.router, args.ctx, { method: rawBody['method'], input: rawBody['input'] })
142
134
  return jsonResponse(single.body, single.status)
143
135
  }
144
-
145
- function normalizeCall(call: unknown): SingleCall {
146
- if (isRecord(call) && typeof call['method'] === 'string') {
147
- return { method: call['method'], input: call['input'] }
148
- }
149
- return { method: '', input: undefined }
150
- }
@@ -0,0 +1,121 @@
1
+ // A service contract: the endpoint table of one of the installation's own services (ADR-0012, ADR-0022 "Installation
2
+ // entries"). Plain data over `@fabrika/auth-core` only, so a table module stays dependency-light.
3
+
4
+ import {
5
+ APP_ENVIRONMENT_SEPARATOR,
6
+ type BodyAdmission,
7
+ compileEndpointTable,
8
+ EndpointTableError,
9
+ type InstallationCaller,
10
+ type InstallationEndpointMethod,
11
+ type InstallationEndpointRow,
12
+ type InstallationEndpointTable,
13
+ OAUTH_CALLER,
14
+ } from '@fabrika/auth-core'
15
+
16
+ /** A row as the builder writes it; the record key of `endpoints` becomes its id. */
17
+ export type ServiceEndpointSpec = InstallationEndpointRow extends infer Row ? Row extends InstallationEndpointRow ? Omit<Row, 'id'> : never : never
18
+
19
+ /** One installation service's table, with the row ids as a type so its handlers can be checked against them. */
20
+ export interface ServiceArtifact<Id extends string = string> extends InstallationEndpointTable {
21
+ /** The service's IAM app id; never `<app>@<env>`. */
22
+ readonly id: string
23
+ readonly endpoints: readonly (InstallationEndpointRow & { readonly id: Id })[]
24
+ }
25
+
26
+ export interface ServiceEndpointOptions {
27
+ readonly callers: readonly [InstallationCaller, ...InstallationCaller[]]
28
+ /** The request body the row admits; omitted admits none. */
29
+ readonly admission?: BodyAdmission
30
+ /** The scopes an OAuth resource token must carry; required exactly when `callers` holds `oauth`. */
31
+ readonly scopes?: readonly string[]
32
+ }
33
+
34
+ export interface ServiceContractBuilder {
35
+ /** A liveness endpoint: anonymous, and served without `X-Fabrika-Endpoint`. */
36
+ health(method: InstallationEndpointMethod, path: string): ServiceEndpointSpec
37
+ /** An endpoint the service authenticates itself; the proxy reads no credential and keeps `Authorization`. */
38
+ protocol(method: InstallationEndpointMethod, path: string): ServiceEndpointSpec
39
+ /** A credentialed endpoint: a listed caller kind, and no permission (`authenticated`). */
40
+ service(method: InstallationEndpointMethod, path: string, options: ServiceEndpointOptions): ServiceEndpointSpec
41
+ }
42
+
43
+ export interface ServiceContractDeclaration<Id extends string> {
44
+ /** `null` is a row this composition does not serve: the table has no row there, and its handler is never called. */
45
+ readonly endpoints: Readonly<Record<Id, ServiceEndpointSpec | null>>
46
+ /** Literal prefixes a path under which reaches only a row declared under it. */
47
+ readonly reservedPrefixes: readonly string[]
48
+ }
49
+
50
+ export class ServiceContractError extends Error {
51
+ override readonly name = 'ServiceContractError'
52
+ readonly problems: readonly string[]
53
+
54
+ constructor(problems: readonly string[]) {
55
+ super(`the service contract is invalid:\n- ${problems.join('\n- ')}`)
56
+ this.problems = problems
57
+ }
58
+ }
59
+
60
+ const builder: ServiceContractBuilder = {
61
+ health: (method, path) => anonymous('health', method, path),
62
+ protocol: (method, path) => anonymous('protocol', method, path),
63
+ service: (method, path, options) => ({
64
+ kind: 'service',
65
+ method,
66
+ path,
67
+ callers: options.callers,
68
+ requirement: { kind: 'authenticated' },
69
+ admission: options.admission ?? null,
70
+ oauth: options.scopes === undefined ? null : { scopes: options.scopes },
71
+ }),
72
+ }
73
+
74
+ function anonymous(kind: 'health' | 'protocol', method: InstallationEndpointMethod, path: string): ServiceEndpointSpec {
75
+ return { kind, method, path, callers: [], requirement: null, admission: null, oauth: null }
76
+ }
77
+
78
+ /**
79
+ * Declare one installation service's endpoint table. Validated here, once: an id that is an application environment,
80
+ * an `oauth` caller without its scopes (or scopes without it), and every table the matcher would refuse.
81
+ */
82
+ export function defineServiceContract<const Id extends string>(
83
+ id: string,
84
+ declare: (s: ServiceContractBuilder) => ServiceContractDeclaration<Id>,
85
+ ): ServiceArtifact<Id> {
86
+ const declaration = declare(builder)
87
+ const endpoints = servedEntries(declaration.endpoints).map(([rowId, spec]) => ({ ...spec, id: rowId }))
88
+ const problems: string[] = []
89
+ if (id === '' || id.includes(APP_ENVIRONMENT_SEPARATOR)) {
90
+ problems.push(`'${id}' is no installation service id: it must be non-empty and hold no '${APP_ENVIRONMENT_SEPARATOR}'`)
91
+ }
92
+ for (const row of endpoints) {
93
+ const admitsOAuth = row.callers.some((caller) => caller === OAUTH_CALLER)
94
+ if (admitsOAuth !== (row.oauth !== null)) {
95
+ problems.push(`'${row.id}' must declare scopes exactly when it admits the caller '${OAUTH_CALLER}'`)
96
+ }
97
+ if (row.oauth !== null && row.oauth.scopes.length === 0) {
98
+ problems.push(`'${row.id}' admits '${OAUTH_CALLER}' with no scope`)
99
+ }
100
+ }
101
+ const table = { endpoints, reservedPrefixes: [...declaration.reservedPrefixes] }
102
+ try {
103
+ compileEndpointTable(table)
104
+ } catch (error) {
105
+ if (!(error instanceof EndpointTableError)) throw error
106
+ problems.push(...error.problems)
107
+ }
108
+ if (problems.length > 0) {
109
+ throw new ServiceContractError(problems)
110
+ }
111
+ return { id, ...table }
112
+ }
113
+
114
+ function servedEntries<Id extends string, Value>(record: Readonly<Record<Id, Value | null>>): [Id, Value][] {
115
+ const entries: [Id, Value][] = []
116
+ for (const key in record) {
117
+ const value = record[key]
118
+ if (Object.hasOwn(record, key) && value !== null) entries.push([key, value])
119
+ }
120
+ return entries
121
+ }
@@ -0,0 +1,21 @@
1
+ // @fabrika/app/service — the installation's own services only (IAM, Control, Operations). An application serves its
2
+ // contract through `serve`; `@fabrika/check` refuses this import in one.
3
+
4
+ export { dispatchRpcRequest } from '../rpc/dispatcher.js'
5
+ export {
6
+ defineServiceContract,
7
+ type ServiceArtifact,
8
+ type ServiceContractBuilder,
9
+ type ServiceContractDeclaration,
10
+ ServiceContractError,
11
+ type ServiceEndpointOptions,
12
+ type ServiceEndpointSpec,
13
+ } from './define.js'
14
+ export {
15
+ serveService,
16
+ type ServeServiceOptions,
17
+ type ServiceHandler,
18
+ type ServiceHandlers,
19
+ ServiceHandlersError,
20
+ type ServiceRequest,
21
+ } from './serve.js'