@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.
- package/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- 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
|
+
}
|