@fabrika/app 0.3.3 → 0.4.1
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 +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -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 +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -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 +331 -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 +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -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 +824 -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 +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -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 +760 -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 +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -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/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -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,407 @@
|
|
|
1
|
+
// Reads a zod 4 schema of the contract subset into the `SchemaShape` the artifact carries. The subset is the one
|
|
2
|
+
// `@fabrika/check` accepts (`packages/check/CLAUDE.md`); a form outside it is a problem, never dropped silently.
|
|
3
|
+
|
|
4
|
+
import type { ArrayShape, NumberShape, SchemaShape, StringShape } from '@fabrika/auth-core'
|
|
5
|
+
import { z } from 'zod/v4'
|
|
6
|
+
import { attachmentOf } from './attachment'
|
|
7
|
+
import type { Problems } from './problems'
|
|
8
|
+
|
|
9
|
+
const SUBSET = 'allowed: the supported z subset of packages/check/CLAUDE.md'
|
|
10
|
+
|
|
11
|
+
type StringFormat = NonNullable<StringShape['format']>
|
|
12
|
+
|
|
13
|
+
const STRING_FORMATS: ReadonlySet<string> = new Set<StringFormat>(['uuid', 'email', 'url', 'datetime', 'date', 'time', 'duration'])
|
|
14
|
+
|
|
15
|
+
function isStringFormat(format: string): format is StringFormat {
|
|
16
|
+
return STRING_FORMATS.has(format)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A schema's shape, or `null` after recording why it has none. */
|
|
20
|
+
export function describeSchema(schema: z.core.$ZodType, where: string, problems: Problems): SchemaShape | null {
|
|
21
|
+
if (attachmentOf(schema) !== null) {
|
|
22
|
+
problems.add(where, "an attachment() is a procedure's whole output, never part of a schema or an input (allowed: output: attachment({ … }))")
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
if (schema instanceof z.core.$ZodString || schema instanceof z.core.$ZodStringFormat) {
|
|
26
|
+
return describeString(schema, where, problems)
|
|
27
|
+
}
|
|
28
|
+
if (schema instanceof z.core.$ZodNumber) {
|
|
29
|
+
return describeNumber(schema, where, problems)
|
|
30
|
+
}
|
|
31
|
+
if (!hasNoChecks(schema, where, problems)) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
if (schema instanceof z.core.$ZodBoolean) {
|
|
35
|
+
if (schema._zod.def.coerce === true) {
|
|
36
|
+
problems.add(where, `a coercing schema is outside the contract subset (${SUBSET})`)
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
return { type: 'boolean' }
|
|
40
|
+
}
|
|
41
|
+
if (schema instanceof z.core.$ZodNull) {
|
|
42
|
+
return { type: 'null' }
|
|
43
|
+
}
|
|
44
|
+
if (schema instanceof z.core.$ZodArray) {
|
|
45
|
+
return describeArray(schema, where, problems)
|
|
46
|
+
}
|
|
47
|
+
if (schema instanceof z.core.$ZodLiteral) {
|
|
48
|
+
return describeLiteral(schema._zod.def.values, where, problems)
|
|
49
|
+
}
|
|
50
|
+
if (schema instanceof z.core.$ZodEnum) {
|
|
51
|
+
return describeEnum(Object.values(schema._zod.def.entries), where, problems)
|
|
52
|
+
}
|
|
53
|
+
if (schema instanceof z.core.$ZodObject) {
|
|
54
|
+
return describeObject(schema, where, problems)
|
|
55
|
+
}
|
|
56
|
+
if (schema instanceof z.core.$ZodTuple) {
|
|
57
|
+
const items = schema._zod.def.items.map((item, index) => describeSchema(item, `${where}[${index}]`, problems))
|
|
58
|
+
const rest = schema._zod.def.rest === null ? null : describeSchema(schema._zod.def.rest, `${where}[…]`, problems)
|
|
59
|
+
return allPresent(items) && (schema._zod.def.rest === null || rest !== null) ? { type: 'tuple', items, rest } : null
|
|
60
|
+
}
|
|
61
|
+
if (schema instanceof z.core.$ZodRecord) {
|
|
62
|
+
const key = describeSchema(schema._zod.def.keyType, `${where} key`, problems)
|
|
63
|
+
const value = describeSchema(schema._zod.def.valueType, `${where} value`, problems)
|
|
64
|
+
return key === null || value === null ? null : { type: 'record', key, value }
|
|
65
|
+
}
|
|
66
|
+
if (schema instanceof z.core.$ZodDiscriminatedUnion && !hasRequiredDiscriminator(schema, where, problems)) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
// A discriminated union admits what the plain union of its options admits, once no discriminator has a default.
|
|
70
|
+
if (schema instanceof z.core.$ZodUnion) {
|
|
71
|
+
const options = schema._zod.def.options.map((option, index) => describeSchema(option, `${where} option ${index}`, problems))
|
|
72
|
+
return allPresent(options) ? { type: 'union', options } : null
|
|
73
|
+
}
|
|
74
|
+
// A default makes the value optional to the caller; the framework fills it in from the live schema.
|
|
75
|
+
if (schema instanceof z.core.$ZodDefault && !admitsItsDefault(schema, where, problems)) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
if (schema instanceof z.core.$ZodOptional || schema instanceof z.core.$ZodDefault) {
|
|
79
|
+
const inner = describeSchema(schema._zod.def.innerType, where, problems)
|
|
80
|
+
return inner === null ? null : { type: 'optional', inner }
|
|
81
|
+
}
|
|
82
|
+
if (schema instanceof z.core.$ZodNullable) {
|
|
83
|
+
const inner = describeSchema(schema._zod.def.innerType, where, problems)
|
|
84
|
+
return inner === null ? null : { type: 'nullable', inner }
|
|
85
|
+
}
|
|
86
|
+
problems.add(where, `a '${schema._zod.def.type}' schema is outside the contract subset (${SUBSET})`)
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function describeObject(schema: z.core.$ZodObject, where: string, problems: Problems): SchemaShape | null {
|
|
91
|
+
if (!(schema._zod.def.catchall instanceof z.core.$ZodNever)) {
|
|
92
|
+
problems.add(where, 'the object admits undeclared keys (allowed: z.strictObject({ … }))')
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
const fields: Record<string, SchemaShape> = {}
|
|
96
|
+
let complete = true
|
|
97
|
+
for (const [name, field] of Object.entries(schema._zod.def.shape)) {
|
|
98
|
+
const shape = describeSchema(field, `${where}.${name}`, problems)
|
|
99
|
+
if (shape === null) {
|
|
100
|
+
complete = false
|
|
101
|
+
} else {
|
|
102
|
+
fields[name] = shape
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return complete ? { type: 'object', fields } : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function describeLiteral(values: readonly unknown[], where: string, problems: Problems): SchemaShape | null {
|
|
109
|
+
const shapes: SchemaShape[] = []
|
|
110
|
+
for (const value of values) {
|
|
111
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) {
|
|
112
|
+
shapes.push({ type: 'literal', value })
|
|
113
|
+
} else {
|
|
114
|
+
problems.add(where, `the literal ${String(value)} is not JSON (allowed: a string, number, boolean or null)`)
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const [only] = shapes
|
|
119
|
+
return shapes.length === 1 && only !== undefined ? only : { type: 'union', options: shapes }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function describeEnum(values: readonly unknown[], where: string, problems: Problems): SchemaShape | null {
|
|
123
|
+
const strings: string[] = []
|
|
124
|
+
for (const value of values) {
|
|
125
|
+
if (typeof value !== 'string') {
|
|
126
|
+
problems.add(where, `the enum value ${String(value)} is not a string (allowed: z.enum(['a', 'b']))`)
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
strings.push(value)
|
|
130
|
+
}
|
|
131
|
+
return { type: 'enum', values: strings }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function describeString(schema: z.core.$ZodString | z.core.$ZodStringFormat, where: string, problems: Problems): StringShape | null {
|
|
135
|
+
if (schema._zod.def.coerce === true) {
|
|
136
|
+
problems.add(where, `a coercing schema is outside the contract subset (${SUBSET})`)
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
const shape: { -readonly [K in keyof StringShape]: StringShape[K] } = {
|
|
140
|
+
type: 'string',
|
|
141
|
+
format: null,
|
|
142
|
+
minLength: null,
|
|
143
|
+
maxLength: null,
|
|
144
|
+
pattern: null,
|
|
145
|
+
startsWith: null,
|
|
146
|
+
endsWith: null,
|
|
147
|
+
includes: null,
|
|
148
|
+
}
|
|
149
|
+
// `z.uuid()` is itself the format check that `z.string().uuid()` adds to its checks.
|
|
150
|
+
const checks: readonly z.core.$ZodCheck<string>[] = schema instanceof z.core.$ZodCheckStringFormat
|
|
151
|
+
? [schema, ...(schema._zod.def.checks ?? [])]
|
|
152
|
+
: (schema._zod.def.checks ?? [])
|
|
153
|
+
let complete = true
|
|
154
|
+
const setOnce = (field: 'pattern' | 'startsWith' | 'endsWith' | 'includes', value: string, method: string): void => {
|
|
155
|
+
if (shape[field] === null) {
|
|
156
|
+
shape[field] = value
|
|
157
|
+
} else {
|
|
158
|
+
problems.add(where, `the string calls .${method}() twice (allowed: one .${method}() per string)`)
|
|
159
|
+
complete = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const check of checks) {
|
|
163
|
+
if (isLengthCheck(check)) {
|
|
164
|
+
const bounds = lengthBounds(check, { min: shape.minLength, max: shape.maxLength }, where, problems)
|
|
165
|
+
complete &&= bounds !== null
|
|
166
|
+
shape.minLength = bounds?.min ?? shape.minLength
|
|
167
|
+
shape.maxLength = bounds?.max ?? shape.maxLength
|
|
168
|
+
} else if (check instanceof z.core.$ZodCheckRegex) {
|
|
169
|
+
if (check._zod.def.pattern.flags !== '') {
|
|
170
|
+
problems.add(where, `the pattern /${check._zod.def.pattern.source}/ has flags (allowed: a pattern without flags)`)
|
|
171
|
+
complete = false
|
|
172
|
+
}
|
|
173
|
+
setOnce('pattern', check._zod.def.pattern.source, 'regex')
|
|
174
|
+
} else if (check instanceof z.core.$ZodCheckStartsWith) {
|
|
175
|
+
setOnce('startsWith', check._zod.def.prefix, 'startsWith')
|
|
176
|
+
} else if (check instanceof z.core.$ZodCheckEndsWith) {
|
|
177
|
+
setOnce('endsWith', check._zod.def.suffix, 'endsWith')
|
|
178
|
+
} else if (check instanceof z.core.$ZodCheckIncludes) {
|
|
179
|
+
if (check._zod.def.position !== undefined) {
|
|
180
|
+
problems.add(where, `.includes() with a position is outside the contract subset (allowed: .includes('text'))`)
|
|
181
|
+
complete = false
|
|
182
|
+
}
|
|
183
|
+
setOnce('includes', check._zod.def.includes, 'includes')
|
|
184
|
+
} else if (check instanceof z.core.$ZodCheckStringFormat) {
|
|
185
|
+
const format = check._zod.def.format
|
|
186
|
+
if (!isStringFormat(format) || !hasDefaultOptions(check)) {
|
|
187
|
+
problems.add(where, `the string format '${format}' with these options is outside the contract subset (${SUBSET})`)
|
|
188
|
+
complete = false
|
|
189
|
+
} else if (shape.format !== null) {
|
|
190
|
+
problems.add(where, `the string has two formats, ${shape.format} and ${format} (allowed: one format per string)`)
|
|
191
|
+
complete = false
|
|
192
|
+
} else {
|
|
193
|
+
shape.format = format
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
problems.add(where, `a '${check._zod.def.check}' check is outside the contract subset (${SUBSET})`)
|
|
197
|
+
complete = false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return complete && isOrderFree(shape, where, problems) && isSatisfiable(shape.minLength, shape.maxLength, where, problems) ? shape : null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The artifact keeps a string's checks unordered, so they must not depend on their order. `z.url()` rewrites the
|
|
205
|
+
* value it accepts (`HTTPS://A.CO` → `https://a.co/`), so a check before it and after it see different strings;
|
|
206
|
+
* `startsWith`, `endsWith` and `includes` overlap with what a format admits, so they stay on a plain string.
|
|
207
|
+
*/
|
|
208
|
+
function isOrderFree(shape: StringShape, where: string, problems: Problems): boolean {
|
|
209
|
+
const affixed = shape.startsWith !== null || shape.endsWith !== null || shape.includes !== null
|
|
210
|
+
if (shape.format === 'url' && (affixed || shape.pattern !== null || shape.minLength !== null || shape.maxLength !== null)) {
|
|
211
|
+
problems.add(where, 'z.url() rewrites the value, so another string check depends on its order (allowed: z.url() with no other check)')
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
if (shape.format !== null && affixed) {
|
|
215
|
+
problems.add(where, `.startsWith(), .endsWith() and .includes() do not combine with the format '${shape.format}' (allowed: one or the other)`)
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
return true
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* zod returns a default without parsing it, so a default its own schema refuses reaches a handler, or leaves an
|
|
223
|
+
* endpoint as output, outside the shape the artifact records. A default the schema admits is a value a caller could
|
|
224
|
+
* have sent, so the shape stays faithful without naming it.
|
|
225
|
+
*/
|
|
226
|
+
function admitsItsDefault(schema: z.core.$ZodDefault, where: string, problems: Problems): boolean {
|
|
227
|
+
if (z.safeParse(schema._zod.def.innerType, schema._zod.def.defaultValue).success) {
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
problems.add(where, 'the default is a value its own schema refuses, and zod returns it unchecked (allowed: a default the schema admits)')
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A discriminator with a default lets a value without it match an option, which the artifact's union cannot say. The
|
|
236
|
+
* default may sit under `.nullable()` or `.optional()`, so every wrapper is looked through.
|
|
237
|
+
*/
|
|
238
|
+
function hasRequiredDiscriminator(schema: z.core.$ZodDiscriminatedUnion, where: string, problems: Problems): boolean {
|
|
239
|
+
const discriminator = schema._zod.def.discriminator
|
|
240
|
+
for (const option of schema._zod.def.options) {
|
|
241
|
+
const field = option instanceof z.core.$ZodObject ? option._zod.def.shape[discriminator] : undefined
|
|
242
|
+
if (field !== undefined && hasDefault(field)) {
|
|
243
|
+
problems.add(where, `the discriminator '${discriminator}' has a default (allowed: a required literal in every option)`)
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function hasDefault(schema: z.core.$ZodType): boolean {
|
|
251
|
+
let wrapper = schema
|
|
252
|
+
for (;;) {
|
|
253
|
+
if (wrapper instanceof z.core.$ZodDefault) return true
|
|
254
|
+
if (!(wrapper instanceof z.core.$ZodOptional || wrapper instanceof z.core.$ZodNullable)) return false
|
|
255
|
+
wrapper = wrapper._zod.def.innerType
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
type LengthCheck = z.core.$ZodCheckMinLength | z.core.$ZodCheckMaxLength | z.core.$ZodCheckLengthEquals
|
|
260
|
+
|
|
261
|
+
function isLengthCheck(check: z.core.$ZodCheck<never>): check is LengthCheck {
|
|
262
|
+
return check instanceof z.core.$ZodCheckMinLength || check instanceof z.core.$ZodCheckMaxLength || check instanceof z.core.$ZodCheckLengthEquals
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface Bounds {
|
|
266
|
+
readonly min: number | null
|
|
267
|
+
readonly max: number | null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The tighter bounds after one length check, or `null` when its value is not a length. */
|
|
271
|
+
function lengthBounds(check: LengthCheck, bounds: Bounds, where: string, problems: Problems): Bounds | null {
|
|
272
|
+
const value = check instanceof z.core.$ZodCheckMinLength
|
|
273
|
+
? check._zod.def.minimum
|
|
274
|
+
: check instanceof z.core.$ZodCheckMaxLength
|
|
275
|
+
? check._zod.def.maximum
|
|
276
|
+
: check._zod.def.length
|
|
277
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
278
|
+
problems.add(where, `the length ${value} is not a whole number of at least 0`)
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
const min = check instanceof z.core.$ZodCheckMaxLength ? bounds.min : Math.max(bounds.min ?? 0, value)
|
|
282
|
+
const max = check instanceof z.core.$ZodCheckMinLength ? bounds.max : Math.min(bounds.max ?? value, value)
|
|
283
|
+
return { min, max }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isSatisfiable(min: number | null, max: number | null, where: string, problems: Problems): boolean {
|
|
287
|
+
if (min !== null && max !== null && min > max) {
|
|
288
|
+
problems.add(where, `the minimum ${min} is above the maximum ${max}, so nothing is admitted`)
|
|
289
|
+
return false
|
|
290
|
+
}
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Options change what a format admits, and the artifact names only the format. */
|
|
295
|
+
function hasDefaultOptions(check: z.core.$ZodCheckStringFormat): boolean {
|
|
296
|
+
if (check instanceof z.core.$ZodUUID) {
|
|
297
|
+
return check._zod.def.version === undefined
|
|
298
|
+
}
|
|
299
|
+
if (check instanceof z.core.$ZodEmail) {
|
|
300
|
+
return check._zod.def.pattern?.source === z.regexes.email.source
|
|
301
|
+
}
|
|
302
|
+
if (check instanceof z.core.$ZodURL) {
|
|
303
|
+
return check._zod.def.hostname === undefined && check._zod.def.protocol === undefined
|
|
304
|
+
}
|
|
305
|
+
if (check instanceof z.core.$ZodISODateTime) {
|
|
306
|
+
return !check._zod.def.offset && !check._zod.def.local && check._zod.def.precision === null
|
|
307
|
+
}
|
|
308
|
+
if (check instanceof z.core.$ZodISOTime) {
|
|
309
|
+
return (check._zod.def.precision ?? null) === null
|
|
310
|
+
}
|
|
311
|
+
return true
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function describeNumber(schema: z.core.$ZodNumber, where: string, problems: Problems): NumberShape | null {
|
|
315
|
+
if (schema._zod.def.coerce === true) {
|
|
316
|
+
problems.add(where, `a coercing schema is outside the contract subset (${SUBSET})`)
|
|
317
|
+
return null
|
|
318
|
+
}
|
|
319
|
+
const shape: { -readonly [K in keyof NumberShape]: NumberShape[K] } = {
|
|
320
|
+
type: 'number',
|
|
321
|
+
int: false,
|
|
322
|
+
min: null,
|
|
323
|
+
exclusiveMin: false,
|
|
324
|
+
max: null,
|
|
325
|
+
exclusiveMax: false,
|
|
326
|
+
multipleOf: null,
|
|
327
|
+
}
|
|
328
|
+
// `z.int()` is itself the format check that `z.number().int()` adds to its checks.
|
|
329
|
+
const checks: readonly z.core.$ZodCheck<never>[] = schema instanceof z.core.$ZodCheckNumberFormat
|
|
330
|
+
? [schema, ...(schema._zod.def.checks ?? [])]
|
|
331
|
+
: (schema._zod.def.checks ?? [])
|
|
332
|
+
let complete = true
|
|
333
|
+
for (const check of checks) {
|
|
334
|
+
if (check instanceof z.core.$ZodCheckNumberFormat && check._zod.def.format === 'safeint') {
|
|
335
|
+
shape.int = true
|
|
336
|
+
} else if (
|
|
337
|
+
(check instanceof z.core.$ZodCheckGreaterThan || check instanceof z.core.$ZodCheckLessThan || check instanceof z.core.$ZodCheckMultipleOf)
|
|
338
|
+
&& !(typeof check._zod.def.value === 'number' && Number.isFinite(check._zod.def.value))
|
|
339
|
+
) {
|
|
340
|
+
problems.add(where, `the bound ${String(check._zod.def.value)} is not a finite number`)
|
|
341
|
+
complete = false
|
|
342
|
+
} else if (check instanceof z.core.$ZodCheckGreaterThan && typeof check._zod.def.value === 'number') {
|
|
343
|
+
const value = check._zod.def.value
|
|
344
|
+
const exclusive = !check._zod.def.inclusive
|
|
345
|
+
if (shape.min === null || value > shape.min || (value === shape.min && exclusive)) {
|
|
346
|
+
shape.min = value
|
|
347
|
+
shape.exclusiveMin = exclusive
|
|
348
|
+
}
|
|
349
|
+
} else if (check instanceof z.core.$ZodCheckLessThan && typeof check._zod.def.value === 'number') {
|
|
350
|
+
const value = check._zod.def.value
|
|
351
|
+
const exclusive = !check._zod.def.inclusive
|
|
352
|
+
if (shape.max === null || value < shape.max || (value === shape.max && exclusive)) {
|
|
353
|
+
shape.max = value
|
|
354
|
+
shape.exclusiveMax = exclusive
|
|
355
|
+
}
|
|
356
|
+
} else if (check instanceof z.core.$ZodCheckMultipleOf && typeof check._zod.def.value === 'number' && shape.multipleOf === null) {
|
|
357
|
+
if (check._zod.def.value <= 0) {
|
|
358
|
+
problems.add(where, `.multipleOf(${check._zod.def.value}) is not a positive step`)
|
|
359
|
+
complete = false
|
|
360
|
+
}
|
|
361
|
+
shape.multipleOf = check._zod.def.value
|
|
362
|
+
} else {
|
|
363
|
+
problems.add(where, `a '${check._zod.def.check}' check is outside the contract subset (${SUBSET}; one .multipleOf() per number)`)
|
|
364
|
+
complete = false
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (shape.min !== null && shape.max !== null && (shape.min > shape.max || (shape.min === shape.max && (shape.exclusiveMin || shape.exclusiveMax)))) {
|
|
368
|
+
problems.add(where, `the bounds ${shape.min} and ${shape.max} admit no number`)
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
return complete ? shape : null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function describeArray(schema: z.core.$ZodArray, where: string, problems: Problems): ArrayShape | null {
|
|
375
|
+
const items = describeSchema(schema._zod.def.element, `${where}[]`, problems)
|
|
376
|
+
let minItems: number | null = null
|
|
377
|
+
let maxItems: number | null = null
|
|
378
|
+
let complete = items !== null
|
|
379
|
+
for (const check of schema._zod.def.checks ?? []) {
|
|
380
|
+
if (isLengthCheck(check)) {
|
|
381
|
+
const bounds = lengthBounds(check, { min: minItems, max: maxItems }, where, problems)
|
|
382
|
+
complete &&= bounds !== null
|
|
383
|
+
minItems = bounds?.min ?? minItems
|
|
384
|
+
maxItems = bounds?.max ?? maxItems
|
|
385
|
+
} else {
|
|
386
|
+
problems.add(where, `a '${check._zod.def.check}' check is outside the contract subset (${SUBSET})`)
|
|
387
|
+
complete = false
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return complete && items !== null && isSatisfiable(minItems, maxItems, where, problems) ? { type: 'array', items, minItems, maxItems } : null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function hasNoChecks(schema: z.core.$ZodType, where: string, problems: Problems): boolean {
|
|
394
|
+
if (schema instanceof z.core.$ZodArray) {
|
|
395
|
+
return true
|
|
396
|
+
}
|
|
397
|
+
const [check] = schema._zod.def.checks ?? []
|
|
398
|
+
if (check !== undefined) {
|
|
399
|
+
problems.add(where, `a '${check._zod.def.check}' check is outside the contract subset (${SUBSET})`)
|
|
400
|
+
return false
|
|
401
|
+
}
|
|
402
|
+
return true
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function allPresent<T>(values: readonly (T | null)[]): values is readonly T[] {
|
|
406
|
+
return values.every((value) => value !== null)
|
|
407
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// The vendored snapshot module `fabrika connect add` and `pull` write: an export's description as zod source in the
|
|
2
|
+
// contract subset, so `@fabrika/check` reads it as a literal and `describeSnapshot` reads back the shapes it came from.
|
|
3
|
+
// The description arrives from another application, so every value is written as a literal the parser cannot misread.
|
|
4
|
+
|
|
5
|
+
import type { ExportDescription, NumberShape, SchemaShape, StringShape } from '@fabrika/auth-core'
|
|
6
|
+
import { ContractError } from './problems'
|
|
7
|
+
|
|
8
|
+
/** The directory, relative to the application root, that holds the vendored snapshots. */
|
|
9
|
+
export const SNAPSHOT_DIRECTORY = 'fabrika/connect'
|
|
10
|
+
|
|
11
|
+
const HEADER = '// @generated by `fabrika connect` from the export description its owner serves. Do not edit: `fabrika connect pull` rewrites it.'
|
|
12
|
+
|
|
13
|
+
export interface SnapshotNames {
|
|
14
|
+
/** Relative to the application root: `fabrika/connect/catalog.products.v1.ts`. */
|
|
15
|
+
readonly file: string
|
|
16
|
+
/** The snapshot const: `catalogProductsV1`. */
|
|
17
|
+
readonly snapshot: string
|
|
18
|
+
/** The digest const the contract's import names: `catalogProductsV1Digest`. */
|
|
19
|
+
readonly digest: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function capitalized(word: string): string {
|
|
23
|
+
return `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The file and the identifiers of the snapshot of `app`'s export `name` at `version`. */
|
|
27
|
+
export function snapshotNames(app: string, name: string, version: number): SnapshotNames {
|
|
28
|
+
const [first = '', ...rest] = app.split('-')
|
|
29
|
+
const appWord = `${first}${rest.map(capitalized).join('')}`
|
|
30
|
+
const snapshot = `${/^[a-z]/.test(appWord) ? appWord : `app${appWord}`}${capitalized(name)}V${version}`
|
|
31
|
+
return { file: `${SNAPSHOT_DIRECTORY}/${app}.${name}.v${version}.ts`, snapshot, digest: `${snapshot}Digest` }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The snapshot module of `description`. Throws `ContractError` for a shape no zod source in the subset writes. */
|
|
35
|
+
export function snapshotSource(description: ExportDescription): string {
|
|
36
|
+
const names = snapshotNames(description.app, description.export, description.version)
|
|
37
|
+
const procedures = Object.entries(description.procedures).map(([path, procedure]) =>
|
|
38
|
+
[
|
|
39
|
+
`\t\t${key(path)}: {`,
|
|
40
|
+
`\t\t\tinput: ${procedure.input === null ? 'null' : schemaSource(procedure.input, path)},`,
|
|
41
|
+
`\t\t\toutput: ${schemaSource(procedure.output, path)},`,
|
|
42
|
+
'\t\t},',
|
|
43
|
+
].join('\n')
|
|
44
|
+
)
|
|
45
|
+
return [
|
|
46
|
+
HEADER,
|
|
47
|
+
"import { z } from 'zod/v4'",
|
|
48
|
+
'',
|
|
49
|
+
`export const ${names.digest} = ${literal(description.digest)}`,
|
|
50
|
+
'',
|
|
51
|
+
`export const ${names.snapshot} = {`,
|
|
52
|
+
`\tapp: ${literal(description.app)},`,
|
|
53
|
+
`\texport: ${literal(description.export)},`,
|
|
54
|
+
`\tversion: ${number(description.version)},`,
|
|
55
|
+
`\taudience: ${literal(description.audience)},`,
|
|
56
|
+
`\tdigest: ${names.digest},`,
|
|
57
|
+
'\tprocedures: {',
|
|
58
|
+
...procedures,
|
|
59
|
+
'\t},',
|
|
60
|
+
'} as const',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A single-quoted string literal with the value's own characters. JSON escapes every `"` as `\"` and every `\` as a
|
|
67
|
+
* pair, so turning each `\"` back into `"` touches only a quote; a `'` is then escaped.
|
|
68
|
+
*/
|
|
69
|
+
function literal(value: string | number | boolean | null): string {
|
|
70
|
+
if (typeof value !== 'string') {
|
|
71
|
+
return typeof value === 'number' ? number(value) : String(value)
|
|
72
|
+
}
|
|
73
|
+
return `'${JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
77
|
+
|
|
78
|
+
/** `__proto__` as a literal key sets the object's prototype instead of a property, quoted or not. */
|
|
79
|
+
function key(name: string): string {
|
|
80
|
+
if (name === '__proto__') {
|
|
81
|
+
throw new ContractError(['a field or procedure named __proto__ has no object-literal key'])
|
|
82
|
+
}
|
|
83
|
+
return IDENTIFIER.test(name) ? name : literal(name)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function number(value: number): string {
|
|
87
|
+
if (!Number.isFinite(value)) {
|
|
88
|
+
throw new ContractError([`the number ${value} has no literal`])
|
|
89
|
+
}
|
|
90
|
+
return Object.is(value, -0) ? '0' : String(value)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function schemaSource(shape: SchemaShape, where: string): string {
|
|
94
|
+
switch (shape.type) {
|
|
95
|
+
case 'string':
|
|
96
|
+
return stringSource(shape, where)
|
|
97
|
+
case 'number':
|
|
98
|
+
return numberSource(shape)
|
|
99
|
+
case 'boolean':
|
|
100
|
+
case 'null':
|
|
101
|
+
return `z.${shape.type}()`
|
|
102
|
+
case 'literal':
|
|
103
|
+
return `z.literal(${literal(shape.value)})`
|
|
104
|
+
case 'enum':
|
|
105
|
+
if (shape.values.length === 0) {
|
|
106
|
+
throw new ContractError([`procedure '${where}': an enum without values has no zod source`])
|
|
107
|
+
}
|
|
108
|
+
return `z.enum([${shape.values.map(literal).join(', ')}])`
|
|
109
|
+
case 'array':
|
|
110
|
+
return `z.array(${schemaSource(shape.items, where)})${bound('min', shape.minItems)}${bound('max', shape.maxItems)}`
|
|
111
|
+
case 'tuple': {
|
|
112
|
+
const items = `[${shape.items.map((item) => schemaSource(item, where)).join(', ')}]`
|
|
113
|
+
return shape.rest === null ? `z.tuple(${items})` : `z.tuple(${items}, ${schemaSource(shape.rest, where)})`
|
|
114
|
+
}
|
|
115
|
+
case 'record':
|
|
116
|
+
return `z.record(${schemaSource(shape.key, where)}, ${schemaSource(shape.value, where)})`
|
|
117
|
+
case 'object': {
|
|
118
|
+
const fields = Object.entries(shape.fields).map(([name, field]) => `${key(name)}: ${schemaSource(field, where)}`)
|
|
119
|
+
return `z.strictObject({ ${fields.join(', ')} })`
|
|
120
|
+
}
|
|
121
|
+
case 'optional':
|
|
122
|
+
case 'nullable':
|
|
123
|
+
return `z.${shape.type}(${schemaSource(shape.inner, where)})`
|
|
124
|
+
case 'union':
|
|
125
|
+
return `z.union([${shape.options.map((option) => schemaSource(option, where)).join(', ')}])`
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function bound(method: string, value: number | null): string {
|
|
130
|
+
return value === null ? '' : `.${method}(${number(value)})`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ISO_FORMATS: ReadonlySet<string> = new Set(['datetime', 'date', 'time', 'duration'])
|
|
134
|
+
|
|
135
|
+
function stringSource(shape: StringShape, where: string): string {
|
|
136
|
+
const base = shape.format === null ? 'z.string()' : ISO_FORMATS.has(shape.format) ? `z.iso.${shape.format}()` : `z.${shape.format}()`
|
|
137
|
+
const affixes = (['startsWith', 'endsWith', 'includes'] as const).map((field) => {
|
|
138
|
+
const value = shape[field]
|
|
139
|
+
return value === null ? '' : `.${field}(${literal(value)})`
|
|
140
|
+
})
|
|
141
|
+
return `${base}${bound('min', shape.minLength)}${bound('max', shape.maxLength)}${patternSource(shape.pattern, where)}${affixes.join('')}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A pattern is written as a regular-expression literal, which only `.regex()` takes. `RegExp#source` escapes every `/`
|
|
146
|
+
* and line terminator, so a pattern equal to its own normalized source cannot end the literal early.
|
|
147
|
+
*/
|
|
148
|
+
function patternSource(pattern: string | null, where: string): string {
|
|
149
|
+
if (pattern === null) {
|
|
150
|
+
return ''
|
|
151
|
+
}
|
|
152
|
+
let normalized: string
|
|
153
|
+
try {
|
|
154
|
+
normalized = new RegExp(pattern).source
|
|
155
|
+
} catch {
|
|
156
|
+
normalized = ''
|
|
157
|
+
}
|
|
158
|
+
if (normalized !== pattern || pattern === '(?:)') {
|
|
159
|
+
throw new ContractError([`procedure '${where}': the pattern is not one regular-expression literal`])
|
|
160
|
+
}
|
|
161
|
+
return `.regex(/${pattern}/)`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function numberSource(shape: NumberShape): string {
|
|
165
|
+
const min = shape.min === null ? '' : `.${shape.exclusiveMin ? 'gt' : 'gte'}(${number(shape.min)})`
|
|
166
|
+
const max = shape.max === null ? '' : `.${shape.exclusiveMax ? 'lt' : 'lte'}(${number(shape.max)})`
|
|
167
|
+
return `${shape.int ? 'z.int()' : 'z.number()'}${min}${max}${bound('multipleOf', shape.multipleOf)}`
|
|
168
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `defineVocabulary`: the authorization vocabulary of an application, the first module of its staged kit. It knows
|
|
2
|
+
// nothing else, so the tables, the kit, the groups and the contract can each be typed by it without a cycle.
|
|
3
|
+
// Browser-safe: plain data.
|
|
4
|
+
|
|
5
|
+
import { type AppMembers, type DimensionDef, type Presentation, presentationOf, type RoleDefs, type Vocabulary, type WildcardMessage } from './define'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The vocabulary as `defineVocabulary` returned it, with its literal names, dimension definitions and the actions of its
|
|
9
|
+
* people (`M`, `never` when it declares none).
|
|
10
|
+
*/
|
|
11
|
+
export interface AppVocabulary<A extends string, D extends string, R extends string, Dims, M extends AppMembers<A> = never>
|
|
12
|
+
extends Vocabulary<A, D, R>
|
|
13
|
+
{
|
|
14
|
+
readonly dimensions: Dims & { readonly [K in D]: DimensionDef<A, D, R> }
|
|
15
|
+
readonly members?: M
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AnyAppVocabulary = AppVocabulary<string, string, string, unknown, AppMembers<string>>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Declares the dimensions, actions and roles, and how IAM names the application (`locale`, `displayName`). `src/vocabulary.ts` exports it; `defineTables`, `defineApp` and the
|
|
22
|
+
* contract take it, so each name is written once.
|
|
23
|
+
*/
|
|
24
|
+
export function defineVocabulary<
|
|
25
|
+
const A extends string,
|
|
26
|
+
const D extends string,
|
|
27
|
+
const R extends string,
|
|
28
|
+
const Dims extends { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> },
|
|
29
|
+
const M extends AppMembers<NoInfer<A>> = never,
|
|
30
|
+
>(
|
|
31
|
+
vocabulary: Presentation & {
|
|
32
|
+
readonly app: string
|
|
33
|
+
readonly dimensions: Dims & { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> }
|
|
34
|
+
readonly actions: { readonly [K in A]: K extends `${string}*${string}` ? WildcardMessage : string }
|
|
35
|
+
readonly roles: RoleDefs<NoInfer<A>, NoInfer<D>, R>
|
|
36
|
+
readonly members?: M
|
|
37
|
+
readonly directory?: 'labels'
|
|
38
|
+
},
|
|
39
|
+
): AppVocabulary<A, D, R, Dims, M> {
|
|
40
|
+
const people = vocabulary.members
|
|
41
|
+
return {
|
|
42
|
+
app: vocabulary.app,
|
|
43
|
+
dimensions: vocabulary.dimensions,
|
|
44
|
+
actions: vocabulary.actions,
|
|
45
|
+
roles: vocabulary.roles,
|
|
46
|
+
...presentationOf(vocabulary),
|
|
47
|
+
...(people === undefined ? {} : { members: people }),
|
|
48
|
+
...(vocabulary.directory === undefined ? {} : { directory: vocabulary.directory }),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The SPA mounts served from their build directories on disk, as the Bun runtime adapter serves them in a deploy; the
|
|
2
|
+
// test kit serves a built directory the same way.
|
|
3
|
+
|
|
4
|
+
import { rawRequestTarget } from '@fabrika/auth-core'
|
|
5
|
+
import { join, resolve, sep } from 'node:path'
|
|
6
|
+
import type { SpaAssets } from './implement/dispatch.js'
|
|
7
|
+
|
|
8
|
+
/** Serves each SPA mount from its build directory under `root`; a path that names no file gets the SPA's `index.html`. */
|
|
9
|
+
export function diskAssets(root: string): SpaAssets {
|
|
10
|
+
return {
|
|
11
|
+
async serve(mount, request) {
|
|
12
|
+
const dir = resolve(root, mount.dir)
|
|
13
|
+
const pathname = rawRequestTarget(request.url).pathname
|
|
14
|
+
const relative = mount.prefix === '/' ? pathname.slice(1) : pathname.slice(mount.prefix.length + 1)
|
|
15
|
+
// The path is canonical, so every segment decodes and none is `.` or `..`.
|
|
16
|
+
const file = join(dir, ...relative.split('/').filter((segment) => segment !== '').map((segment) => decodeURIComponent(segment)))
|
|
17
|
+
const candidate = file.startsWith(`${dir}${sep}`) ? Bun.file(file) : null
|
|
18
|
+
const served = candidate !== null && (await candidate.exists()) ? candidate : Bun.file(join(dir, 'index.html'))
|
|
19
|
+
if (!(await served.exists())) {
|
|
20
|
+
return new Response('Not found', { status: 404 })
|
|
21
|
+
}
|
|
22
|
+
return new Response(served)
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// What both runtime adapters serve: a service contract's dispatcher (`serveService`), or any other `{ fetch }`.
|
|
2
|
+
|
|
3
|
+
/** Runtime-neutral background-work capability available while handling a request. */
|
|
4
|
+
export interface RequestExecutionContext {
|
|
5
|
+
waitUntil(promise: Promise<unknown>): void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FetchService<Env> {
|
|
9
|
+
fetch(request: Request, env: Env, exec: RequestExecutionContext): Promise<Response>
|
|
10
|
+
}
|