@fabrika/app 0.6.0 → 0.8.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 (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. 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,20 @@ import {
22
26
  type TablePolicyArtifact,
23
27
  type TupleShape,
24
28
  } from '@fabrika/auth-core'
29
+ import { isPolicyOrigin, WORKER_BLOB_SOURCE } from '@fabrika/proxy-core/headers'
25
30
  import { z } from 'zod/v4'
26
- import { FORM_LIMITS } from './defaults'
31
+ import {
32
+ ALTCHA_LIMITS,
33
+ altchaSolvable,
34
+ CHALLENGE_ACTION,
35
+ CHALLENGE_PREFIX,
36
+ challengePath,
37
+ challengeRowId,
38
+ JOB_LIMITS,
39
+ PROCEDURE_LIMITS,
40
+ } from './defaults'
41
+ import { TOKEN_MAX_DAYS } from './token'
42
+ import { webhookSchemeSchema } from './webhook-scheme'
27
43
 
28
44
  const name = z.string().min(1)
29
45
  const nullableName = name.nullable()
@@ -31,7 +47,6 @@ const names = z.array(name)
31
47
  const record = <T extends z.core.$ZodType>(value: T) => z.record(z.string().min(1), value)
32
48
  /** A contract's actions are exact: IAM treats `*` as a pattern, which would widen every grant naming it. */
33
49
  const action = name.refine((value) => !value.includes('*'), 'an action is exact; * is not an action')
34
- const nullableAction = action.nullable()
35
50
  const actions = z.array(action)
36
51
  /** IAM's own role keys win over an application role of the same key at resolution. */
37
52
  const roleKey = name.refine((key) => !BUILTIN_ROLE_KEYS.includes(key), 'a built-in IAM role key is not a contract role name')
@@ -72,6 +87,8 @@ export const schemaShapeSchema: z.ZodType<SchemaShape> = z.lazy(() =>
72
87
  z.strictObject({ type: z.literal('optional'), inner: schemaShapeSchema }),
73
88
  z.strictObject({ type: z.literal('nullable'), inner: schemaShapeSchema }),
74
89
  z.strictObject({ type: z.literal('union'), options: z.array(schemaShapeSchema) }),
90
+ z.strictObject({ type: z.literal('token'), purpose: name }),
91
+ z.strictObject({ type: z.literal('file'), kind: z.string().min(1) }),
75
92
  ])
76
93
  )
77
94
 
@@ -102,7 +119,9 @@ const attachmentShape: z.ZodType<AttachmentShape> = z.strictObject({
102
119
  })
103
120
 
104
121
  /** What a procedure answers: a schema shape, or an attachment's file. */
105
- export const outputShapeSchema: z.ZodType<OutputShape> = z.union([schemaShapeSchema, attachmentShape])
122
+ const redirectShape = z.strictObject({ type: z.literal('redirect'), origins: z.array(z.string().startsWith('https://')) })
123
+
124
+ export const outputShapeSchema: z.ZodType<OutputShape> = z.union([schemaShapeSchema, attachmentShape, redirectShape])
106
125
 
107
126
  const caller = z.union([z.enum(['human', 'machine', 'link']), z.templateLiteral(['app:', z.string().min(1)])])
108
127
  const rpcCaller = z.enum(['human', 'machine'])
@@ -115,9 +134,27 @@ const accessForm = z.discriminatedUnion('form', [
115
134
  z.strictObject({ form: z.literal('app') }),
116
135
  ])
117
136
 
