@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.
Files changed (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -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 +387 -236
  10. package/skill/references/spa.md +5 -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 +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  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 +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. 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 { FORM_LIMITS } from './defaults'
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
- export const outputShapeSchema: z.ZodType<OutputShape> = z.union([schemaShapeSchema, attachmentShape])
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 an RPC procedure and a form open to another origin, and only a form to any (ADR-0022).
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
- const formRate = z.int().positive().max(FORM_LIMITS.maxPerMinute)
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.tuple([rpcCaller], rpcCaller) }),
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: z.strictObject({ provider: z.literal('turnstile'), secret: applicationVariable, siteKey: applicationVariable.optional() }).optional(),
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({ kind: z.literal('webhook'), path: name, scheme: z.literal('github-sha256'), boundTo: scopeBinding, actions }),
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: action,
218
- insert: nullableAction,
219
- update: nullableAction,
220
- delete: nullableAction,
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
- timeout: nullableName,
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
- })).optional(),
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) {