@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
@@ -1,15 +1,14 @@
1
1
  // `defineContract`: the application's whole access surface as data. Browser-safe: it builds plain objects and
2
2
  // holds zod schemas; it validates nothing at runtime, `compileContract` does.
3
3
 
4
- import type { BuiltinRoleKey, ChallengeProvider, Locale, MailTemplates, RpcCaller } from '@fabrika/auth-core'
4
+ import type { BuiltinRoleKey, ChallengeProvider, Locale, MailTemplates, RpcCaller, WebhookScheme } from '@fabrika/auth-core'
5
5
  import type { z } from 'zod/v4'
6
6
  import {
7
7
  type CronDeclaration,
8
8
  type CspDeclaration,
9
9
  type ExportAccessDeclaration,
10
10
  type ExportDeclaration,
11
- type FilesDeclaration,
12
- type FormMountDeclaration,
11
+ type FileKindDeclaration,
13
12
  type HealthMountDeclaration,
14
13
  type ImportDeclaration,
15
14
  type LinkAccessDeclaration,
@@ -23,9 +22,15 @@ import {
23
22
  unknownOptions,
24
23
  type WebhookMountDeclaration,
25
24
  } from './declaration'
25
+ import type { TokenPurposes } from './token'
26
26
 
27
27
  export type Path = `/${string}`
28
28
 
29
+ /** A job attempt's time limit: 15 minutes by default, at most a day. A job reads no body, so it has no `maxBytes`. */
30
+ export interface JobLimitsOption {
31
+ readonly timeoutMs?: number
32
+ }
33
+
29
34
  /** Any zod 4 schema. `compileContract` refuses every form outside the contract subset. */
30
35
  export type Schema = z.core.$ZodType
31
36
 
@@ -179,7 +184,10 @@ export interface AppAccess<A extends string> {
179
184
  export interface LinkAccess<A extends string> {
180
185
  readonly action: A
181
186
  }
182
- /** An SPA anyone opens, with no session; the page reaches only the anonymous endpoints. */
187
+ /**
188
+ * Anyone opens it, with no session: an SPA whose page reaches only the anonymous endpoints, or a procedure whose
189
+ * handler runs as its own workload, holding the `actions` declared beside it.
190
+ */
183
191
  export interface PublicAccess {
184
192
  readonly public: true
185
193
  }
@@ -190,12 +198,27 @@ export type InputOf<I> = [I] extends [InputSchema] ? z.output<I> : Record<never,
190
198
  /** Input fields of string type: the only fields a scope value can be read from. */
191
199
  export type StringField<In> = { [K in keyof In]-?: In[K] extends string ? K : never }[keyof In] & string
192
200
 
201
+ /** One of several capabilities: an action listed over a dimension, or held on or in the value an input field names. */
202
+ export type CapabilityAccess<A extends string, D extends string, In> =
203
+ | OverAccess<A, D>
204
+ | InAccess<A, D, StringField<In>>
205
+ | OnAccess<A, D, StringField<In>>
206
+
207
+ /**
208
+ * Several capabilities, by the name the handler reads each under (`ctx.capabilities.<name>`): an operation that reads
209
+ * one dimension and writes another holds both, each checked and minted as its own access form would be.
210
+ */
211
+ export interface CapabilitiesAccess<C> {
212
+ readonly capabilities: C
213
+ }
214
+
193
215
  export type QueryAccess<A extends string, D extends string, In> =
194
216
  | MemberAccess
195
217
  | OverAccess<A, D>
196
218
  | InAccess<A, D, StringField<In>>
197
219
  | OnAccess<A, D, StringField<In>>
198
220
  | AppAccess<A>
221
+ | CapabilitiesAccess<{ readonly [name: string]: CapabilityAccess<A, D, In> }>
199
222
 
200
223
  export type MutationAccess<A extends string, D extends string, CD extends string, In> = QueryAccess<A, D, In> | CreatesAccess<A, CD>
201
224
 
@@ -220,11 +243,10 @@ export interface ProcedureDef<I, O, Acc> extends Owned<'~kind' | 'type'> {
220
243
  readonly input?: I & NotLoose<I>
221
244
  readonly output: O & NotLoose<O>
222
245
  readonly access: Acc
223
- /** A page on one of the environment's listed origins may call it, with a key (ADR-0022). An RPC procedure only. */
224
- readonly crossOrigin?: true
225
246
  }
226
247
 
227
- export interface RpcMount<C extends readonly [RpcCaller, ...RpcCaller[]], T extends ProcedureTree> extends RpcMountDeclaration<Schema> {
248
+ /** `C` names the callers of its credentialed procedures; `readonly []` when every procedure is public. */
249
+ export interface RpcMount<C extends readonly RpcCaller[], T extends ProcedureTree> extends RpcMountDeclaration<Schema> {
228
250
  readonly callers: C
229
251
  readonly procedures: T
230
252
  }
@@ -242,31 +264,17 @@ export interface SpaMount<Acc extends SpaAccessDeclaration> extends SpaMountDecl
242
264
 
243
265
  export type HealthMount = HealthMountDeclaration
244
266
 
245
- export interface FormMount<I extends Schema, T extends string = string> extends FormMountDeclaration<Schema> {
246
- readonly input: I
247
- readonly then?: T
248
- }
249
-
250
267
  export interface WebhookMount<B extends string, Acts extends string, I extends Schema> extends WebhookMountDeclaration<Schema> {
251
268
  readonly boundTo: B
252
269
  readonly actions: readonly Acts[]
253
270
  readonly input: I
254
271
  }
255
272
 
256
- /** `L` is which link mounts' holders reach the collection: the typed client gives a link mount's holders only those. */
257
- /** `link` of `c.files`: every link mount bound to the owner's dimension, none, or the named ones. */
258
- export type LinkServing = boolean | readonly string[]
259
-
260
- export interface FileCollection<A extends string, L extends LinkServing = LinkServing> extends FilesDeclaration {
261
- readonly read: A
262
- readonly write: A
263
- readonly delete: A
264
- readonly link?: L
265
- }
273
+ /** A file kind `c.files({ types, maxBytes, scan? })` declares: what a file column of that kind holds. */
274
+ export interface FileKind extends FileKindDeclaration {}
266
275
 
267
- export interface CronJob<Acts extends string, Sc extends string> extends CronDeclaration {
276
+ export interface CronJob<Acts extends string> extends CronDeclaration {
268
277
  readonly actions: readonly Acts[]
269
- readonly scope: Sc
270
278
  }
271
279
 
272
280
  export interface Export<T extends QueryTree> extends ExportDeclaration<Schema> {
@@ -274,74 +282,59 @@ export interface Export<T extends QueryTree> extends ExportDeclaration<Schema> {
274
282
  }
275
283
 
276
284
  export type AnyMount =
277
- | RpcMount<readonly [RpcCaller, ...RpcCaller[]], ProcedureTree>
285
+ | RpcMount<readonly RpcCaller[], ProcedureTree>
278
286
  | LinkMount<string, string, string, LinkProcedureTree>
279
287
  | SpaMount<SpaAccessDeclaration>
280
288
  | HealthMount
281
- | FormMount<Schema>
282
289
  | WebhookMount<string, string, Schema>
283
290
 
284
291
  /** What the callback of `defineContract` returns: mounts by name, and the sections `SURFACE_SECTIONS` names. */
285
292
  export interface SurfaceSections {
286
- readonly files?: { readonly [name: string]: FileCollection<string> }
287
- readonly jobs?: { readonly [name: string]: CronJob<string, string> }
293
+ readonly files?: { readonly [name: string]: FileKind }
294
+ readonly jobs?: { readonly [name: string]: CronJob<string> }
288
295
  readonly exports?: { readonly [name: string]: Export<QueryTree> }
289
296
  readonly imports?: { readonly [name: string]: ImportDeclaration }
290
297
  readonly csp?: CspDeclaration
298
+ /** The token purposes; a kit contract takes them from `defineApp`'s options. */
299
+ readonly tokens?: TokenPurposes
291
300
  }
292
301
 
293
302
  export interface Surface extends SurfaceSections {
294
303
  readonly [name: string]: AnyMount | SurfaceSections[keyof SurfaceSections]
295
304
  }
296
305
 
297
- /** What a form's `then` must be instead: the refusal, at the form's own line. */
298
- export type FormJobMessage = "then names a job of the contract's jobs section"
306
+ /** A public procedure's challenge, by provider: Turnstile, verified at Cloudflare, or ALTCHA, which the application issues and verifies. */
307
+ export type ChallengeOption = TurnstileChallengeOption | AltchaChallengeOption
299
308
 
300
- /** Each form's `then` names a job of the same surface's `jobs` section. */
301
- export type FormJobs<S> = {
302
- readonly [K in keyof S]: S[K] extends { readonly kind: 'form'; readonly then?: infer T }
303
- ? [T] extends [string] ? { readonly then?: (S extends { readonly jobs: infer J } ? keyof J & string : never) | FormJobMessage }
304
- : unknown
305
- : unknown
306
- }
307
-
308
- /** A form's challenge: the provider and the application variables that hold its secret and its public site key. */
309
- export interface FormChallengeOption {
309
+ interface ChallengeOptionBase {
310
310
  readonly provider: ChallengeProvider
311
+ /** The application variable that holds the provider's secret (Turnstile's) or the HMAC key (ALTCHA's): a name, never the value. */
311
312
  readonly secret: string
313
+ /**
314
+ * The widget's `action`, which the verification expects back: at most 32 letters, digits, `_` and `-`. The endpoint id
315
+ * with `_` for `.` by default (`api_visitors_register`).
316
+ */
317
+ readonly action?: string
318
+ }
319
+
320
+ export interface TurnstileChallengeOption extends ChallengeOptionBase {
321
+ readonly provider: 'turnstile'
312
322
  /** Served by every SPA mount at `<prefix>/__fabrika-config.json`, so the page renders the widget with it. */
313
323
  readonly siteKey?: string
314
324
  }
315
325
 
316
- /** A file field of a form: the collection its file lands in (a `scan: true` one), and whether a submission must carry it. */
317
- export interface FormFileOption {
318
- readonly collection: string
319
- /** `true`: a submission without the file is refused (400). Absent or `false`: the column gets `null`. */
320
- readonly required?: boolean
326
+ /** The page fetches its challenge from `GET /__fabrika/challenge/<action>` (`challengeUrl` of `@fabrika/app/client`). */
327
+ export interface AltchaChallengeOption extends ChallengeOptionBase {
328
+ readonly provider: 'altcha'
329
+ /**
330
+ * The PBKDF2 iterations of one attempt, 1000 to 18000; 5000 by default. A browser makes up to 10000 attempts, about
331
+ * 5000 on average, and the build refuses a cost a slow phone would not solve within 90 s or half of `maxAgeMs`.
332
+ */
333
+ readonly cost?: number
334
+ /** How long a challenge may be solved and submitted, in ms, 60 s to an hour; ten minutes by default. */
335
+ readonly maxAgeMs?: number
321
336
  }
322
337
 
323
- /** What `c.form(path, options)` takes. */
324
- export type FormOptions<I, T> =
325
- & {
326
- readonly input: I & NotLoose<I>
327
- readonly appendTo: string
328
- readonly maxBytes?: number
329
- /** Per minute; `bytesPerHour` only on a form with files, the bytes all its submissions write per hour. */
330
- readonly rate?: { readonly perClient?: number; readonly perForm?: number; readonly bytesPerHour?: number }
331
- readonly honeypot?: string
332
- readonly challenge?: FormChallengeOption
333
- /** The cron job each accepted submission kicks, in the background of the constant answer. */
334
- readonly then?: T
335
- /** A page on another origin may submit it: `true` from the environment's listed origins, `'*'` from any. */
336
- readonly crossOrigin?: true | '*'
337
- /**
338
- * Files a submission carries beside its input, by field. The form becomes `multipart/form-data` and needs a
339
- * challenge; each file waits, owned by nobody, until the job that drains the inbox attaches it.
340
- */
341
- readonly files?: { readonly [field: string]: FormFileOption }
342
- }
343
- & Owned<'kind' | 'path'>
344
-
345
338
  export interface LinkKit<A extends string> {
346
339
  query<const Acc extends LinkAccess<A>, O extends Schema, I extends InputSchema | null = null>(
347
340
  def: ProcedureDef<I, O, Acc>,
@@ -383,40 +376,40 @@ export interface Kit<A extends string, D extends string, CD extends string> {
383
376
  readonly dir: string
384
377
  readonly access: Acc
385
378
  /** Application variables whose values the pages read from `<prefix>/__fabrika-config.json`: public values only. */
386
- readonly public?: readonly [string, ...string[]]
379
+ readonly browserConfig?: readonly [string, ...string[]]
387
380
  } & Owned<'kind' | 'prefix'>,
388
381
  ): SpaMount<Acc>
389
382
  health(path: Path): HealthMount
390
- form<I extends InputSchema, const T extends string = never>(path: Path, options: FormOptions<I, T>): FormMount<I, NoInfer<T>>
391
383
  webhook<const B extends D | 'app', const Acts extends readonly [A, ...A[]], I extends InputSchema>(
392
384
  path: Path,
393
385
  options: {
394
- readonly scheme: 'github-sha256'
386
+ readonly scheme: WebhookScheme
395
387
  readonly boundTo: B
396
388
  readonly actions: Acts
397
389
  readonly input: I & NotLoose<I>
398
- readonly maxBytes?: number
390
+ /** A delivery's body (1 MiB by default and at most) and its handler's time (10 s by default, at most 60 s). */
391
+ readonly limits?: { readonly maxBytes?: number; readonly timeoutMs?: number }
399
392
  } & Owned<'kind' | 'path'>,
400
393
  ): WebhookMount<B, Acts[number], I>
401
- files<const L extends boolean | readonly [string, ...string[]] = false>(
402
- collection: {
403
- readonly owner: string
404
- readonly read: A
405
- readonly write: A
406
- readonly delete: A
407
- readonly maxBytes: number
408
- readonly types: readonly [string, ...string[]]
409
- readonly maxPerOwner?: number
410
- /** `true`: every link mount bound to the owner's dimension serves the files; a list: the named link mounts only. */
411
- readonly link?: L
412
- /** Each upload is scanned for malware and served only after a clean verdict; until then it answers 409. */
413
- readonly scan?: boolean
414
- } & Owned<'kind'>,
415
- ): FileCollection<A, L>
416
- cron<const Acts extends readonly [A, ...A[]], const Sc extends D | 'app'>(
394
+ /**
395
+ * A file kind: the media types its files may be, the largest one, and whether each waits for a clean scan. A table's
396
+ * file column holds files of it (`files: { <column>: '<kind>' }`), and the table's actions are their authority.
397
+ */
398
+ files(
399
+ kind:
400
+ & {
401
+ readonly types: readonly [string, ...string[]]
402
+ readonly maxBytes: number
403
+ /** Each file is scanned for malware and served only after a clean verdict; until then it answers 409. */
404
+ readonly scan?: boolean
405
+ }
406
+ & Owned<'kind'>,
407
+ ): FileKind
408
+ /** A job of the whole application, holding `actions`; it runs on its schedule (UTC, five fields) and when kicked. */
409
+ cron<const Acts extends readonly [A, ...A[]]>(
417
410
  schedule: string,
418
- options: { readonly actions: Acts; readonly scope: Sc; readonly timeout?: string; readonly retries?: number } & Owned<'kind' | 'schedule'>,
419
- ): CronJob<Acts[number], Sc>
411
+ options: { readonly actions: Acts; readonly limits?: JobLimitsOption; readonly retries?: number } & Owned<'kind' | 'schedule'>,
412
+ ): CronJob<Acts[number]>
420
413
  export<const T extends QueryTree>(
421
414
  definition: {
422
415
  readonly version: number
@@ -452,7 +445,6 @@ function procedure<T extends ProcedureType, I extends InputSchema | null, O exte
452
445
  input: def.input ?? null,
453
446
  output: def.output,
454
447
  access: def.access,
455
- ...(def.crossOrigin === undefined ? {} : { crossOrigin: def.crossOrigin }),
456
448
  unknownOptions: unknownOptions(def, 'procedure'),
457
449
  }
458
450
  }
@@ -492,24 +484,10 @@ export function surfaceKit<A extends string, D extends string, CD extends string
492
484
  prefix,
493
485
  dir: options.dir,
494
486
  access: options.access,
495
- public: options.public,
487
+ browserConfig: options.browserConfig,
496
488
  unknownOptions: unknownOptions(options, 'spa'),
497
489
  }),
498
490
  health: (path) => ({ kind: 'health', path }),
499
- form: (path, options) => ({
500
- kind: 'form',
501
- path,
502
- input: options.input,
503
- appendTo: options.appendTo,
504
- maxBytes: options.maxBytes,
505
- rate: options.rate,
506
- honeypot: options.honeypot,
507
- challenge: options.challenge,
508
- then: options.then,
509
- ...(options.crossOrigin === undefined ? {} : { crossOrigin: options.crossOrigin }),
510
- ...(options.files === undefined ? {} : { files: options.files }),
511
- unknownOptions: unknownOptions(options, 'form'),
512
- }),
513
491
  webhook: (path, options) => ({
514
492
  kind: 'webhook',
515
493
  path,
@@ -517,28 +495,21 @@ export function surfaceKit<A extends string, D extends string, CD extends string
517
495
  boundTo: options.boundTo,
518
496
  actions: options.actions,
519
497
  input: options.input,
520
- maxBytes: options.maxBytes,
498
+ ...(options.limits === undefined ? {} : { limits: options.limits }),
521
499
  unknownOptions: unknownOptions(options, 'webhook'),
522
500
  }),
523
- files: (collection) => ({
524
- kind: 'files',
525
- owner: collection.owner,
526
- read: collection.read,
527
- write: collection.write,
528
- delete: collection.delete,
529
- maxBytes: collection.maxBytes,
530
- types: collection.types,
531
- maxPerOwner: collection.maxPerOwner,
532
- link: collection.link,
533
- scan: collection.scan,
534
- unknownOptions: unknownOptions(collection, 'files'),
501
+ files: (kind) => ({
502
+ kind: 'file-kind',
503
+ types: kind.types,
504
+ maxBytes: kind.maxBytes,
505
+ scan: kind.scan,
506
+ unknownOptions: unknownOptions(kind, 'fileKind'),
535
507
  }),
536
508
  cron: (schedule, options) => ({
537
509
  kind: 'cron',
538
510
  schedule,
539
511
  actions: options.actions,
540
- scope: options.scope,
541
- timeout: options.timeout,
512
+ ...(options.limits === undefined ? {} : { limits: options.limits }),
542
513
  retries: options.retries,
543
514
  unknownOptions: unknownOptions(options, 'cron'),
544
515
  }),
@@ -562,7 +533,7 @@ export function defineContract<
562
533
  const D extends string,
563
534
  const R extends string,
564
535
  const Dims extends { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> },
565
- const S extends Surface & FormJobs<S>,
536
+ const S extends Surface,
566
537
  >(
567
538
  vocabulary: Presentation & AppPeople<NoInfer<A>> & {
568
539
  readonly app: string
@@ -0,0 +1,78 @@
1
+ // `file('<kind>')`: a stored file of a declared kind, as a procedure's input field or its whole output. In an input it
2
+ // is a top-level field, optional, or a bounded array of them: the client sends it as a multipart part of its own, and
3
+ // the handler receives a file reference to the file the framework staged. As an output it is the file the procedure
4
+ // answers: the handler returns a reference it read, and the framework streams the file. The value is a zod schema of a
5
+ // file reference, marked with its kind.
6
+
7
+ import { z } from 'zod/v4'
8
+ import type { Schema } from './define'
9
+
10
+ const FILE = Symbol('fabrika.file')
11
+
12
+ /** A stored file's states: written, waiting for a scan, served, or refused by the scan. */
13
+ export type FileStatus = 'pending' | 'quarantined' | 'live' | 'rejected'
14
+
15
+ /**
16
+ * What a handler holds of a stored file: its metadata, never its bytes. A file column read through `ctx.db`, an input
17
+ * file and `ctx.files.put` answer one; only one this request holds is written into a column, served or opened.
18
+ */
19
+ export interface FileRef {
20
+ readonly id: string
21
+ readonly name: string | null
22
+ readonly type: string
23
+ readonly size: number
24
+ readonly status: FileStatus
25
+ }
26
+
27
+ /** What `file()` adds to its schema: the kind of file it holds. */
28
+ export interface FileMark {
29
+ readonly kind: string
30
+ }
31
+
32
+ /** A file field or output of kind `K`. */
33
+ export type FileSchema<K extends string = string> = z.ZodType<FileRef, FileRef> & FileBrand<K>
34
+
35
+ /** What tells a file schema from a JSON one in the types. */
36
+ export interface FileBrand<K extends string> {
37
+ readonly [FILE]: FileMark & { readonly '~kind'?: K }
38
+ }
39
+
40
+ /** The marks `file` made: a property another module set on a schema is no file. */
41
+ const marks = new WeakSet<object>()
42
+
43
+ /** A value a file field admits on its way in: a reference the framework made, or (in a browser) the `Blob` it sends. */
44
+ function isFileValue(value: unknown): boolean {
45
+ if (typeof Blob !== 'undefined' && value instanceof Blob) {
46
+ return true
47
+ }
48
+ return typeof value === 'object' && value !== null && typeof Reflect.get(value, 'id') === 'string'
49
+ }
50
+
51
+ /**
52
+ * A file of kind `kind` (`c.files({ … })` of the contract): an input field (top level, `z.optional`, or `z.array(…).max(n)`)
53
+ * or a procedure's output. `compileContract` and the checker refuse it anywhere else.
54
+ */
55
+ export function file<const K extends string>(kind: K): FileSchema<K> {
56
+ const mark: FileMark = Object.freeze({ kind })
57
+ marks.add(mark)
58
+ const schema: z.ZodType<FileRef, FileRef> = z.custom<FileRef>(isFileValue, { message: `a file of kind '${kind}'` })
59
+ return Object.assign(schema, { [FILE]: mark })
60
+ }
61
+
62
+ /** The mark of a `file()` schema, or `null` for any other schema. */
63
+ export function fileOf(schema: Schema | z.core.$ZodType): FileMark | null {
64
+ const mark: unknown = Reflect.get(schema, FILE)
65
+ return isMark(mark) ? mark : null
66
+ }
67
+
68
+ function isMark(value: unknown): value is FileMark {
69
+ return typeof value === 'object' && value !== null && marks.has(value)
70
+ }
71
+
72
+ type ClientValue<V> = V extends FileRef ? Blob : V extends readonly FileRef[] ? readonly Blob[] : V
73
+
74
+ /**
75
+ * An input as a client sends it: each file field takes the `Blob` (or `File`) the page holds, which the client sends as
76
+ * a part of its own. An input without a file field is the input itself.
77
+ */
78
+ export type ClientInput<T> = [Extract<T[keyof T], FileRef | readonly FileRef[]>] extends [never] ? T : { [K in keyof T]: ClientValue<T[K]> }