137
+ const capabilityForm = z.discriminatedUnion('form', [
138
+ z.strictObject({ form: z.literal('over'), dimension: name }),
139
+ z.strictObject({ form: z.literal('in'), dimension: name, from: name }),
140
+ z.strictObject({ form: z.literal('on'), dimension: name, from: name }),
141
+ ])
142
+
118
143
  const requirement = z.discriminatedUnion('kind', [
119
144
  z.strictObject({ kind: z.literal('member') }),
120
145
  z.strictObject({ kind: z.literal('action'), action, access: accessForm }),
146
+ z.strictObject({
147
+ kind: z.literal('capabilities'),
148
+ capabilities: record(z.strictObject({ action, access: capabilityForm })).refine(
149
+ (capabilities) => Object.keys(capabilities).length >= 2,
150
+ 'several capabilities are at least two',
151
+ ),
152
+ }),
153
+ ])
154
+
155
+ const endpointCacheSchema = z.union([
156
+ z.literal('immutable'),
157
+ z.strictObject({ maxAgeSeconds: z.int().min(1).max(MAX_CACHE_AGE_SECONDS) }),
121
158
  ])
122
159
 
123
160
  const rowBase = {
@@ -128,6 +165,7 @@ const rowBase = {
128
165
  admission: z.strictObject({ contentTypes: z.tuple([name], name), maxBytes: z.int().positive().nullable() }).nullable(),
129
166
  input: schemaShapeSchema.nullable(),
130
167
  output: outputShapeSchema.nullable(),
168
+ response: z.literal('file').optional(),
131
169
  }
132
170
 
133
171
  const endpointRow: z.ZodType<EndpointRow> = z.union([
@@ -144,6 +182,31 @@ const endpointRow: z.ZodType<EndpointRow> = z.union([
144
182
  callers: z.tuple([]),
145
183
  requirement: z.null(),
146
184
  crossOrigin: z.enum(['environment', 'any']).optional(),
185
+ authorization: z.literal('forward').optional(),
186
+ }).refine((row) => row.authorization === undefined || row.kind === 'webhook', "only a webhook forwards the request's Authorization"),
187
+ // A public procedure is a mount's procedure anyone calls: it admits a body and answers its output.
188
+ z.strictObject({
189
+ ...rowBase,
190
+ kind: z.literal('public'),
191
+ source: z.strictObject({ kind: z.literal('mount'), name }),
192
+ output: outputShapeSchema,
193
+ callers: z.tuple([]),
194
+ requirement: z.null(),
195
+ crossOrigin: z.enum(['environment', 'any']).optional(),
196
+ cache: endpointCacheSchema.optional(),
197
+ }).refine((row) => row.cache === undefined || row.method === 'GET', 'only a GET answer is cached'),
198
+ // The framework's own GET that issues the ALTCHA challenge of the public procedure its source names: no body, no schemas.
199
+ z.strictObject({
200
+ ...rowBase,
201
+ kind: z.literal('public'),
202
+ source: z.strictObject({ kind: z.literal('challenge'), name }),
203
+ method: z.literal('GET'),
204
+ admission: z.null(),
205
+ input: z.null(),
206
+ output: z.null(),
207
+ callers: z.tuple([]),
208
+ requirement: z.null(),
209
+ crossOrigin: z.enum(['environment', 'any']).optional(),
147
210
  }),
148
211
  // A public SPA is a fixed static row: GET only, no body, no schemas.
149
212
  z.strictObject({
@@ -157,7 +220,11 @@ const endpointRow: z.ZodType<EndpointRow> = z.union([
157
220
  requirement: z.null(),
158
221
  }),
159
222
  ]).refine(
160
- // Only an RPC procedure and a form open to another origin, and only a form to any (ADR-0022).
223
+ // Only a procedure answers a stored file, and exactly when its output is one.
224
+ (row) => (row.response === 'file') === (row.output?.type === 'file') && (row.response === undefined || FILE_RESPONSE_KINDS.includes(row.kind)),
225
+ 'only a procedure whose output is a file answers a stored file',
226
+ ).refine(
227
+ // Only an RPC procedure, a public procedure and a form open to another origin, and only the anonymous two to any (ADR-0022).
161
228
  (row) =>
162
229
  !('crossOrigin' in row) || row.crossOrigin === undefined
163
230
  || (CROSS_ORIGIN_KINDS.includes(row.kind) && (row.crossOrigin !== 'any' || ANY_ORIGIN_KINDS.includes(row.kind))),
@@ -169,11 +236,25 @@ const scopeBinding = z.discriminatedUnion('kind', [
169
236
  z.strictObject({ kind: z.literal('dimension'), dimension: name }),
170
237
  ])
171
238
 
172
- const formRate = z.int().positive().max(FORM_LIMITS.maxPerMinute)
239
+ /** A form's rate, per minute, as the releases that declared forms (up to 0.5) bounded it. */
240
+ const formRate = z.int().positive().max(10_000)
173
241
  const applicationVariable = z.string().regex(/^[A-Z][A-Z0-9_]*$/)
242
+ const bucket = z.int().positive().max(PROCEDURE_LIMITS.maxPerMinute).nullable()
243
+ const challenge = z.strictObject({ provider: z.literal('turnstile'), secret: applicationVariable, siteKey: applicationVariable.optional() })
244
+ const challengeActionSchema = z.string().regex(CHALLENGE_ACTION)
245
+ const procedureChallenge = z.discriminatedUnion('provider', [
246
+ challenge.extend({ action: challengeActionSchema }),
247
+ z.strictObject({
248
+ provider: z.literal('altcha'),
249
+ secret: applicationVariable,
250
+ action: challengeActionSchema,
251
+ cost: z.int().min(ALTCHA_LIMITS.minCost).max(ALTCHA_LIMITS.maxCost),
252
+ maxAgeMs: z.int().min(ALTCHA_LIMITS.minMaxAgeMs).max(ALTCHA_LIMITS.maxMaxAgeMs),
253
+ }).refine((challenge) => altchaSolvable(challenge.cost, challenge.maxAgeMs), 'a slow phone does not solve this challenge in time'),
254
+ ])
174
255
 
175
256
  const mount = z.discriminatedUnion('kind', [
176
- z.strictObject({ kind: z.literal('rpc'), prefix: name, callers: z.tuple([rpcCaller], rpcCaller) }),
257
+ z.strictObject({ kind: z.literal('rpc'), prefix: name, callers: z.array(rpcCaller), compatibility: z.literal('backward').optional() }),
177
258
  z.strictObject({
178
259
  kind: z.literal('link'),
179
260
  prefix: name,
@@ -191,15 +272,28 @@ const mount = z.discriminatedUnion('kind', [
191
272
  appendTo: name,
192
273
  rate: z.strictObject({ perClient: formRate.optional(), perForm: formRate.optional(), bytesPerHour: z.int().positive().optional() }).optional(),
193
274
  honeypot: name.optional(),
194
- challenge: z.strictObject({ provider: z.literal('turnstile'), secret: applicationVariable, siteKey: applicationVariable.optional() }).optional(),
275
+ challenge: challenge.optional(),
195
276
  then: name.optional(),
196
277
  files: z.record(name, z.strictObject({ collection: name, required: z.boolean() })).optional(),
197
278
  }),
198
- z.strictObject({ kind: z.literal('webhook'), path: name, scheme: z.literal('github-sha256'), boundTo: scopeBinding, actions }),
279
+ z.strictObject({
280
+ kind: z.literal('webhook'),
281
+ path: name,
282
+ scheme: webhookSchemeSchema,
283
+ boundTo: scopeBinding,
284
+ actions,
285
+ timeoutMs: z.int().positive().max(PROCEDURE_LIMITS.maxTimeoutMs),
286
+ }),
199
287
  ])
200
288
 
201
289
  const cspSource = z.strictObject({ origin: name, purpose: name })
202
290
 
291
+ /** One action as its text (every release's form), or several distinct ones as a list of at least two. */
292
+ const tableAction = z.union([
293
+ action,
294
+ z.array(action).min(2).refine((list) => new Set(list).size === list.length, 'a table operation names each action once'),
295
+ ])
296
+
203
297
  const tablePolicy: z.ZodType<TablePolicyArtifact> = z.strictObject({
204
298
  scope: z.discriminatedUnion('mode', [
205
299
  z.strictObject({
@@ -214,10 +308,10 @@ const tablePolicy: z.ZodType<TablePolicyArtifact> = z.strictObject({
214
308
  z.strictObject({ mode: z.literal('app'), reason: name, key: name }),
215
309
  ]),
216
310
  actions: z.strictObject({
217
- read: action,
218
- insert: nullableAction,
219
- update: nullableAction,
220
- delete: nullableAction,
311
+ read: tableAction,
312
+ insert: tableAction.nullable(),
313
+ update: tableAction.nullable(),
314
+ delete: tableAction.nullable(),
221
315
  appRead: z.array(action).nonempty().optional(),
222
316
  appCount: z.array(action).nonempty().optional(),
223
317
  }),
@@ -226,9 +320,11 @@ const tablePolicy: z.ZodType<TablePolicyArtifact> = z.strictObject({
226
320
  version: nullableName,
227
321
  createdBy: nullableName,
228
322
  columns: record(z.strictObject({ read: action.optional(), write: action.optional() })),
323
+ files: z.record(name, name).optional(),
229
324
  })
230
325
 
231
326
  export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObject({
327
+ format: z.literal(CONTRACT_FORMAT),
232
328
  app: name,
233
329
  dimensions: record(z.strictObject({
234
330
  in: nullableName,
@@ -260,11 +356,12 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
260
356
  link: z.union([z.boolean(), z.array(name).nonempty()]),
261
357
  scan: z.literal(true).optional(),
262
358
  })),
359
+ fileKinds: z.record(name, z.strictObject({ types: names, maxBytes: z.int().positive(), scan: z.literal(true).optional() })).optional(),
263
360
  jobs: record(z.strictObject({
264
361
  schedule: name,
265
362
  actions,
266
363
  scope: scopeBinding,
267
- timeout: nullableName,
364
+ timeoutMs: z.int().positive().max(JOB_LIMITS.maxTimeoutMs),
268
365
  retries: z.int().nonnegative(),
269
366
  })),
270
367
  exports: record(z.strictObject({ version: z.int().positive(), consumers: names, audience: name })),
@@ -285,6 +382,7 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
285
382
  frameSrc: z.array(cspSource).nonempty().optional(),
286
383
  styleSrc: z.array(cspSource).nonempty().optional(),
287
384
  fontSrc: z.array(cspSource).nonempty().optional(),
385
+ workerSrc: z.tuple([z.strictObject({ origin: z.literal(WORKER_BLOB_SOURCE), purpose: z.string().min(1) })]).optional(),
288
386
  }),
289
387
  tables: record(tablePolicy),
290
388
  endpoints: z.array(endpointRow).superRefine((rows, context) => {
@@ -308,7 +406,18 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
308
406
  issues: nullableName,
309
407
  then: name.optional(),
310
408
  mailer: z.enum(JOB_MAILERS).optional(),
311
- })).optional(),
409
+ actions: actions.nonempty().optional(),
410
+ kicks: z.array(name).nonempty().optional(),
411
+ binds: name.optional(),
412
+ signs: z.array(name).nonempty().optional(),
413
+ egress: z.array(z.string().refine((origin) => isPolicyOrigin(origin))).nonempty().optional(),
414
+ })).superRefine((declared, context) => {
415
+ for (const [site, reach] of Object.entries(declared)) {
416
+ if (reach.actions !== undefined && !isOwnWorkloadName('public', site)) {
417
+ context.addIssue({ code: 'custom', path: [site, 'actions'], message: 'workload actions belong to a public procedure, keyed by its endpoint id' })
418
+ }
419
+ }
420
+ }).optional(),
312
421
  directories: record(z.strictObject({ dimension: name, lists: z.boolean() })).optional(),
313
422
  locale: z.enum(LOCALES).optional(),
314
423
  displayName: z.string().refine((value) => displayNameProblem(value) === null, 'not a display name').optional(),
@@ -316,6 +425,51 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
316
425
  .optional(),
317
426
  members: z.strictObject({ manage: action, list: action }).optional(),
318
427
  directory: z.literal('labels').optional(),
428
+ procedures: record(z.strictObject({
429
+ rate: z.strictObject({
430
+ perClient: bucket,
431
+ perCaller: bucket,
432
+ perEndpoint: bucket,
433
+ bytesPerHour: z.int().positive().max(PROCEDURE_LIMITS.maxBytesPerHour).nullable(),
434
+ }),
435
+ challenge: procedureChallenge.nullable(),
436
+ timeoutMs: z.int().positive().max(PROCEDURE_LIMITS.maxTimeoutMs),
437
+ query: schemaShapeSchema.optional(),
438
+ changesStateOnGet: z.literal(true).optional(),
439
+ })).optional(),
440
+ tokens: record(z.strictObject({ claims: schemaShapeSchema, maxDays: z.int().positive().max(TOKEN_MAX_DAYS) })).optional(),
441
+ }).superRefine((artifact, context) => {
442
+ for (const row of artifact.endpoints) {
443
+ if (row.kind === 'public' && row.source.kind !== 'challenge' && artifact.declared?.[row.id]?.actions === undefined) {
444
+ context.addIssue({ code: 'custom', path: ['declared', row.id], message: 'a public procedure declares its workload actions' })
445
+ }
446
+ }
447
+ // Every ALTCHA challenge has exactly its own issuer, under its action, from the procedure's origins, with its own buckets.
448
+ const rows = new Map(artifact.endpoints.map((row) => [row.id, row]))
449
+ const issuers = artifact.endpoints.filter((row) => row.source.kind === 'challenge')
450
+ for (const [id, rules] of Object.entries(artifact.procedures ?? {})) {
451
+ if (rules.challenge?.provider !== 'altcha') continue
452
+ const issuer = rows.get(challengeRowId(id))
453
+ const procedure = rows.get(id)
454
+ if (
455
+ issuer === undefined || procedure === undefined || issuer.source.kind !== 'challenge' || issuer.source.name !== id
456
+ || issuer.path !== challengePath(rules.challenge.action) || issuer.crossOrigin !== procedure.crossOrigin
457
+ || artifact.procedures?.[issuer.id]?.challenge !== null
458
+ ) {
459
+ context.addIssue({ code: 'custom', path: ['procedures', id, 'challenge'], message: 'an ALTCHA challenge has its own issuer row' })
460
+ }
461
+ }
462
+ for (const issuer of issuers) {
463
+ if (issuer.id !== challengeRowId(issuer.source.name) || artifact.procedures?.[issuer.source.name]?.challenge?.provider !== 'altcha') {
464
+ context.addIssue({ code: 'custom', path: ['endpoints'], message: `'${issuer.id}' issues the challenge of no ALTCHA procedure` })
465
+ }
466
+ }
467
+ if (issuers.length > 0 && !artifact.reservedPrefixes.includes(CHALLENGE_PREFIX)) {
468
+ context.addIssue({ code: 'custom', path: ['reservedPrefixes'], message: `an issuer row reserves ${CHALLENGE_PREFIX}` })
469
+ }
470
+ if ((artifact.csp.workerSrc !== undefined) !== issuers.length > 0) {
471
+ context.addIssue({ code: 'custom', path: ['csp', 'workerSrc'], message: 'workerSrc admits the workers of an ALTCHA challenge, and only those' })
472
+ }
319
473
  })
320
474
 
321
475
  function mailLocales(kind: MessageKind) {