@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
@@ -0,0 +1,49 @@
1
+ // `redirect()`: a procedure output that answers a redirect. The handler returns `{ to }`, and the framework answers
2
+ // 303 after a POST and 302 after a GET, to a path of the application's own origin or a URL of an origin the output
3
+ // lists; any other target fails the invocation, so no procedure is an open redirect. The value is the zod schema of
4
+ // what the handler returns, marked with the origins it lists.
5
+
6
+ import { z } from 'zod/v4'
7
+ import type { Schema } from './define'
8
+
9
+ const REDIRECT = Symbol('fabrika.redirect')
10
+
11
+ /** What `redirect()` adds to the `{ to }` schema: the origins a target may name besides the application's own. */
12
+ export interface RedirectMark {
13
+ readonly origins: readonly string[]
14
+ }
15
+
16
+ /** The marks `redirect` made: a property another module set on a schema is no redirect. */
17
+ const marks = new WeakSet<object>()
18
+
19
+ const Target = z.strictObject({ to: z.string().min(1).max(2048) })
20
+
21
+ /** A redirect output: the handler returns `{ to }`, and the caller receives a 303 or a 302 there. */
22
+ export type Redirect = typeof Target & RedirectBrand
23
+
24
+ /** What tells a redirect output from a JSON one in the types. */
25
+ export interface RedirectBrand {
26
+ readonly [REDIRECT]: RedirectMark
27
+ }
28
+
29
+ export interface RedirectOptions {
30
+ /** Exact `https://` origins a target may name, besides the application's own: `['https://shop.example']`. */
31
+ readonly origins?: readonly string[]
32
+ }
33
+
34
+ /** The output of a procedure that answers a redirect. `compileContract` and the checker refuse an origin it may not list. */
35
+ export function redirect(options: RedirectOptions = {}): Redirect {
36
+ const mark: RedirectMark = Object.freeze({ origins: Object.freeze([...(options.origins ?? [])]) })
37
+ marks.add(mark)
38
+ return Object.assign(Target.clone(), { [REDIRECT]: mark })
39
+ }
40
+
41
+ /** The mark of a `redirect()` output, or `null` for any other schema. */
42
+ export function redirectOf(schema: Schema): RedirectMark | null {
43
+ const mark: unknown = Reflect.get(schema, REDIRECT)
44
+ return isMark(mark) ? mark : null
45
+ }
46
+
47
+ function isMark(value: unknown): value is RedirectMark {
48
+ return typeof value === 'object' && value !== null && marks.has(value)
49
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { SchemaShape, StringShape } from '@fabrika/auth-core'
6
6
  import { z } from 'zod/v4'
7
+ import { SIGNED_TOKEN_MAX_LENGTH } from './token'
7
8
 
8
9
  /** The zod schema that admits what `shape` describes; `describeSchema` reads it back into the same shape. */
9
10
  export function schemaFromShape(shape: SchemaShape): z.ZodType {
@@ -55,6 +56,12 @@ export function schemaFromShape(shape: SchemaShape): z.ZodType {
55
56
  return z.nullable(schemaFromShape(shape.inner))
56
57
  case 'union':
57
58
  return z.union(shape.options.map(schemaFromShape))
59
+ case 'token':
60
+ // The text of a signed token; the runtime verifies it and hands the handler its claims.
61
+ return z.string().min(1).max(SIGNED_TOKEN_MAX_LENGTH)
62
+ case 'file':
63
+ // No export carries a file: the build refuses one, so a description that holds one admits nothing.
64
+ return z.never()
58
65
  }
59
66
  }
60
67
 
@@ -4,7 +4,9 @@
4
4
  import type { ArrayShape, NumberShape, SchemaShape, StringShape } from '@fabrika/auth-core'
5
5
  import { z } from 'zod/v4'
6
6
  import { attachmentOf } from './attachment'
7
+ import { fileOf } from './file'
7
8
  import type { Problems } from './problems'
9
+ import { signedTokenOf } from './token'
8
10
 
9
11
  const SUBSET = 'allowed: the supported z subset of packages/check/CLAUDE.md'
10
12
 
@@ -22,6 +24,14 @@ export function describeSchema(schema: z.core.$ZodType, where: string, problems:
22
24
  problems.add(where, "an attachment() is a procedure's whole output, never part of a schema or an input (allowed: output: attachment({ … }))")
23
25
  return null
24
26
  }
27
+ const token = signedTokenOf(schema)
28
+ if (token !== null) {
29
+ return { type: 'token', purpose: token.purpose }
30
+ }
31
+ const file = fileOf(schema)
32
+ if (file !== null) {
33
+ return { type: 'file', kind: file.kind }
34
+ }
25
35
  if (schema instanceof z.core.$ZodString || schema instanceof z.core.$ZodStringFormat) {
26
36
  return describeString(schema, where, problems)
27
37
  }
@@ -123,6 +123,11 @@ function schemaSource(shape: SchemaShape, where: string): string {
123
123
  return `z.${shape.type}(${schemaSource(shape.inner, where)})`
124
124
  case 'union':
125
125
  return `z.union([${shape.options.map((option) => schemaSource(option, where)).join(', ')}])`
126
+ case 'token':
127
+ // The build refuses a signed token in an export, so no description it vendors holds one.
128
+ throw new ContractError([`procedure '${where}': a signed token has no zod source in a connector snapshot`])
129
+ case 'file':
130
+ throw new ContractError([`procedure '${where}': an export carries no file`])
126
131
  }
127
132
  }
