@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
@@ -70,3 +70,26 @@ export async function readBoundedBytes(stream: ReadableStream<Uint8Array> | null
70
70
  signal?.removeEventListener('abort', cancel)
71
71
  }
72
72
  }
73
+
74
+ /** `body` passed on as it streams, failed once more than `maxBytes` went through; `exceeded` tells that failure apart. */
75
+ export function boundedStream(body: ReadableStream<Uint8Array>, maxBytes: number): {
76
+ readonly stream: ReadableStream<Uint8Array>
77
+ exceeded(): boolean
78
+ } {
79
+ let size = 0
80
+ let over = false
81
+ const stream = body.pipeThrough(
82
+ new TransformStream<Uint8Array, Uint8Array>({
83
+ transform(chunk, controller) {
84
+ size += chunk.byteLength
85
+ if (size > maxBytes) {
86
+ over = true
87
+ controller.error(new Error(`the body is longer than ${maxBytes} bytes`))
88
+ return
89
+ }
90
+ controller.enqueue(chunk)
91
+ },
92
+ }),
93
+ )
94
+ return { stream, exceeded: () => over }
95
+ }
@@ -11,7 +11,7 @@ export interface BrowserConfig {
11
11
  /** `FABRIKA_ENVIRONMENT`, which the page's error reports carry, or `null` when unset. */
12
12
  readonly environment: string | null
13
13
  /**
14
- * Every form challenge's site key, and the variables the mount lists in `public`, by variable name. Absent when
14
+ * Every public procedure challenge's site key, and the variables the mount lists in `public`, by variable name. Absent when
15
15
  * there is none, so the file of an application without them holds the three Operations values only.
16
16
  */
17
17
  readonly public?: Readonly<Record<string, string>>
@@ -33,11 +33,11 @@ export function browserConfigs(
33
33
  return typeof value === 'string' && value.trim() !== '' ? value : null
34
34
  }
35
35
  const siteKeys: Record<string, string> = {}
36
- for (const [form, mount] of Object.entries(artifact.mounts)) {
37
- const challenge = mount.kind === 'form' ? mount.challenge : undefined
36
+ for (const [id, procedure] of Object.entries(artifact.procedures ?? {})) {
37
+ const challenge = procedure.challenge
38
38
  if (challenge?.siteKey === undefined) continue
39
39
  const value = valueOf(challenge.siteKey)
40
- if (value === null) problems.push(`form '${form}' names its ${challenge.provider} site key in ${challenge.siteKey}, which is not set`)
40
+ if (value === null) problems.push(`procedure '${id}' names its ${challenge.provider} site key in ${challenge.siteKey}, which is not set`)
41
41
  else siteKeys[challenge.siteKey] = value
42
42
  }
43
43
  const configs = new Map<string, BrowserConfig>()
@@ -15,6 +15,8 @@ import {
15
15
  MAX_WITHIN_VALUES,
16
16
  type Ref as RefCapability,
17
17
  type RefGrant,
18
+ type RowGrant,
19
+ type RowRef as RowRefCapability,
18
20
  type Within as WithinCapability,
19
21
  type WithinGrant,
20
22
  } from '@fabrika/db/capability'
@@ -28,8 +30,13 @@ export const CAPABILITY_REFUSALS = {
28
30
  wrong_target: "an IAM request was not passed this request's ctx.target",
29
31
  undeclared_action: "the job's declaration or its token does not carry the action",
30
32
  undeclared_dimension: 'the job named a dimension the contract does not declare',
33
+ undeclared_kick: 'the site kicked a job it does not declare in kicks',
34
+ undeclared_binding: 'the site named a webhook it does not declare in binds',
35
+ undeclared_signs: 'the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows',
36
+ undeclared_egress: 'the site called an origin it does not declare in egress',
37
+ wrong_binding: 'a webhook binding was handed a capability of another kind or dimension than the webhook is bound to',
31
38
  missing_id: 'the job named an object without an id',
32
- wrong_capability: 'a file collection was handed a capability of another dimension or action than the operation needs',
39
+ wrong_capability: 'an operation was handed a capability of another dimension or action than it needs',
33
40
  } as const
34
41
 
35
42
  export type CapabilityRefusal = keyof typeof CAPABILITY_REFUSALS
@@ -173,6 +180,7 @@ interface Bound {
173
180
  let readRef: (value: unknown) => Bound | undefined = () => undefined
174
181
  let readWithin: (value: unknown) => Bound | undefined = () => undefined
175
182
  let readApp: (value: unknown) => Bound | undefined = () => undefined
183
+ let readRow: (value: unknown) => Bound | undefined = () => undefined
176
184
 
177
185
  /** One authorized object for one action, in one request. `chain` holds the containers the object lies in. */
178
186
  export class Ref<D extends string, A extends string> implements RefCapability<D, A> {
@@ -344,10 +352,70 @@ export class AppCapability<A extends string> implements AppCapabilityType<A> {
344
352
  }
345
353
  }
346
354
 
355
+ interface RowFacts {
356
+ readonly table: string
357
+ readonly action: string
358
+ readonly principal: string
359
+ readonly id: string
360
+ readonly chain: Chain
361
+ }
362
+
363
+ /**
364
+ * One row a claim leased, for one action, in one job attempt: `row.ref`, which `tx.db.on(row.ref)` finishes the row
365
+ * through. `chain` holds the row's containment columns as it was claimed.
366
+ */
367
+ export class RowRef<T extends string, A extends string> implements RowRefCapability<T, A> {
368
+ declare readonly '~capability': { readonly kind: 'row'; readonly table: T; readonly action: A }
369
+ readonly #facts: RowFacts
370
+ readonly #frame: RequestFrame
371
+ readonly #issuer: object
372
+
373
+ constructor(token: typeof MINT, issuer: object, frame: RequestFrame, grant: Omit<RowGrant, 'kind'>) {
374
+ if (token !== MINT) {
375
+ throw new CapabilityRefusedError('not_minted', 'a row capability is minted by the framework only: claim the row')
376
+ }
377
+ this.#facts = freeze({ table: grant.table, action: grant.action, principal: grant.principal, id: grant.id, chain: chainOf(grant.chain) })
378
+ this.#frame = frame
379
+ this.#issuer = issuer
380
+ }
381
+
382
+ get table(): string {
383
+ return this.#facts.table
384
+ }
385
+
386
+ get action(): string {
387
+ return this.#facts.action
388
+ }
389
+
390
+ /** The row's key. */
391
+ get value(): string {
392
+ return this.#facts.id
393
+ }
394
+
395
+ static {
396
+ readRow = (value) => {
397
+ if (typeof value !== 'object' || value === null || !(#facts in value)) {
398
+ return undefined
399
+ }
400
+ const facts = value.#facts
401
+ const grant: RowGrant = freeze({
402
+ kind: 'row',
403
+ table: facts.table,
404
+ action: facts.action,
405
+ principal: facts.principal,
406
+ id: facts.id,
407
+ chain: mapOf(facts.chain),
408
+ })
409
+ return { grant, frame: value.#frame, issuer: value.#issuer }
410
+ }
411
+ }
412
+ }
413
+
347
414
  // Application code could otherwise redefine a getter that the framework or a handler reads.
348
415
  freeze(Ref.prototype)
349
416
  freeze(Within.prototype)
350
417
  freeze(AppCapability.prototype)
418
+ freeze(RowRef.prototype)
351
419
 
352
420
  /** Makes the capabilities one authority accepts; a runtime's is held by the `boot` call that made it alone. */
353
421
  export interface CapabilityMint {
@@ -410,7 +478,7 @@ export function createCapabilityIssuer(): CapabilityIssuer {
410
478
  })
411
479
  const authority: CapabilityAuthority = freeze({
412
480
  resolve(capability: unknown): CapabilityGrant {
413
- const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability)
481
+ const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability) ?? readRow(capability)
414
482
  if (bound === undefined || bound.issuer !== issuer) {
415
483
  throw new CapabilityRefusedError('not_minted', 'not a capability the framework minted: pass the ctx.target or ctx.within of this request')
416
484
  }
@@ -431,6 +499,14 @@ export function createCapabilityIssuer(): CapabilityIssuer {
431
499
  return grant
432
500
  },
433
501
 
502
+ row(grant: Omit<RowGrant, 'kind'>): RowRef<string, string> {
503
+ const frame = currentRequest.getStore()
504
+ if (frame === undefined || frame.ended) {
505
+ throw new CapabilityRefusedError('ended', 'a claimed row is minted only inside the job attempt that claims it')
506
+ }
507
+ return new RowRef(MINT, issuer, frame, grant)
508
+ },
509
+
434
510
  fence(): LeaseFence | null {
435
511
  return currentRequest.getStore()?.fence ?? null
436
512
  },
@@ -1,13 +1,9 @@
1
- // A form's admission: which bucket a request takes from, and what a submission carries beside its row. The buckets
2
- // live in the application's database (`@fabrika/db`'s `FormAdmissions`), so every instance shares them. Admission is
3
- // not authorization: a form is anonymous, and this only bounds how much one client and all clients together submit.
1
+ // The client a per-client bucket counts. The buckets live in the application's database (`@fabrika/db`'s
2
+ // `Admissions`), so every instance shares them. Admission is not authorization: it only bounds how much one client
3
+ // calls a public procedure.
4
4
 
5
5
  import { CLIENT_ADDRESS_HEADER } from '@fabrika/auth-core'
6
6
  import { createHash } from 'node:crypto'
7
- import { FORM_CHALLENGE_FIELD } from '../contract/defaults'
8
-
9
- /** The bucket of a form's accepted submissions; a client's bucket is `client:<digest>`, so the two never meet. */
10
- export const ACCEPTED_BUCKET = 'accepted'
11
7
 
12
8
  /** Longest header value read; an IPv6 address with a zone id fits with room to spare. */
13
9
  const MAX_ADDRESS_LENGTH = 64
@@ -87,34 +83,3 @@ export function clientKey(header: string | null): string {
87
83
  export function clientBucket(request: Request): string {
88
84
  return `client:${createHash('sha256').update(clientKey(request.headers.get(CLIENT_ADDRESS_HEADER))).digest('hex')}`
89
85
  }
90
-
91
- /** What the form declared beside its row: its honeypot field and whether a challenge token travels in the input. */
92
- export interface SubmissionFields {
93
- readonly honeypot: string | null
94
- readonly challenge: boolean
95
- }
96
-
97
- export interface Submission {
98
- /** The honeypot was filled: the answer is the constant one, and nothing is written. */
99
- readonly trapped: boolean
100
- /** The challenge token, when the form declares a challenge. */
101
- readonly token: string | null
102
- /** The columns the inbox row gets: the input without the honeypot and the token. */
103
- readonly row: Readonly<Record<string, unknown>>
104
- }
105
-
106
- export function submissionOf(input: Readonly<Record<string, unknown>>, fields: SubmissionFields): Submission {
107
- const row: Record<string, unknown> = {}
108
- for (const [name, value] of Object.entries(input)) {
109
- if (name !== fields.honeypot && !(fields.challenge && name === FORM_CHALLENGE_FIELD)) {
110
- row[name] = value
111
- }
112
- }
113
- const trap = fields.honeypot === null ? undefined : input[fields.honeypot]
114
- const token = fields.challenge ? input[FORM_CHALLENGE_FIELD] : undefined
115
- return {
116
- trapped: typeof trap === 'string' && trap !== '',
117
- token: typeof token === 'string' ? token : null,
118
- row,
119
- }
120
- }