@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,113 @@
1
+ // What a contract compiles to for IAM (ADR-0012 §4): the vocabulary the deploy reconciles. The proxy reads the
2
+ // endpoint table itself (`contractProxyTable` in `@fabrika/proxy-core/manifest`).
3
+
4
+ import {
5
+ APP_CALLER_PREFIX,
6
+ type AppDelegationDef,
7
+ type AppLinkMountDef,
8
+ type AppSchema,
9
+ type AppScopeDef,
10
+ type AppWorkloadDef,
11
+ type ContractArtifact,
12
+ type DimensionArtifact,
13
+ directoryLookups,
14
+ endpointDelegation,
15
+ type EndpointRow,
16
+ JOB_SITE_PREFIX,
17
+ type LinkMountArtifact,
18
+ type RoleArtifact,
19
+ type RoleDef,
20
+ } from '@fabrika/auth-core'
21
+
22
+ /**
23
+ * The IAM vocabulary of a contract application, marked `contract` so IAM refuses every wildcard written for it: the
24
+ * dimensions as scopes with their container, creator role and members actions; the actions; the roles, a membership
25
+ * role with its scope and the roles that may assign it; the link mounts; the delegations; the workloads; and the locale,
26
+ * display name and e-mail wording IAM names the application with. Every artifact
27
+ * dimension is a scope, a file collection's `file:<name>` included, because access to a file is checked there.
28
+ */
29
+ export function schemaFromContract(artifact: ContractArtifact): AppSchema {
30
+ return {
31
+ scopes: Object.entries(artifact.dimensions).sort(([a], [b]) => byCodeUnit(a, b)).map(([type, dimension]) =>
32
+ scopeDef(type, dimension, directoryLookups(artifact, type))
33
+ ),
34
+ actions: Object.entries(artifact.actions).sort(([a], [b]) => byCodeUnit(a, b)).map(([action, description]) => ({ action, description })),
35
+ roles: Object.fromEntries(Object.entries(artifact.roles).map(([key, role]) => [key, roleDef(key, role)])),
36
+ contract: true,
37
+ links: Object.fromEntries(Object.entries(artifact.mounts).flatMap(([name, mount]) => (mount.kind === 'link' ? [[name, linkDef(mount)]] : []))),
38
+ delegations: Object.fromEntries(artifact.endpoints.flatMap((row) => {
39
+ const delegation = delegationOf(artifact, row)
40
+ return delegation === null ? [] : [[row.id, delegation]]
41
+ })),
42
+ workloads: workloadsOf(artifact),
43
+ ...(artifact.locale === undefined ? {} : { locale: artifact.locale }),
44
+ ...(artifact.displayName === undefined ? {} : { displayName: artifact.displayName }),
45
+ ...(artifact.mail === undefined ? {} : { mail: artifact.mail }),
46
+ ...(artifact.members === undefined ? {} : { members: { manage: artifact.members.manage, list: artifact.members.list } }),
47
+ ...(artifact.directory === 'labels' ? { directory: 'labels' } : {}),
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Every job and webhook with its exact actions, a job with the link mount it declares it `issues`, and every consumer of
53
+ * an export with the actions of the export rows it may call: what each workload key carries (ADR-0022 "Principal kinds").
54
+ */
55
+ function workloadsOf(artifact: ContractArtifact): AppWorkloadDef[] {
56
+ const jobs = Object.entries(artifact.jobs).map(([name, job]): AppWorkloadDef => {
57
+ const issues = artifact.declared?.[`${JOB_SITE_PREFIX}${name}`]?.issues ?? null
58
+ return { kind: 'job', name, actions: [...job.actions].sort(byCodeUnit), ...(issues === null ? {} : { issues }) }
59
+ })
60
+ const webhooks = Object.entries(artifact.mounts).flatMap(([name, mount]): AppWorkloadDef[] =>
61
+ mount.kind === 'webhook' ? [{ kind: 'webhook', name, actions: [...mount.actions].sort(byCodeUnit) }] : []
62
+ )
63
+ const consumers = new Map<string, Set<string>>()
64
+ for (const row of artifact.endpoints) {
65
+ if (row.source.kind !== 'export' || row.requirement?.kind !== 'action') continue
66
+ for (const caller of row.callers) {
67
+ if (!caller.startsWith(APP_CALLER_PREFIX)) continue
68
+ const consumer = caller.slice(APP_CALLER_PREFIX.length)
69
+ consumers.set(consumer, (consumers.get(consumer) ?? new Set<string>()).add(row.requirement.action))
70
+ }
71
+ }
72
+ const apps = [...consumers].map(([name, actions]): AppWorkloadDef => ({ kind: 'app', name, actions: [...actions].sort(byCodeUnit) }))
73
+ return [...jobs, ...webhooks, ...apps].sort((a, b) => byCodeUnit(`${a.kind}:${a.name}`, `${b.kind}:${b.name}`))
74
+ }
75
+
76
+ /** Key order differs between the two producers of an artifact; the schema must not. */
77
+ function byCodeUnit(a: string, b: string): number {
78
+ return a < b ? -1 : a > b ? 1 : 0
79
+ }
80
+
81
+ function scopeDef(type: string, dimension: DimensionArtifact, lookups: readonly string[]): AppScopeDef {
82
+ return {
83
+ type,
84
+ ...(dimension.in === null ? {} : { in: dimension.in }),
85
+ ...(dimension.creator === null ? {} : { creator: dimension.creator }),
86
+ ...(dimension.members === null ? {} : { members: { manage: dimension.members.manage, list: dimension.members.list } }),
87
+ ...(dimension.directory === 'labels' ? { directory: 'labels' } : {}),
88
+ ...(lookups.length === 0 ? {} : { lookups: [...lookups] }),
89
+ }
90
+ }
91
+
92
+ function roleDef(key: string, role: RoleArtifact): RoleDef {
93
+ // `external` is carried on every role, so IAM refuses one on a role that is not a membership role instead of never seeing it.
94
+ return {
95
+ name: key,
96
+ permissions: [...role.grants],
97
+ ...(role.member === null ? {} : { member: { scope: role.member.scope, assignableBy: [...role.member.assignableBy] } }),
98
+ ...(role.external === true ? { external: true } : {}),
99
+ }
100
+ }
101
+
102
+ function linkDef(mount: LinkMountArtifact): AppLinkMountDef {
103
+ return { dimension: mount.dimension, grants: [...mount.grants], issuedBy: mount.issuedBy, maxDays: mount.maxDays }
104
+ }
105
+
106
+ /** The grant IAM issues for an endpoint that spends membership authority; `endpointDelegation` decides which do. */
107
+ function delegationOf(artifact: ContractArtifact, row: EndpointRow): AppDelegationDef | null {
108
+ const purpose = endpointDelegation(artifact, row)
109
+ if (purpose === null || row.requirement?.kind !== 'action' || row.requirement.access.form === 'app') {
110
+ return null
111
+ }
112
+ return { action: row.requirement.action, dimension: row.requirement.access.dimension, purpose }
113
+ }
@@ -0,0 +1,30 @@
1
+ /** Every reason a contract does not compile, each with where it is and the allowed form. */
2
+ export class ContractError extends Error {
3
+ override readonly name = 'ContractError'
4
+ readonly problems: readonly string[]
5
+
6
+ constructor(problems: readonly string[]) {
7
+ super(`the contract does not compile:\n- ${problems.join('\n- ')}`)
8
+ this.problems = problems
9
+ }
10
+ }
11
+
12
+ /** Collects problems so that one compile reports all of them, not the first. */
13
+ export class Problems {
14
+ readonly #problems: string[] = []
15
+
16
+ add(where: string, problem: string): void {
17
+ this.#problems.push(`${where}: ${problem}`)
18
+ }
19
+
20
+ throwIfAny(): void {
21
+ if (this.#problems.length > 0) {
22
+ throw new ContractError([...this.#problems])
23
+ }
24
+ }
25
+ }
26
+
27
+ /** A builder of the staged kit whose signature is fixed and whose runtime a later work unit of the sprint adds. */
28
+ export function notImplemented(what: string, unit: string): never {
29
+ throw new Error(`${what} is not implemented (${unit})`)
30
+ }
@@ -0,0 +1,152 @@
1
+ // A `SchemaShape` turned back into a zod schema, and the part of a validation issue's path that the shape declares.
2
+ // A connector validates against schemas built here from the snapshot's description, never against the application's
3
+ // own schema objects, which application code could change after `boot` checked them.
4
+
5
+ import type { SchemaShape, StringShape } from '@fabrika/auth-core'
6
+ import { z } from 'zod/v4'
7
+
8
+ /** The zod schema that admits what `shape` describes; `describeSchema` reads it back into the same shape. */
9
+ export function schemaFromShape(shape: SchemaShape): z.ZodType {
10
+ switch (shape.type) {
11
+ case 'string':
12
+ return stringSchema(shape)
13
+ case 'number': {
14
+ let schema = shape.int ? z.int() : z.number()
15
+ if (shape.min !== null) {
16
+ schema = shape.exclusiveMin ? schema.gt(shape.min) : schema.gte(shape.min)
17
+ }
18
+ if (shape.max !== null) {
19
+ schema = shape.exclusiveMax ? schema.lt(shape.max) : schema.lte(shape.max)
20
+ }
21
+ return shape.multipleOf === null ? schema : schema.multipleOf(shape.multipleOf)
22
+ }
23
+ case 'boolean':
24
+ return z.boolean()
25
+ case 'null':
26
+ return z.null()
27
+ case 'literal':
28
+ return z.literal(shape.value)
29
+ case 'enum': {
30
+ const [first, ...rest] = shape.values
31
+ return first === undefined ? z.never() : z.enum([first, ...rest])
32
+ }
33
+ case 'array': {
34
+ let schema = z.array(schemaFromShape(shape.items))
35
+ if (shape.minItems !== null) {
36
+ schema = schema.min(shape.minItems)
37
+ }
38
+ return shape.maxItems === null ? schema : schema.max(shape.maxItems)
39
+ }
40
+ case 'tuple': {
41
+ const [first, ...rest] = shape.items.map(schemaFromShape)
42
+ if (first === undefined) {
43
+ // A tuple of no fixed items and a rest admits what an array of the rest admits.
44
+ return shape.rest === null ? z.tuple([]) : z.array(schemaFromShape(shape.rest))
45
+ }
46
+ return shape.rest === null ? z.tuple([first, ...rest]) : z.tuple([first, ...rest], schemaFromShape(shape.rest))
47
+ }
48
+ case 'record':
49
+ return z.record(recordKey(shape.key), schemaFromShape(shape.value))
50
+ case 'object':
51
+ return z.strictObject(Object.fromEntries(Object.entries(shape.fields).map(([name, field]) => [name, schemaFromShape(field)])))
52
+ case 'optional':
53
+ return z.optional(schemaFromShape(shape.inner))
54
+ case 'nullable':
55
+ return z.nullable(schemaFromShape(shape.inner))
56
+ case 'union':
57
+ return z.union(shape.options.map(schemaFromShape))
58
+ }
59
+ }
60
+
61
+ /** A record's keys are strings: a string, a string literal, an enum or a union of them; any other key admits nothing. */
62
+ function recordKey(shape: SchemaShape): z.ZodType<string, string> {
63
+ switch (shape.type) {
64
+ case 'string':
65
+ return stringSchema(shape)
66
+ case 'enum': {
67
+ const [first, ...rest] = shape.values
68
+ return first === undefined ? z.never() : z.enum([first, ...rest])
69
+ }
70
+ case 'literal':
71
+ return typeof shape.value === 'string' ? z.literal(shape.value) : z.never()
72
+ case 'union':
73
+ return z.union(shape.options.map(recordKey))
74
+ default:
75
+ return z.never()
76
+ }
77
+ }
78
+
79
+ function stringSchema(shape: StringShape): z.ZodType<string, string> {
80
+ let schema: z.ZodString | z.ZodStringFormat = formatSchema(shape.format)
81
+ if (shape.minLength !== null) {
82
+ schema = schema.min(shape.minLength)
83
+ }
84
+ if (shape.maxLength !== null) {
85
+ schema = schema.max(shape.maxLength)
86
+ }
87
+ if (shape.pattern !== null) {
88
+ schema = schema.regex(new RegExp(shape.pattern))
89
+ }
90
+ if (shape.startsWith !== null) {
91
+ schema = schema.startsWith(shape.startsWith)
92
+ }
93
+ if (shape.endsWith !== null) {
94
+ schema = schema.endsWith(shape.endsWith)
95
+ }
96
+ return shape.includes === null ? schema : schema.includes(shape.includes)
97
+ }
98
+
99
+ function formatSchema(format: StringShape['format']): z.ZodString | z.ZodStringFormat {
100
+ switch (format) {
101
+ case null:
102
+ return z.string()
103
+ case 'uuid':
104
+ return z.uuid()
105
+ case 'email':
106
+ return z.email()
107
+ case 'url':
108
+ return z.url()
109
+ case 'datetime':
110
+ return z.iso.datetime()
111
+ case 'date':
112
+ return z.iso.date()
113
+ case 'time':
114
+ return z.iso.time()
115
+ case 'duration':
116
+ return z.iso.duration()
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The path of a validation issue as far as `shape` declares it: field names the schema names, `[]` for an array item,
122
+ * a tuple position, `{}` for a record value. Record keys and array indexes come from the value, which another
123
+ * application wrote, so they never reach a message or a log.
124
+ */
125
+ export function declaredPath(shape: SchemaShape, path: readonly PropertyKey[]): string {
126
+ let current: SchemaShape = shape
127
+ let rendered = ''
128
+ for (const segment of path) {
129
+ while (current.type === 'optional' || current.type === 'nullable') {
130
+ current = current.inner
131
+ }
132
+ if (current.type === 'object' && typeof segment === 'string' && Object.hasOwn(current.fields, segment)) {
133
+ rendered += rendered === '' ? segment : `.${segment}`
134
+ current = current.fields[segment] ?? current
135
+ } else if (current.type === 'array') {
136
+ rendered += '[]'
137
+ current = current.items
138
+ } else if (current.type === 'tuple' && typeof segment === 'number' && segment < current.items.length) {
139
+ rendered += `[${segment}]`
140
+ current = current.items[segment] ?? current
141
+ } else if (current.type === 'tuple' && current.rest !== null) {
142
+ rendered += '[]'
143
+ current = current.rest
144
+ } else if (current.type === 'record') {
145
+ rendered += '{}'
146
+ current = current.value
147
+ } else {
148
+ break
149
+ }
150
+ }
151
+ return rendered === '' ? '(root)' : rendered
152
+ }