128
133
 
@@ -0,0 +1,89 @@
1
+ // Signed tokens: a purpose declared once in `defineApp`'s options (`token(Claims, { maxDays })`), a field of a
2
+ // procedure's input or `http.query` that carries one (`signedToken('<purpose>')`), and `ctx.tokens.sign` of a site that
3
+ // declares `signs`. On the wire a token is a string; the framework verifies it as it parses the input and hands the
4
+ // handler the purpose's typed claims. A token is single-purpose and no credential: it opens no mount.
5
+
6
+ import { z } from 'zod/v4'
7
+ import type { InputSchema, NotLoose, Schema } from './define'
8
+
9
+ const SIGNED_TOKEN = Symbol('fabrika.signedToken')
10
+
11
+ /** The longest token a field admits: a purpose's claims, a key id, an expiry and a signature, URL-safe. */
12
+ export const SIGNED_TOKEN_MAX_LENGTH = 4096
13
+
14
+ /** The most days any purpose may declare: a key stays verifying that long after it stopped signing. */
15
+ export const TOKEN_MAX_DAYS = 3650
16
+
17
+ /** One token purpose: the claims a token of it carries and the longest lifetime a site may sign one for. */
18
+ export interface TokenPurpose<C extends Schema = Schema> {
19
+ readonly '~kind': 'token'
20
+ readonly claims: C
21
+ readonly maxDays: number
22
+ }
23
+
24
+ export type TokenPurposes = { readonly [purpose: string]: TokenPurpose }
25
+
26
+ export interface TokenOptions {
27
+ /** The longest lifetime, in days, of a token of this purpose. */
28
+ readonly maxDays: number
29
+ }
30
+
31
+ /**
32
+ * A token purpose: `token(Recipient, { maxDays: 400 })` under `defineApp(vocabulary, tables, { tokens })`. `Claims` is a
33
+ * strict object; `compileContract` and the checker refuse what a token may not carry.
34
+ */
35
+ export function token<C extends InputSchema>(claims: C & NotLoose<C>, options: TokenOptions): TokenPurpose<C> {
36
+ return Object.freeze({ '~kind': 'token', claims, maxDays: options.maxDays })
37
+ }
38
+
39
+ /** What `signedToken()` adds to its schema: the purpose a token of the field must be signed for. */
40
+ export interface SignedTokenMark {
41
+ readonly purpose: string
42
+ }
43
+
44
+ type TokenBrand<P extends string> = `fabrika.token:${P}`
45
+
46
+ /** A field that carries a signed token of purpose `P`: a string on the wire, the purpose's claims in the handler. */
47
+ export type SignedTokenSchema<P extends string> =
48
+ & (PropertyKey extends TokenBrand<P> ? z.ZodString : z.core.$ZodBranded<z.ZodString, TokenBrand<P>>)
49
+ & { readonly [SIGNED_TOKEN]: SignedTokenMark }
50
+
51
+ /** The marks `signedToken` made: a property another module set on a schema is no token field. */
52
+ const marks = new WeakSet<object>()
53
+
54
+ /**
55
+ * A field of a procedure's input or `http.query` that carries a token of `purpose`, verified as the input is parsed:
56
+ * `z.strictObject({ t: signedToken('unsubscribe') })`. Only a top-level field; the handler receives the claims.
57
+ */
58
+ export function signedToken<const P extends string>(purpose: P): SignedTokenSchema<P> {
59
+ const mark: SignedTokenMark = Object.freeze({ purpose })
60
+ marks.add(mark)
61
+ return Object.assign(z.string().min(1).max(SIGNED_TOKEN_MAX_LENGTH).brand<TokenBrand<P>>(), { [SIGNED_TOKEN]: mark })
62
+ }
63
+
64
+ /** The mark of a `signedToken()` schema, or `null` for any other schema. */
65
+ export function signedTokenOf(schema: Schema): SignedTokenMark | null {
66
+ const mark: unknown = Reflect.get(schema, SIGNED_TOKEN)
67
+ return isMark(mark) ? mark : null
68
+ }
69
+
70
+ function isMark(value: unknown): value is SignedTokenMark {
71
+ return typeof value === 'object' && value !== null && marks.has(value)
72
+ }
73
+
74
+ /** The purpose a field's parsed value names when the field is a signed token; `never` for any other field. */
75
+ export type TokenPurposeOf<V> = V extends { readonly [z.core.$brand]: infer B } ? keyof B extends TokenBrand<infer P> ? P : never : never
76
+
77
+ /** One field as the handler holds it: a signed token as the claims of its purpose, any other field as it is. */
78
+ type ClaimsField<V, T> = [TokenPurposeOf<NonNullable<V>>] extends [never] ? V
79
+ :
80
+ | (TokenPurposeOf<NonNullable<V>> extends keyof T ? T[TokenPurposeOf<NonNullable<V>>] extends TokenPurpose<infer C> ? z.output<C> : never : never)
81
+ | (undefined extends V ? undefined : never)
82
+
83
+ /** An input as its handler receives it: each signed token field replaced by the verified claims of its purpose. */
84
+ export type WithClaims<In, T> = [keyof In] extends [never] ? In : { readonly [F in keyof In]: ClaimsField<In[F], T> }
85
+
86
+ /** `ctx.tokens` of a site that declares `signs`: a token of one of those purposes, valid for `days`. */
87
+ export interface TokenSigning<T extends TokenPurposes, S extends string> {
88
+ sign<const P extends S & keyof T & string>(purpose: P, claims: z.input<T[P]['claims']>, options: { readonly days: number }): string
89
+ }
@@ -0,0 +1,26 @@
1
+ // What a procedure that declares `binds: '<webhook>'` reaches through `ctx.webhooks('<webhook>')`, and what it answers
2
+ // the member who binds: the binding's URL and its secret, shown once, or the provider's secret set afterwards.
3
+
4
+ import { z } from 'zod/v4'
5
+ import type { AppCapability, Ref } from '../implement/capability'
6
+
7
+ /** The output of a procedure that binds: the binding's id, the URL the provider posts to, and the secret, once. */
8
+ export const WebhookSecret = z.strictObject({ id: z.uuid(), url: z.string().min(1), secret: z.string().min(1) })
9
+
10
+ export type WebhookSecret = z.output<typeof WebhookSecret>
11
+
12
+ /**
13
+ * The bindings of one webhook. A binding reaches the value the capability names: a `Ref` of the webhook's dimension, or
14
+ * an `AppCapability` for a webhook bound to `'app'`. Both run in the procedure's transaction.
15
+ */
16
+ export interface WebhookBindings {
17
+ /** A new binding that accepts deliveries for `account`; the framework generates its secret. */
18
+ bind(capability: Ref<string, string> | AppCapability<string>, account: string): Promise<WebhookSecret>
19
+ /**
20
+ * Replaces the secret of the binding `id` of that value with one the provider issued (Stripe, Slack, Twilio): the
21
+ * provider shows it only after the binding's URL is registered. `false` when the value has no binding of that id.
22
+ */
23
+ setSecret(capability: Ref<string, string> | AppCapability<string>, id: string, secret: string): Promise<boolean>
24
+ /** Removes the binding `id` of that value; `false` when it has none of that id. */
25
+ unbind(capability: Ref<string, string> | AppCapability<string>, id: string): Promise<boolean>
26
+ }
@@ -0,0 +1,32 @@
1
+ // A webhook's verifier as data: `buildArtifact` refuses a scheme this parser refuses, and the artifact's parser reads
2
+ // it with the same schema, so a stored artifact never carries a verifier the runtime cannot run.
3
+
4
+ import { WEBHOOK_PRESETS, type WebhookScheme } from '@fabrika/auth-core'
5
+ import { z } from 'zod/v4'
6
+
7
+ /** An HTTP header name (RFC 9110's token). */
8
+ const headerName = z.string().regex(/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/, 'is not a header name')
9
+
10
+ export const hmacSchemeSchema = z.strictObject({
11
+ header: headerName,
12
+ algorithm: z.enum(['sha1', 'sha256', 'sha512']),
13
+ encoding: z.enum(['hex', 'base64']),
14
+ prefix: z.string().min(1).max(32).optional(),
15
+ timestamp: headerName.optional(),
16
+ signed: z.enum(['body', 'timestamp.body']),
17
+ id: headerName.optional(),
18
+ }).refine(
19
+ // An unsigned timestamp proves nothing, and a signed one needs the header it is read from.
20
+ (scheme) => (scheme.signed === 'timestamp.body') === (scheme.timestamp !== undefined),
21
+ "signed: 'timestamp.body' names its timestamp header, and only it does",
22
+ )
23
+
24
+ export const webhookSchemeSchema: z.ZodType<WebhookScheme> = z.union([
25
+ z.enum(WEBHOOK_PRESETS),
26
+ z.strictObject({ hmac: hmacSchemeSchema }),
27
+ z.strictObject({ basic: z.literal(true) }),
28
+ z.strictObject({ bearer: z.literal(true) }),
29
+ ])
30
+
31
+ export const WEBHOOK_SCHEME_ALLOWED =
32
+ "scheme: 'github-sha256' | 'slack' | 'standard-webhooks' | 'stripe' | 'twilio' | { hmac: { header, algorithm: 'sha1' | 'sha256' | 'sha512', encoding: 'hex' | 'base64', prefix?, timestamp?, signed: 'body' | 'timestamp.body', id? } } | { basic: true } | { bearer: true }"
@@ -3,6 +3,7 @@
3
3
  // collection's actions, so a file reaches what its owner reaches, as through the generated endpoints.
