@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,346 @@
1
+ // One invocation of a staged-kit site — an endpoint request, a webhook delivery, a job attempt — and the `ctx.db` its
2
+ // handler reaches data through. The invocation owns one transaction, opened by the first statement: the dispatcher
3
+ // commits it after the handler returned and its output passed validation, and rolls it back otherwise. Every handle
4
+ // is bound to the invocation: called from another request, another `boot`, or after it ended, it refuses.
5
+
6
+ import type { ContractArtifact } from '@fabrika/auth-core'
7
+ import {
8
+ type DataAccess,
9
+ type DataHandles,
10
+ type DataSite,
11
+ type InvocationTransaction,
12
+ type QueryRefusal,
13
+ QueryRefusedError,
14
+ type Session,
15
+ type SqlTag,
16
+ } from '@fabrika/db/framework'
17
+ import type { DataKind, UnscopedMode } from '@fabrika/db/tables'
18
+ import type { SqlMode } from '../contract/context'
19
+ import type { FileSite } from '../files/collections'
20
+ import { CapabilityRefusedError, isActiveRequest, type RequestFrame } from './capability'
21
+ import { ServerFault } from './faults'
22
+
23
+ /** A site's declared off-road reach, parsed at `boot`: what its handler's `ctx` gets beyond the paved road. */
24
+ export interface SiteDeclaration {
25
+ readonly sql: SqlMode | null
26
+ readonly unscoped: Readonly<Record<string, UnscopedMode>>
27
+ readonly connect: readonly string[]
28
+ readonly issues: string | null
29
+ }
30
+
31
+ export type SiteFunction = (args: { readonly input?: unknown; readonly ctx: object }) => unknown
32
+
33
+ /**
34
+ * A handler tree leaf of a staged-kit site, which `serve` builds from each handled endpoint, job and webhook: the
35
+ * handler and the site's `declared` object. `boot` builds the site's `ctx.db` (and the declared handles) from it.
36
+ */
37
+ export interface SiteHandler {
38
+ readonly handler: SiteFunction
39
+ readonly declared: unknown
40
+ }
41
+
42
+ const sites = new WeakSet<object>()
43
+
44
+ /** The leaf `serve` puts in the handler tree for one site. */
45
+ export function siteHandler(handler: SiteFunction, declared: unknown): SiteHandler {
46
+ const site = Object.freeze({ handler, declared })
47
+ sites.add(site)
48
+ return site
49
+ }
50
+
51
+ /** Whether a handler tree node is a site leaf, rather than a group of handlers that happens to have the same fields. */
52
+ export function isSiteHandler(node: unknown): node is SiteHandler {
53
+ return typeof node === 'object' && node !== null && sites.has(node)
54
+ }
55
+
56
+ const DECLARED_FIELDS: readonly string[] = ['sql', 'unscoped', 'connect', 'issues']
57
+ const NO_DECLARATION: SiteDeclaration = Object.freeze({ sql: null, unscoped: Object.freeze({}), connect: Object.freeze([]), issues: null })
58
+
59
+ function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
60
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
61
+ return false
62
+ }
63
+ const prototype: unknown = Object.getPrototypeOf(value)
64
+ return prototype === Object.prototype || prototype === null
65
+ }
66
+
67
+ function ownField(record: Readonly<Record<string, unknown>>, key: string): unknown {
68
+ return Object.hasOwn(record, key) ? record[key] : undefined
69
+ }
70
+
71
+ /**
72
+ * The site's `declared` object, parsed at the boundary: the kit's types already refused what is wrong in it, and this
73
+ * refuses it again, because a widened or cast declaration would otherwise reach further than its site shows.
74
+ */
75
+ export function parseDeclaration(where: string, declared: unknown, kind: DataKind, artifact: ContractArtifact, problems: string[]): SiteDeclaration {
76
+ if (declared === undefined) {
77
+ return NO_DECLARATION
78
+ }
79
+ if (!isPlainObject(declared)) {
80
+ problems.push(`${where}: its declaration is not a plain object`)
81
+ return NO_DECLARATION
82
+ }
83
+ for (const key of Object.keys(declared)) {
84
+ if (!DECLARED_FIELDS.includes(key)) {
85
+ problems.push(`${where}: '${key}' is no declaration (declarations: ${DECLARED_FIELDS.join(', ')})`)
86
+ }
87
+ }
88
+ const reads = kind === 'query'
89
+ const sql = ownField(declared, 'sql')
90
+ if (sql !== undefined && sql !== 'read' && (sql !== 'write' || reads)) {
91
+ problems.push(`${where}: sql is ${JSON.stringify(sql)}; ${reads ? "a query declares only sql: 'read'" : "sql is 'read' or 'write'"}`)
92
+ }
93
+ const unscoped: Record<string, UnscopedMode> = {}
94
+ const tables = ownField(declared, 'unscoped')
95
+ if (tables !== undefined && !isPlainObject(tables)) {
96
+ problems.push(`${where}: unscoped is not an object of tables`)
97
+ }
98
+ for (const [table, mode] of Object.entries(isPlainObject(tables) ? tables : {})) {
99
+ if (table.startsWith('fabrika_') || !Object.hasOwn(artifact.tables, table)) {
100
+ problems.push(`${where}: unscoped declares '${table}', which is not a table of src/db/tables.ts`)
101
+ } else if (mode === 'read' || (mode === 'write' && !reads)) {
102
+ unscoped[table] = mode
103
+ } else {
104
+ problems.push(
105
+ `${where}: unscoped declares '${table}' as ${JSON.stringify(mode)}; ${
106
+ reads ? "a query declares only 'read'" : "the modes are 'read' and 'write'"
107
+ }`,
108
+ )
109
+ }
110
+ }
111
+ const connect = ownField(declared, 'connect')
112
+ const connectors = Array.isArray(connect) ? connect.filter((name): name is string => typeof name === 'string') : []
113
+ if (connect !== undefined && (!Array.isArray(connect) || connectors.length !== connect.length)) {
114
+ problems.push(`${where}: connect is not a list of connector names`)
115
+ }
116
+ for (const name of connectors) {
117
+ if (!Object.hasOwn(artifact.imports, name)) {
118
+ problems.push(`${where}: connect names '${name}', which the kit does not import`)
119
+ }
120
+ }
121
+ const issues = ownField(declared, 'issues')
122
+ if (issues !== undefined && typeof issues !== 'string') {
123
+ problems.push(`${where}: issues is not the name of a link mount`)
124
+ }
125
+ return Object.freeze({
126
+ sql: sql === 'read' || sql === 'write' ? sql : null,
127
+ unscoped: Object.freeze(unscoped),
128
+ connect: Object.freeze(connectors),
129
+ issues: typeof issues === 'string' ? issues : null,
130
+ })
131
+ }
132
+
133
+ /**
134
+ * The declaration a site runs with: the one the approved contract holds (`ContractArtifact.declared`, which the
135
+ * contract's digest covers), by the site's endpoint id or `job:<name>`. A leaf whose own `declared` says otherwise is
136
+ * refused, so a declaration only the handler tree carries never widens a site past what was approved.
137
+ */
138
+ export function approvedDeclaration(
139
+ where: string,
140
+ site: string,
141
+ leaf: unknown,
142
+ kind: DataKind,
143
+ artifact: ContractArtifact,
144
+ problems: string[],
145
+ ): SiteDeclaration {
146
+ const before = problems.length
147
+ const declared = parseDeclaration(where, leaf, kind, artifact, problems)
148
+ const approved = approvedReach(site, artifact)
149
+ // A leaf the parse refused is reported once, for what is wrong in it.
150
+ if (problems.length === before && describeDeclaration(declared) !== describeDeclaration(approved)) {
151
+ problems.push(
152
+ `${where}: its handler declares ${describeDeclaration(declared)}, but the approved contract declares ${
153
+ describeDeclaration(approved)
154
+ }; declare the site's reach beside its declaration in the shell, which the contract's digest covers`,
155
+ )
156
+ }
157
+ return approved
158
+ }
159
+
160
+ /** What the approved artifact declares for `site` (an endpoint id or `job:<name>`); nothing when it holds none. */
161
+ export function approvedReach(site: string, artifact: ContractArtifact): SiteDeclaration {
162
+ const reach = artifact.declared === undefined || !Object.hasOwn(artifact.declared, site) ? undefined : artifact.declared[site]
163
+ return reach === undefined
164
+ ? NO_DECLARATION
165
+ : Object.freeze({
166
+ sql: reach.sql,
167
+ unscoped: Object.freeze({ ...reach.unscoped }),
168
+ connect: Object.freeze([...reach.connect]),
169
+ issues: reach.issues,
170
+ })
171
+ }
172
+
173
+ /** A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted. */
174
+ function describeDeclaration(declaration: SiteDeclaration): string {
175
+ const unscoped = Object.entries(declaration.unscoped).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
176
+ return JSON.stringify({ sql: declaration.sql, unscoped, connect: [...declaration.connect].sort(), issues: declaration.issues })
177
+ }
178
+
179
+ /**
180
+ * Refuses a handle used outside the work it was made for: another request, another `boot`, an ended one. `handle` names
181
+ * it in the refusal: a `ctx.db` handle, or `ctx.sql`.
182
+ */
183
+ export function boundTo(frame: RequestFrame, ended: () => boolean, handle = 'a ctx.db handle'): () => void {
184
+ return () => {
185
+ if (frame.ended || ended()) {
186
+ throw new CapabilityRefusedError('ended', `${handle} of ${frame.id} was used after its invocation ended; never keep a handle past its handler`)
187
+ }
188
+ if (!isActiveRequest(frame)) {
189
+ throw new CapabilityRefusedError('foreign_work', `${handle} of ${frame.id} was used from other work; never share a handle`)
190
+ }
191
+ }
192
+ }
193
+
194
+ /** One invocation's transaction, and the data handles of its site. */
195
+ export class Invocation {
196
+ readonly #frame: RequestFrame
197
+ readonly #transaction: InvocationTransaction
198
+ readonly #data: DataAccess
199
+
200
+ constructor(frame: RequestFrame, data: DataAccess, readOnly: boolean) {
201
+ this.#frame = frame
202
+ this.#data = data
203
+ this.#transaction = data.invocation(readOnly)
204
+ }
205
+
206
+ /** The handles of the site, all of them running in this invocation's transaction. */
207
+ handles(kind: DataKind, declaration: SiteDeclaration, principal: string): DataHandles {
208
+ const transaction = this.#transaction
209
+ const site: DataSite = {
210
+ kind,
211
+ unscoped: declaration.unscoped,
212
+ principal,
213
+ check: boundTo(this.#frame, () => transaction.ended),
214
+ session: () => transaction.session(),
215
+ }
216
+ return this.#data.handles(site)
217
+ }
218
+
219
+ /** `ctx.sql` of a site that declares it: its statements run in this invocation's transaction, like every handle's. */
220
+ sql(mode: SqlMode): SqlTag {
221
+ const transaction = this.#transaction
222
+ return this.#data.sql({
223
+ mode,
224
+ check: boundTo(this.#frame, () => transaction.ended, 'ctx.sql'),
225
+ session: () => transaction.session(),
226
+ fail: () => transaction.fail(),
227
+ })
228
+ }
229
+
230
+ /** Where `ctx.files` runs: this invocation's transaction, bound like every handle; a query's only reads. */
231
+ fileSite(where: string, kind: DataKind): Omit<FileSite, 'frame'> {
232
+ const transaction = this.#transaction
233
+ return {
234
+ where,
235
+ check: boundTo(this.#frame, () => transaction.ended, 'ctx.files'),
236
+ session: () => transaction.session(),
237
+ readOnly: kind === 'mutation' && !transaction.readOnly ? null : 'a query reads only; remove files in a mutation',
238
+ }
239
+ }
240
+
241
+ /** Commits; a request the caller abandoned meanwhile rolls back instead. */
242
+ async commit(signal: AbortSignal | null): Promise<void> {
243
+ if (signal?.aborted === true) {
244
+ await this.#transaction.rollback()
245
+ throw new ServerFault(
246
+ 'request_cancelled',
247
+ `request ${this.#frame.id} was cancelled or outlived its budget before its transaction committed; nothing it wrote is kept`,
248
+ )
249
+ }
250
+ await this.#transaction.commit()
251
+ }
252
+
253
+ rollback(): Promise<void> {
254
+ return this.#transaction.rollback()
255
+ }
256
+ }
257
+
258
+ /** What a request's access form handed the handler, by `ctx` name. */
259
+ export interface AccessCapabilities {
260
+ readonly target?: unknown
261
+ readonly within?: unknown
262
+ readonly created?: unknown
263
+ /** An `app` endpoint's capability, `ctx.app`. */
264
+ readonly app?: unknown
265
+ }
266
+
267
+ /** `ctx.db` of an endpoint, by its access form: the API matrix of `docs/reference/application-runtime.md`. */
268
+ export function endpointData(
269
+ handles: DataHandles,
270
+ form: 'on' | 'in' | 'over' | 'creates' | 'app' | 'member',
271
+ access: AccessCapabilities,
272
+ ): object {
273
+ const shared = { reference: handles.reference, ...(handles.unscoped === undefined ? {} : { unscoped: handles.unscoped }) }
274
+ switch (form) {
275
+ case 'on':
276
+ return Object.freeze({ target: handles.target(access.target, 'on'), app: handles.app(access.target), ...shared })
277
+ case 'in':
278
+ return Object.freeze({ target: handles.target(access.target, 'in'), app: handles.app(access.target), ...shared })
279
+ case 'creates':
280
+ return Object.freeze({ created: handles.target(access.created, 'in'), ...shared })
281
+ case 'over':
282
+ return Object.freeze({ within: handles.within(access.within), app: handles.app(access.within), ...shared })
283
+ case 'app':
284
+ return Object.freeze({ app: handles.app(access.app), ...shared })
285
+ case 'member':
286
+ return Object.freeze(shared)
287
+ }
288
+ }
289
+
290
+ /** `ctx.db` of a job or a webhook: each capability it minted or received binds the same handles an endpoint gets. */
291
+ export function siteData(handles: DataHandles): object {
292
+ return Object.freeze({
293
+ on: (ref: unknown) => handles.target(ref, 'on'),
294
+ in: (ref: unknown) => handles.target(ref, 'in'),
295
+ over: (within: unknown) => handles.within(within),
296
+ reference: handles.reference,
297
+ ...(handles.unscoped === undefined ? {} : { unscoped: handles.unscoped }),
298
+ })
299
+ }
300
+
301
+ /** The handles of a job attempt, outside its fenced transaction (`'job'`, each read on its own) or inside it. */
302
+ export function jobHandles(
303
+ frame: RequestFrame,
304
+ data: DataAccess,
305
+ declaration: SiteDeclaration,
306
+ principal: string,
307
+ kind: 'job' | 'mutation',
308
+ session: () => Promise<Session>,
309
+ ended: () => boolean,
310
+ ): DataHandles {
311
+ return data.handles({ kind, unscoped: declaration.unscoped, principal, check: boundTo(frame, ended), session })
312
+ }
313
+
314
+ /**
315
+ * A `ctx.sql` (`tx.sql`) that may not run here. The types already refuse it; at run time it exists only to refuse by
316
+ * name, so an untyped call reports what to change rather than a `TypeError`.
317
+ */
318
+ function refusingSql(reason: QueryRefusal, message: string): SqlTag {
319
+ const refuse = (): never => {
320
+ throw new QueryRefusedError(reason, message)
321
+ }
322
+ return Object.freeze(Object.assign(() => refuse(), { scope: () => refuse() }))
323
+ }
324
+
325
+ /** `ctx.sql` (or `member`, a job's `tx.sql`) of a site that does not declare `sql`. */
326
+ export function undeclaredSql(site: string, kind: DataKind, member: 'ctx.sql' | 'tx.sql' = 'ctx.sql'): SqlTag {
327
+ const declaration = kind === 'query' ? "sql: 'read'" : "sql: 'read' (or 'write' to write)"
328
+ return refusingSql('undeclared_reach', `${site} does not declare sql, so it has no ${member}; add ${declaration} to it`)
329
+ }
330
+
331
+ /** `ctx.sql` of a job that declares `sql`, outside `ctx.transaction`: its statements run only in the attempt's fence. */
332
+ export function jobSqlOutsideTransaction(job: string): SqlTag {
333
+ return refusingSql('sql_outside_transaction', `${job} runs ctx.sql inside ctx.transaction((tx) => …), as tx.sql`)
334
+ }
335
+
336
+ /** `ctx.files` of a contract that declares no file collection: every name is refused. */
337
+ export function noFileCollections(where: string): (collection: string) => never {
338
+ return (collection) => {
339
+ throw new ServerFault('file_collection_unknown', `${where}: ctx.files('${String(collection)}') names a collection, and the contract declares none`)
340
+ }
341
+ }
342
+
343
+ /** `tx.sql` of a job site that declares it: its statements run in the attempt's fenced transaction. */
344
+ export function jobSql(frame: RequestFrame, data: DataAccess, mode: SqlMode, session: Session, ended: () => boolean): SqlTag {
345
+ return data.sql({ mode, check: boundTo(frame, ended, 'tx.sql'), session: async () => session, fail: () => session.fail() })
346
+ }