@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.
- package/package.json +10 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +58 -36
- package/skill/references/dev.md +11 -9
- package/skill/references/endpoint.md +154 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +450 -255
- package/skill/references/spa.md +6 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +14 -8
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +221 -79
- package/src/client/index.ts +3 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +168 -14
- package/src/contract/build.ts +1282 -619
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +79 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +142 -60
- package/src/contract/define.ts +92 -121
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +210 -157
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +6 -5
- package/src/implement/capability.ts +78 -2
- package/src/implement/challenge.ts +203 -47
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +831 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +167 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/challenge.ts +35 -1
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +168 -128
- package/src/testing/kit.ts +90 -25
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
package/src/contract/define.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
257
|
-
|
|
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
|
|
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
|
|
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]:
|
|
287
|
-
readonly jobs?: { readonly [name: string]: CronJob<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
|
-
/**
|
|
298
|
-
export type
|
|
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
|
-
|
|
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
|
-
/**
|
|
317
|
-
export interface
|
|
318
|
-
readonly
|
|
319
|
-
/**
|
|
320
|
-
|
|
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
|
|
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:
|
|
386
|
+
readonly scheme: WebhookScheme
|
|
395
387
|
readonly boundTo: B
|
|
396
388
|
readonly actions: Acts
|
|
397
389
|
readonly input: I & NotLoose<I>
|
|
398
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
cron<const Acts extends readonly [A, ...A[]]
|
|
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
|
|
419
|
-
): CronJob<Acts[number]
|
|
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
|
-
|
|
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
|
-
|
|
498
|
+
...(options.limits === undefined ? {} : { limits: options.limits }),
|
|
521
499
|
unknownOptions: unknownOptions(options, 'webhook'),
|
|
522
500
|
}),
|
|
523
|
-
files: (
|
|
524
|
-
kind: '
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
|
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]> }
|