@fabrika/app 0.5.5 → 0.7.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/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
|
@@ -6,13 +6,17 @@ import {
|
|
|
6
6
|
type ArrayShape,
|
|
7
7
|
type AttachmentShape,
|
|
8
8
|
BUILTIN_ROLE_KEYS,
|
|
9
|
+
CONTRACT_FORMAT,
|
|
9
10
|
type ContractArtifact,
|
|
10
11
|
CROSS_ORIGIN_KINDS,
|
|
11
12
|
displayNameProblem,
|
|
12
13
|
type EndpointRow,
|
|
14
|
+
FILE_RESPONSE_KINDS,
|
|
15
|
+
isOwnWorkloadName,
|
|
13
16
|
JOB_MAILERS,
|
|
14
17
|
LOCALES,
|
|
15
18
|
mailTemplateProblems,
|
|
19
|
+
MAX_CACHE_AGE_SECONDS,
|
|
16
20
|
type MessageKind,
|
|
17
21
|
type NumberShape,
|
|
18
22
|
type OutputShape,
|
|
@@ -22,8 +26,11 @@ import {
|
|
|
22
26
|
type TablePolicyArtifact,
|
|
23
27
|
type TupleShape,
|
|
24
28
|
} from '@fabrika/auth-core'
|
|
29
|
+
import { isPolicyOrigin } from '@fabrika/proxy-core/headers'
|
|
25
30
|
import { z } from 'zod/v4'
|
|
26
|
-
import {
|
|
31
|
+
import { CHALLENGE_ACTION, JOB_LIMITS, PROCEDURE_LIMITS } from './defaults'
|
|
32
|
+
import { TOKEN_MAX_DAYS } from './token'
|
|
33
|
+
import { webhookSchemeSchema } from './webhook-scheme'
|
|
27
34
|
|
|
28
35
|
const name = z.string().min(1)
|
|
29
36
|
const nullableName = name.nullable()
|
|
@@ -31,7 +38,6 @@ const names = z.array(name)
|
|
|
31
38
|
const record = <T extends z.core.$ZodType>(value: T) => z.record(z.string().min(1), value)
|
|
32
39
|
/** A contract's actions are exact: IAM treats `*` as a pattern, which would widen every grant naming it. */
|
|
33
40
|
const action = name.refine((value) => !value.includes('*'), 'an action is exact; * is not an action')
|
|
34
|
-
const nullableAction = action.nullable()
|
|
35
41
|
const actions = z.array(action)
|
|
36
42
|
/** IAM's own role keys win over an application role of the same key at resolution. */
|
|
37
43
|
const roleKey = name.refine((key) => !BUILTIN_ROLE_KEYS.includes(key), 'a built-in IAM role key is not a contract role name')
|
|
@@ -72,6 +78,8 @@ export const schemaShapeSchema: z.ZodType<SchemaShape> = z.lazy(() =>
|
|
|
72
78
|
z.strictObject({ type: z.literal('optional'), inner: schemaShapeSchema }),
|
|
73
79
|
z.strictObject({ type: z.literal('nullable'), inner: schemaShapeSchema }),
|
|
74
80
|
z.strictObject({ type: z.literal('union'), options: z.array(schemaShapeSchema) }),
|
|
81
|
+
z.strictObject({ type: z.literal('token'), purpose: name }),
|
|
82
|
+
z.strictObject({ type: z.literal('file'), kind: z.string().min(1) }),
|
|
75
83
|
])
|
|
76
84
|
)
|
|
77
85
|
|
|
@@ -102,7 +110,9 @@ const attachmentShape: z.ZodType<AttachmentShape> = z.strictObject({
|
|
|
102
110
|
})
|
|
103
111
|
|
|
104
112
|
/** What a procedure answers: a schema shape, or an attachment's file. */
|
|
105
|
-
|
|
113
|
+
const redirectShape = z.strictObject({ type: z.literal('redirect'), origins: z.array(z.string().startsWith('https://')) })
|
|
114
|
+
|
|
115
|
+
export const outputShapeSchema: z.ZodType<OutputShape> = z.union([schemaShapeSchema, attachmentShape, redirectShape])
|
|
106
116
|
|
|
107
117
|
const caller = z.union([z.enum(['human', 'machine', 'link']), z.templateLiteral(['app:', z.string().min(1)])])
|
|
108
118
|
const rpcCaller = z.enum(['human', 'machine'])
|
|
@@ -115,9 +125,27 @@ const accessForm = z.discriminatedUnion('form', [
|
|
|
115
125
|
z.strictObject({ form: z.literal('app') }),
|
|
116
126
|
])
|
|
117
127
|
|
|
128
|
+
const capabilityForm = z.discriminatedUnion('form', [
|
|
129
|
+
z.strictObject({ form: z.literal('over'), dimension: name }),
|
|
130
|
+
z.strictObject({ form: z.literal('in'), dimension: name, from: name }),
|
|
131
|
+
z.strictObject({ form: z.literal('on'), dimension: name, from: name }),
|
|
132
|
+
])
|
|
133
|
+
|
|
118
134
|
const requirement = z.discriminatedUnion('kind', [
|
|
119
135
|
z.strictObject({ kind: z.literal('member') }),
|
|
120
136
|
z.strictObject({ kind: z.literal('action'), action, access: accessForm }),
|
|
137
|
+
z.strictObject({
|
|
138
|
+
kind: z.literal('capabilities'),
|
|
139
|
+
capabilities: record(z.strictObject({ action, access: capabilityForm })).refine(
|
|
140
|
+
(capabilities) => Object.keys(capabilities).length >= 2,
|
|
141
|
+
'several capabilities are at least two',
|
|
142
|
+
),
|
|
143
|
+
}),
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
const endpointCacheSchema = z.union([
|
|
147
|
+
z.literal('immutable'),
|
|
148
|
+
z.strictObject({ maxAgeSeconds: z.int().min(1).max(MAX_CACHE_AGE_SECONDS) }),
|
|
121
149
|
])
|
|
122
150
|
|
|
123
151
|
const rowBase = {
|
|
@@ -128,6 +156,7 @@ const rowBase = {
|
|
|
128
156
|
admission: z.strictObject({ contentTypes: z.tuple([name], name), maxBytes: z.int().positive().nullable() }).nullable(),
|
|
129
157
|
input: schemaShapeSchema.nullable(),
|
|
130
158
|
output: outputShapeSchema.nullable(),
|
|
159
|
+
response: z.literal('file').optional(),
|
|
131
160
|
}
|
|
132
161
|
|
|
133
162
|
const endpointRow: z.ZodType<EndpointRow> = z.union([
|
|
@@ -144,7 +173,19 @@ const endpointRow: z.ZodType<EndpointRow> = z.union([
|
|
|
144
173
|
callers: z.tuple([]),
|
|
145
174
|
requirement: z.null(),
|
|
146
175
|
crossOrigin: z.enum(['environment', 'any']).optional(),
|
|
147
|
-
|
|
176
|
+
authorization: z.literal('forward').optional(),
|
|
177
|
+
}).refine((row) => row.authorization === undefined || row.kind === 'webhook', "only a webhook forwards the request's Authorization"),
|
|
178
|
+
// A public procedure is a mount's procedure anyone calls: it admits a body and answers its output.
|
|
179
|
+
z.strictObject({
|
|
180
|
+
...rowBase,
|
|
181
|
+
kind: z.literal('public'),
|
|
182
|
+
source: z.strictObject({ kind: z.literal('mount'), name }),
|
|
183
|
+
output: outputShapeSchema,
|
|
184
|
+
callers: z.tuple([]),
|
|
185
|
+
requirement: z.null(),
|
|
186
|
+
crossOrigin: z.enum(['environment', 'any']).optional(),
|
|
187
|
+
cache: endpointCacheSchema.optional(),
|
|
188
|
+
}).refine((row) => row.cache === undefined || row.method === 'GET', 'only a GET answer is cached'),
|
|
148
189
|
// A public SPA is a fixed static row: GET only, no body, no schemas.
|
|
149
190
|
z.strictObject({
|
|
150
191
|
...rowBase,
|
|
@@ -157,7 +198,11 @@ const endpointRow: z.ZodType<EndpointRow> = z.union([
|
|
|
157
198
|
requirement: z.null(),
|
|
158
199
|
}),
|
|
159
200
|
]).refine(
|
|
160
|
-
// Only
|
|
201
|
+
// Only a procedure answers a stored file, and exactly when its output is one.
|
|
202
|
+
(row) => (row.response === 'file') === (row.output?.type === 'file') && (row.response === undefined || FILE_RESPONSE_KINDS.includes(row.kind)),
|
|
203
|
+
'only a procedure whose output is a file answers a stored file',
|
|
204
|
+
).refine(
|
|
205
|
+
// Only an RPC procedure, a public procedure and a form open to another origin, and only the anonymous two to any (ADR-0022).
|
|
161
206
|
(row) =>
|
|
162
207
|
!('crossOrigin' in row) || row.crossOrigin === undefined
|
|
163
208
|
|| (CROSS_ORIGIN_KINDS.includes(row.kind) && (row.crossOrigin !== 'any' || ANY_ORIGIN_KINDS.includes(row.kind))),
|
|
@@ -169,11 +214,15 @@ const scopeBinding = z.discriminatedUnion('kind', [
|
|
|
169
214
|
z.strictObject({ kind: z.literal('dimension'), dimension: name }),
|
|
170
215
|
])
|
|
171
216
|
|
|
172
|
-
|
|
217
|
+
/** A form's rate, per minute, as the releases that declared forms (up to 0.5) bounded it. */
|
|
218
|
+
const formRate = z.int().positive().max(10_000)
|
|
173
219
|
const applicationVariable = z.string().regex(/^[A-Z][A-Z0-9_]*$/)
|
|
220
|
+
const bucket = z.int().positive().max(PROCEDURE_LIMITS.maxPerMinute).nullable()
|
|
221
|
+
const challenge = z.strictObject({ provider: z.literal('turnstile'), secret: applicationVariable, siteKey: applicationVariable.optional() })
|
|
222
|
+
const procedureChallenge = challenge.extend({ action: z.string().regex(CHALLENGE_ACTION) })
|
|
174
223
|
|
|
175
224
|
const mount = z.discriminatedUnion('kind', [
|
|
176
|
-
z.strictObject({ kind: z.literal('rpc'), prefix: name, callers: z.
|
|
225
|
+
z.strictObject({ kind: z.literal('rpc'), prefix: name, callers: z.array(rpcCaller), compatibility: z.literal('backward').optional() }),
|
|
177
226
|
z.strictObject({
|
|
178
227
|
kind: z.literal('link'),
|
|
179
228
|
prefix: name,
|
|
@@ -191,15 +240,28 @@ const mount = z.discriminatedUnion('kind', [
|
|
|
191
240
|
appendTo: name,
|
|
192
241
|
rate: z.strictObject({ perClient: formRate.optional(), perForm: formRate.optional(), bytesPerHour: z.int().positive().optional() }).optional(),
|
|
193
242
|
honeypot: name.optional(),
|
|
194
|
-
challenge:
|
|
243
|
+
challenge: challenge.optional(),
|
|
195
244
|
then: name.optional(),
|
|
196
245
|
files: z.record(name, z.strictObject({ collection: name, required: z.boolean() })).optional(),
|
|
197
246
|
}),
|
|
198
|
-
z.strictObject({
|
|
247
|
+
z.strictObject({
|
|
248
|
+
kind: z.literal('webhook'),
|
|
249
|
+
path: name,
|
|
250
|
+
scheme: webhookSchemeSchema,
|
|
251
|
+
boundTo: scopeBinding,
|
|
252
|
+
actions,
|
|
253
|
+
timeoutMs: z.int().positive().max(PROCEDURE_LIMITS.maxTimeoutMs),
|
|
254
|
+
}),
|
|
199
255
|
])
|
|
200
256
|
|
|
201
257
|
const cspSource = z.strictObject({ origin: name, purpose: name })
|
|
202
258
|
|
|
259
|
+
/** One action as its text (every release's form), or several distinct ones as a list of at least two. */
|
|
260
|
+
const tableAction = z.union([
|
|
261
|
+
action,
|
|
262
|
+
z.array(action).min(2).refine((list) => new Set(list).size === list.length, 'a table operation names each action once'),
|
|
263
|
+
])
|
|
264
|
+
|
|
203
265
|
const tablePolicy: z.ZodType<TablePolicyArtifact> = z.strictObject({
|
|
204
266
|
scope: z.discriminatedUnion('mode', [
|
|
205
267
|
z.strictObject({
|
|
@@ -214,10 +276,10 @@ const tablePolicy: z.ZodType<TablePolicyArtifact> = z.strictObject({
|
|
|
214
276
|
z.strictObject({ mode: z.literal('app'), reason: name, key: name }),
|
|
215
277
|
]),
|
|
216
278
|
actions: z.strictObject({
|
|
217
|
-
read:
|
|
218
|
-
insert:
|
|
219
|
-
update:
|
|
220
|
-
delete:
|
|
279
|
+
read: tableAction,
|
|
280
|
+
insert: tableAction.nullable(),
|
|
281
|
+
update: tableAction.nullable(),
|
|
282
|
+
delete: tableAction.nullable(),
|
|
221
283
|
appRead: z.array(action).nonempty().optional(),
|
|
222
284
|
appCount: z.array(action).nonempty().optional(),
|
|
223
285
|
}),
|
|
@@ -226,9 +288,11 @@ const tablePolicy: z.ZodType<TablePolicyArtifact> = z.strictObject({
|
|
|
226
288
|
version: nullableName,
|
|
227
289
|
createdBy: nullableName,
|
|
228
290
|
columns: record(z.strictObject({ read: action.optional(), write: action.optional() })),
|
|
291
|
+
files: z.record(name, name).optional(),
|
|
229
292
|
})
|
|
230
293
|
|
|
231
294
|
export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObject({
|
|
295
|
+
format: z.literal(CONTRACT_FORMAT),
|
|
232
296
|
app: name,
|
|
233
297
|
dimensions: record(z.strictObject({
|
|
234
298
|
in: nullableName,
|
|
@@ -260,11 +324,12 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
|
|
|
260
324
|
link: z.union([z.boolean(), z.array(name).nonempty()]),
|
|
261
325
|
scan: z.literal(true).optional(),
|
|
262
326
|
})),
|
|
327
|
+
fileKinds: z.record(name, z.strictObject({ types: names, maxBytes: z.int().positive(), scan: z.literal(true).optional() })).optional(),
|
|
263
328
|
jobs: record(z.strictObject({
|
|
264
329
|
schedule: name,
|
|
265
330
|
actions,
|
|
266
331
|
scope: scopeBinding,
|
|
267
|
-
|
|
332
|
+
timeoutMs: z.int().positive().max(JOB_LIMITS.maxTimeoutMs),
|
|
268
333
|
retries: z.int().nonnegative(),
|
|
269
334
|
})),
|
|
270
335
|
exports: record(z.strictObject({ version: z.int().positive(), consumers: names, audience: name })),
|
|
@@ -308,7 +373,18 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
|
|
|
308
373
|
issues: nullableName,
|
|
309
374
|
then: name.optional(),
|
|
310
375
|
mailer: z.enum(JOB_MAILERS).optional(),
|
|
311
|
-
|
|
376
|
+
actions: actions.nonempty().optional(),
|
|
377
|
+
kicks: z.array(name).nonempty().optional(),
|
|
378
|
+
binds: name.optional(),
|
|
379
|
+
signs: z.array(name).nonempty().optional(),
|
|
380
|
+
egress: z.array(z.string().refine((origin) => isPolicyOrigin(origin))).nonempty().optional(),
|
|
381
|
+
})).superRefine((declared, context) => {
|
|
382
|
+
for (const [site, reach] of Object.entries(declared)) {
|
|
383
|
+
if (reach.actions !== undefined && !isOwnWorkloadName('public', site)) {
|
|
384
|
+
context.addIssue({ code: 'custom', path: [site, 'actions'], message: 'workload actions belong to a public procedure, keyed by its endpoint id' })
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}).optional(),
|
|
312
388
|
directories: record(z.strictObject({ dimension: name, lists: z.boolean() })).optional(),
|
|
313
389
|
locale: z.enum(LOCALES).optional(),
|
|
314
390
|
displayName: z.string().refine((value) => displayNameProblem(value) === null, 'not a display name').optional(),
|
|
@@ -316,6 +392,25 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
|
|
|
316
392
|
.optional(),
|
|
317
393
|
members: z.strictObject({ manage: action, list: action }).optional(),
|
|
318
394
|
directory: z.literal('labels').optional(),
|
|
395
|
+
procedures: record(z.strictObject({
|
|
396
|
+
rate: z.strictObject({
|
|
397
|
+
perClient: bucket,
|
|
398
|
+
perCaller: bucket,
|
|
399
|
+
perEndpoint: bucket,
|
|
400
|
+
bytesPerHour: z.int().positive().max(PROCEDURE_LIMITS.maxBytesPerHour).nullable(),
|
|
401
|
+
}),
|
|
402
|
+
challenge: procedureChallenge.nullable(),
|
|
403
|
+
timeoutMs: z.int().positive().max(PROCEDURE_LIMITS.maxTimeoutMs),
|
|
404
|
+
query: schemaShapeSchema.optional(),
|
|
405
|
+
changesStateOnGet: z.literal(true).optional(),
|
|
406
|
+
})).optional(),
|
|
407
|
+
tokens: record(z.strictObject({ claims: schemaShapeSchema, maxDays: z.int().positive().max(TOKEN_MAX_DAYS) })).optional(),
|
|
408
|
+
}).superRefine((artifact, context) => {
|
|
409
|
+
for (const row of artifact.endpoints) {
|
|
410
|
+
if (row.kind === 'public' && artifact.declared?.[row.id]?.actions === undefined) {
|
|
411
|
+
context.addIssue({ code: 'custom', path: ['declared', row.id], message: 'a public procedure declares its workload actions' })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
319
414
|
})
|
|
320
415
|
|
|
321
416
|
function mailLocales(kind: MessageKind) {
|