@fabrika/app 0.6.0 → 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
@@ -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,40 @@ 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"
299
-
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 {
306
+ /** A public procedure's challenge: the provider and the application variables that hold its secret and its public site key. */
307
+ export interface ChallengeOption {
310
308
  readonly provider: ChallengeProvider
311
309
  readonly secret: string
312
310
  /** Served by every SPA mount at `<prefix>/__fabrika-config.json`, so the page renders the widget with it. */
313
311
  readonly siteKey?: string
312
+ /**
313
+ * The widget's `action`, which the verification expects back: at most 32 letters, digits, `_` and `-`. The endpoint id
314
+ * with `_` for `.` by default (`api_visitors_register`).
315
+ */
316
+ readonly action?: string
314
317
  }
315
318
 
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
321
- }
322
-
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
319
  export interface LinkKit<A extends string> {
346
320
  query<const Acc extends LinkAccess<A>, O extends Schema, I extends InputSchema | null = null>(
347
321
  def: ProcedureDef<I, O, Acc>,
@@ -383,40 +357,40 @@ export interface Kit<A extends string, D extends string, CD extends string> {
383
357
  readonly dir: string
384
358
  readonly access: Acc
385
359
  /** Application variables whose values the pages read from `<prefix>/__fabrika-config.json`: public values only. */
386
- readonly public?: readonly [string, ...string[]]
360
+ readonly browserConfig?: readonly [string, ...string[]]
387
361
  } & Owned<'kind' | 'prefix'>,
388
362
  ): SpaMount<Acc>
389
363
  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
364
  webhook<const B extends D | 'app', const Acts extends readonly [A, ...A[]], I extends InputSchema>(
392
365
  path: Path,
393
366
  options: {
394
- readonly scheme: 'github-sha256'
367
+ readonly scheme: WebhookScheme
395
368
  readonly boundTo: B
396
369
  readonly actions: Acts
397
370
  readonly input: I & NotLoose<I>
398
- readonly maxBytes?: number
371
+ /** A delivery's body (1 MiB by default and at most) and its handler's time (10 s by default, at most 60 s). */
372
+ readonly limits?: { readonly maxBytes?: number; readonly timeoutMs?: number }
399
373
  } & Owned<'kind' | 'path'>,
400
374
  ): 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'>(
375
+ /**
376
+ * A file kind: the media types its files may be, the largest one, and whether each waits for a clean scan. A table's
377
+ * file column holds files of it (`files: { <column>: '<kind>' }`), and the table's actions are their authority.
378
+ */
379
+ files(
380
+ kind:
381
+ & {
382
+ readonly types: readonly [string, ...string[]]
383
+ readonly maxBytes: number
384
+ /** Each file is scanned for malware and served only after a clean verdict; until then it answers 409. */
385
+ readonly scan?: boolean
386
+ }
387
+ & Owned<'kind'>,
388
+ ): FileKind
389
+ /** A job of the whole application, holding `actions`; it runs on its schedule (UTC, five fields) and when kicked. */
390
+ cron<const Acts extends readonly [A, ...A[]]>(
417
391
  schedule: string,
418
- options: { readonly actions: Acts; readonly scope: Sc; readonly timeout?: string; readonly retries?: number } & Owned<'kind' | 'schedule'>,
419
- ): CronJob<Acts[number], Sc>
392
+ options: { readonly actions: Acts; readonly limits?: JobLimitsOption; readonly retries?: number } & Owned<'kind' | 'schedule'>,
393
+ ): CronJob<Acts[number]>
420
394
  export<const T extends QueryTree>(
421
395
  definition: {
422
396
  readonly version: number
@@ -452,7 +426,6 @@ function procedure<T extends ProcedureType, I extends InputSchema | null, O exte
452
426
  input: def.input ?? null,
453
427
  output: def.output,
454
428
  access: def.access,
455
- ...(def.crossOrigin === undefined ? {} : { crossOrigin: def.crossOrigin }),
456
429
  unknownOptions: unknownOptions(def, 'procedure'),
457
430
  }
458
431
  }
@@ -492,24 +465,10 @@ export function surfaceKit<A extends string, D extends string, CD extends string
492
465
  prefix,
493
466
  dir: options.dir,
494
467
  access: options.access,
495
- public: options.public,
468
+ browserConfig: options.browserConfig,
496
469
  unknownOptions: unknownOptions(options, 'spa'),
497
470
  }),
498
471
  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
472
  webhook: (path, options) => ({
514
473
  kind: 'webhook',
515
474
  path,
@@ -517,28 +476,21 @@ export function surfaceKit<A extends string, D extends string, CD extends string
517
476
  boundTo: options.boundTo,
518
477
  actions: options.actions,
519
478
  input: options.input,
520
- maxBytes: options.maxBytes,
479
+ ...(options.limits === undefined ? {} : { limits: options.limits }),
521
480
  unknownOptions: unknownOptions(options, 'webhook'),
522
481
  }),
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'),
482
+ files: (kind) => ({
483
+ kind: 'file-kind',
484
+ types: kind.types,
485
+ maxBytes: kind.maxBytes,
486
+ scan: kind.scan,
487
+ unknownOptions: unknownOptions(kind, 'fileKind'),
535
488
  }),
536
489
  cron: (schedule, options) => ({
537
490
  kind: 'cron',
538
491
  schedule,
539
492
  actions: options.actions,
540
- scope: options.scope,
541
- timeout: options.timeout,
493
+ ...(options.limits === undefined ? {} : { limits: options.limits }),
542
494
  retries: options.retries,
543
495
  unknownOptions: unknownOptions(options, 'cron'),
544
496
  }),
@@ -562,7 +514,7 @@ export function defineContract<
562
514
  const D extends string,
563
515
  const R extends string,
564
516
  const Dims extends { readonly [K in D]: DimensionDef<NoInfer<A>, NoInfer<D>, NoInfer<R>> },
565
- const S extends Surface & FormJobs<S>,
517
+ const S extends Surface,
566
518
  >(
567
519
  vocabulary: Presentation & AppPeople<NoInfer<A>> & {
568
520
  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]> }