4
4
 
5
5
  import type { Unavailable } from '@fabrika/db/tables'
6
+ import type { FileRef } from '../contract/file'
6
7
  import type { Ref } from '../implement/capability'
7
8
 
8
9
  /** A held file's state: served when `live`, `409` while `quarantined`, gone when `rejected`. */
@@ -94,3 +95,24 @@ export type FileCollectionReadHandle<Why extends string> = FileCollectionReader
94
95
 
95
96
  /** `ctx.files(<collection>)`: the handle of one collection of the contract, by its name in `files`. */
96
97
  export type FileCollections<H> = (collection: string) => H
98
+
99
+ /** What `ctx.files.put` writes: bytes in memory, a `Blob`, or a stream a large file comes from. */
100
+ export type StoredFileBody = Uint8Array | ReadableStream<Uint8Array> | Blob
101
+
102
+ /**
103
+ * `ctx.files.put` and `ctx.files.open`: the stored files of a site (`c.files` kinds). A put stages a file this request
104
+ * holds; writing its reference into a file column is what keeps it. `open` reads a live file whose reference this
105
+ * request holds and may read: an input file, a put, or a file column read under its table's read action.
106
+ */
107
+ export interface StoredFilesHandle {
108
+ /** A file of `kind` with these bytes, which must be of `type`; it is pending until a column references it. */
109
+ put(kind: string, body: StoredFileBody, options: { readonly type: string; readonly name?: string }): Promise<FileRef>
110
+ /** The bytes of a live file, read whole, at most `maxBytes` (the kind's by default); 409 until it is live. */
111
+ open(file: FileRef, options?: { readonly maxBytes?: number }): Promise<OpenedFile>
112
+ }
113
+
114
+ /** A site that only reads (a query): it opens a file and creates none. */
115
+ export interface StoredFilesReadHandle {
116
+ readonly put: Unavailable<'a query creates no file; put it in a mutation, a job or a webhook'>
117
+ open(file: FileRef, options?: { readonly maxBytes?: number }): Promise<OpenedFile>
118
+ }