@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,143 @@
1
+ // `compileContract`: the evaluated contract → its declaration with every zod schema read as a `SchemaShape` →
2
+ // `buildArtifact`. A reader of source produces the same declaration and calls `buildArtifact` itself.
3
+
4
+ import type { ContractArtifact, SchemaShape, TablePolicyArtifact } from '@fabrika/auth-core'
5
+ import { inboxColumns, isTables, tablePolicies, type Tables } from '@fabrika/db/tables'
6
+ import { attachmentOf } from './attachment'
7
+ import { buildArtifact } from './build'
8
+ import {
9
+ type AttachmentDeclaration,
10
+ type ContractDeclaration,
11
+ type ExportDeclaration,
12
+ isProcedure,
13
+ type MountDeclaration,
14
+ type ProcedureDeclaration,
15
+ type ProcedureTreeDeclaration,
16
+ SURFACE_SECTIONS,
17
+ type SurfaceSection,
18
+ } from './declaration'
19
+ import type { AnyContract, AnyMount, Schema, Surface } from './define'
20
+ import { Problems } from './problems'
21
+ import { describeSchema } from './schema-shape'
22
+
23
+ /**
24
+ * The normalized artifact of a contract and its table policies: what `defineTables` returned (`src/db/tables.ts`),
25
+ * or the artifact form of each policy. Only the first knows each inbox's columns, so only it checks a form's fields
26
+ * against them; `boot` checks them against the data layer either way.
27
+ */
28
+ export function compileContract<DB, P>(
29
+ contract: AnyContract,
30
+ tables: Tables<DB, P> | Readonly<Record<string, TablePolicyArtifact>>,
31
+ ): ContractArtifact {
32
+ const described = describeContract(contract)
33
+ return isTables(tables) ? buildArtifact(described, tablePolicies(tables), inboxColumns(tables)) : buildArtifact(described, tables)
34
+ }
35
+
36
+ /** The contract with its schemas read as shapes; throws `ContractError` for a schema outside the subset. */
37
+ export function describeContract(contract: AnyContract): ContractDeclaration<SchemaShape> {
38
+ const problems = new Problems()
39
+ const reader = new SchemaReader(problems)
40
+ const { surface, vocabulary } = contract
41
+ const mounts: Record<string, MountDeclaration<SchemaShape>> = {}
42
+ for (const [name, entry] of Object.entries(surface)) {
43
+ if (isSection(name)) {
44
+ continue
45
+ }
46
+ if (isMount(entry)) {
47
+ mounts[name] = reader.mount(name, entry)
48
+ } else {
49
+ problems.add(`mount '${name}'`, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health, c.form or c.webhook)')
50
+ }
51
+ }
52
+ const exports: Record<string, ExportDeclaration<SchemaShape>> = {}
53
+ for (const [name, declaration] of Object.entries(surface.exports ?? {})) {
54
+ exports[name] = { ...declaration, procedures: reader.tree(`export '${name}'`, declaration.procedures) }
55
+ }
56
+ problems.throwIfAny()
57
+ return {
58
+ app: vocabulary.app,
59
+ dimensions: vocabulary.dimensions,
60
+ actions: vocabulary.actions,
61
+ roles: vocabulary.roles,
62
+ mounts,
63
+ files: surface.files ?? {},
64
+ jobs: surface.jobs ?? {},
65
+ exports,
66
+ imports: surface.imports ?? {},
67
+ csp: surface.csp ?? {},
68
+ ...(vocabulary.locale === undefined ? {} : { locale: vocabulary.locale }),
69
+ ...(vocabulary.displayName === undefined ? {} : { displayName: vocabulary.displayName }),
70
+ ...(vocabulary.mail === undefined ? {} : { mail: vocabulary.mail }),
71
+ ...(vocabulary.members === undefined ? {} : { members: vocabulary.members }),
72
+ ...(vocabulary.directory === undefined ? {} : { directory: vocabulary.directory }),
73
+ }
74
+ }
75
+
76
+ const SECTIONS: ReadonlySet<string> = new Set(SURFACE_SECTIONS)
77
+
78
+ function isSection(name: string): name is SurfaceSection {
79
+ return SECTIONS.has(name)
80
+ }
81
+
82
+ const MOUNT_KINDS: ReadonlySet<string> = new Set(['rpc', 'link', 'spa', 'health', 'form', 'webhook'])
83
+
84
+ function isMount(entry: Surface[string]): entry is AnyMount {
85
+ return typeof entry === 'object' && 'kind' in entry && typeof entry.kind === 'string' && MOUNT_KINDS.has(entry.kind)
86
+ }
87
+
88
+ class SchemaReader {
89
+ readonly #problems: Problems
90
+
91
+ constructor(problems: Problems) {
92
+ this.#problems = problems
93
+ }
94
+
95
+ mount(name: string, mount: AnyMount): MountDeclaration<SchemaShape> {
96
+ const where = `mount '${name}'`
97
+ switch (mount.kind) {
98
+ case 'rpc':
99
+ return { ...mount, procedures: this.tree(where, mount.procedures) }
100
+ case 'link':
101
+ return { ...mount, procedures: this.tree(where, mount.procedures) }
102
+ case 'form':
103
+ return { ...mount, input: this.schema(`${where} input`, mount.input) }
104
+ case 'webhook':
105
+ return { ...mount, input: this.schema(`${where} input`, mount.input) }
106
+ default:
107
+ return mount
108
+ }
109
+ }
110
+
111
+ tree<Access>(where: string, tree: ProcedureTreeDeclaration<Schema, Access>, prefix = ''): ProcedureTreeDeclaration<SchemaShape, Access> {
112
+ const described: Record<string, ProcedureDeclaration<SchemaShape, Access> | ProcedureTreeDeclaration<SchemaShape, Access>> = {}
113
+ for (const [name, node] of Object.entries(tree)) {
114
+ const path = prefix === '' ? name : `${prefix}.${name}`
115
+ if (isProcedure(node)) {
116
+ const at = `${where} procedure '${path}'`
117
+ described[name] = {
118
+ ...node,
119
+ input: node.input === null ? null : this.schema(`${at} input`, node.input),
120
+ ...this.output(`${at} output`, node.output),
121
+ }
122
+ } else {
123
+ described[name] = this.tree(where, node, path)
124
+ }
125
+ }
126
+ return described
127
+ }
128
+
129
+ /** An `attachment()` output is described as the rows' array, its declaration carried beside it. */
130
+ output(where: string, schema: Schema): { readonly output: SchemaShape; readonly attachment?: AttachmentDeclaration } {
131
+ const mark = attachmentOf(schema)
132
+ if (mark === null) {
133
+ return { output: this.schema(where, schema) }
134
+ }
135
+ const row = this.schema(`${where} row`, mark.row)
136
+ return { output: { type: 'array', items: row, minItems: null, maxItems: null }, attachment: mark.declaration }
137
+ }
138
+
139
+ /** A failed schema leaves a placeholder; `describeContract` throws before anything reads it. */
140
+ schema(where: string, schema: Schema): SchemaShape {
141
+ return describeSchema(schema, where, this.#problems) ?? { type: 'null' }
142
+ }
143
+ }
@@ -0,0 +1,170 @@
1
+ // Connectors between applications, as data: an export's description (what the owner serves and a consumer vendors),
2
+ // the vendored snapshot a consumer imports, and the one way each is read back into shapes and a digest.
3
+ // Browser-safe: the snapshot is imported by the contract, which the SPA imports.
4
+
5
+ import {
6
+ canonicalJson,
7
+ canonicalShape,
8
+ type ContractArtifact,
9
+ type ExportDescription,
10
+ exportDigest,
11
+ type ExportProcedureDescription,
12
+ isAttachmentShape,
13
+ type SchemaShape,
14
+ } from '@fabrika/auth-core'
15
+ import { z } from 'zod/v4'
16
+ import { schemaShapeSchema } from './artifact-schema'
17
+ import { exportBase } from './defaults'
18
+ import type { InputSchema, Schema } from './define'
19
+ import { ContractError, Problems } from './problems'
20
+ import { describeSchema } from './schema-shape'
21
+
22
+ /** One vendored procedure: the zod schemas `fabrika connect add` generated from the owner's description. */
23
+ export interface SnapshotProcedure {
24
+ readonly input: InputSchema | null
25
+ readonly output: Schema
26
+ }
27
+
28
+ /**
29
+ * The vendored description of another application's export, as `fabrika connect add` writes it to
30
+ * `fabrika/connect/<app>.<export>.v<version>.ts`. The consumer passes it to `implement({ connect })` and imports its
31
+ * digest into the contract.
32
+ */
33
+ export interface ConnectSnapshot {
34
+ readonly app: string
35
+ readonly export: string
36
+ readonly version: number
37
+ readonly audience: string
38
+ readonly digest: string
39
+ /** By the procedure's dotted path in the export. */
40
+ readonly procedures: { readonly [path: string]: SnapshotProcedure }
41
+ }
42
+
43
+ /** The description the owner serves for `name`, or `null` when the artifact declares no such export. */
44
+ export function exportDescription(artifact: ContractArtifact, name: string): ExportDescription | null {
45
+ const declaration = Object.hasOwn(artifact.exports, name) ? artifact.exports[name] : undefined
46
+ if (declaration === undefined) {
47
+ return null
48
+ }
49
+ const prefix = `${exportBase(name, declaration.version)}.`
50
+ const procedures: Record<string, ExportProcedureDescription> = {}
51
+ for (const row of artifact.endpoints) {
52
+ // The build refuses an attachment on an export, so an export row's output is a schema.
53
+ if (
54
+ row.source.kind === 'export' && row.source.name === name && row.id.startsWith(prefix) && row.output !== null && !isAttachmentShape(row.output)
55
+ ) {
56
+ procedures[row.id.slice(prefix.length)] = { input: row.input, output: row.output }
57
+ }
58
+ }
59
+ const described = { app: artifact.app, export: name, version: declaration.version, audience: declaration.audience, procedures }
60
+ return { ...described, digest: exportDigest(described) }
61
+ }
62
+
63
+ /**
64
+ * The description a snapshot's schemas describe now, with the digest of that. A snapshot whose recomputed digest is
65
+ * not its own `digest` was edited after `fabrika connect add` wrote it. Throws `ContractError` for a schema outside
66
+ * the contract subset.
67
+ */
68
+ export function describeSnapshot(snapshot: ConnectSnapshot): ExportDescription {
69
+ const problems = new Problems()
70
+ const procedures: Record<string, ExportProcedureDescription> = {}
71
+ for (const [path, procedure] of Object.entries(snapshot.procedures)) {
72
+ const where = `snapshot ${snapshot.app}/${snapshot.export}@${snapshot.version} procedure '${path}'`
73
+ const input = procedure.input === null ? null : describeSchema(procedure.input, `${where} input`, problems)
74
+ const output = describeSchema(procedure.output, `${where} output`, problems)
75
+ procedures[path] = { input, output: output ?? { type: 'null' } }
76
+ }
77
+ problems.throwIfAny()
78
+ const described = { app: snapshot.app, export: snapshot.export, version: snapshot.version, audience: snapshot.audience, procedures }
79
+ return { ...described, digest: exportDigest(described) }
80
+ }
81
+
82
+ const procedureDescription: z.ZodType<ExportProcedureDescription> = z.strictObject({
83
+ input: schemaShapeSchema.nullable(),
84
+ output: schemaShapeSchema,
85
+ })
86
+
87
+ /** The parser of a description another application served; the digest is checked by `parseExportDescription`. */
88
+ export const exportDescriptionSchema: z.ZodType<ExportDescription> = z.strictObject({
89
+ app: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
90
+ export: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/),
91
+ version: z.int().positive(),
92
+ audience: z.string().min(1),
93
+ procedures: z.record(z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/), procedureDescription),
94
+ digest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
95
+ })
96
+
97
+ const DESCRIPTION_FIELDS: readonly string[] = ['app', 'export', 'version', 'audience', 'procedures', 'digest']
98
+
99
+ /** A served description, parsed, whose digest is the digest of what it describes; throws `ContractError` otherwise. */
100
+ export function parseExportDescription(value: unknown): ExportDescription {
101
+ const parsed = exportDescriptionSchema.safeParse(value)
102
+ if (!parsed.success) {
103
+ // Another application wrote the value: name only the fields this parser declares and zod's fixed codes.
104
+ const problems = parsed.error.issues.map((issue) => {
105
+ const [top] = issue.path
106
+ const field = typeof top === 'string' && DESCRIPTION_FIELDS.includes(top) ? top : '(root)'
107
+ return `description: invalid at ${field} (${issue.code})`
108
+ })
109
+ throw new ContractError([...new Set(problems)])
110
+ }
111
+ const { digest, ...described } = parsed.data
112
+ if (exportDigest(described) !== digest) {
113
+ throw new ContractError([`description ${described.app}/${described.export}@${described.version}: its digest does not match what it describes`])
114
+ }
115
+ return parsed.data
116
+ }
117
+
118
+ /**
119
+ * What an owner's change of one export version breaks for a consumer that imports `procedures` of it: a removed
120
+ * procedure, an input that accepts less, a changed output, or a changed audience. An output may not even gain a field,
121
+ * because the consumer parses every answer against its vendored schema, whose objects are strict; an input object may
122
+ * gain optional fields. Every other change is a new version. Empty when the change is compatible.
123
+ */
124
+ export function incompatibleChanges(before: ExportDescription, after: ExportDescription, procedures: readonly string[]): string[] {
125
+ const problems: string[] = []
126
+ if (before.audience !== after.audience) {
127
+ problems.push('changes its audience')
128
+ }
129
+ for (const path of procedures) {
130
+ const old = Object.hasOwn(before.procedures, path) ? before.procedures[path] : undefined
131
+ const now = Object.hasOwn(after.procedures, path) ? after.procedures[path] : undefined
132
+ // A procedure the export never had is the consumer's mistake, which its own deploy is refused for.
133
+ if (old === undefined) {
134
+ continue
135
+ }
136
+ if (now === undefined) {
137
+ problems.push(`removes '${path}'`)
138
+ continue
139
+ }
140
+ if (!acceptsEveryInput(old.input, now.input)) {
141
+ problems.push(`'${path}' accepts less input than before`)
142
+ }
143
+ if (canonicalJson(canonicalShape(old.output)) !== canonicalJson(canonicalShape(now.output))) {
144
+ problems.push(`'${path}' answers another output than before`)
145
+ }
146
+ }
147
+ return problems
148
+ }
149
+
150
+ function acceptsEveryInput(before: SchemaShape | null, after: SchemaShape | null): boolean {
151
+ if (before === null || after === null) {
152
+ return before === after
153
+ }
154
+ return widens(canonicalShape(before), canonicalShape(after))
155
+ }
156
+
157
+ /** Whether `after` accepts every value `before` does, by the one widening allowed: an object field that is new and optional. */
158
+ function widens(before: SchemaShape, after: SchemaShape): boolean {
159
+ if (before.type === 'object' && after.type === 'object') {
160
+ const kept = Object.entries(before.fields).every(([name, field]) => {
161
+ const next = Object.hasOwn(after.fields, name) ? after.fields[name] : undefined
162
+ return next !== undefined && widens(field, next)
163
+ })
164
+ return kept && Object.entries(after.fields).every(([name, field]) => Object.hasOwn(before.fields, name) || field.type === 'optional')
165
+ }
166
+ if ((before.type === 'optional' && after.type === 'optional') || (before.type === 'nullable' && after.type === 'nullable')) {
167
+ return widens(before.inner, after.inner)
168
+ }
169
+ return canonicalJson(before) === canonicalJson(after)
170
+ }