@fabrika/app 0.3.2 → 0.4.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 (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,317 @@
1
+ // The parser of a `ContractArtifact` read back as JSON (the build's own output, a file Control stored). Every
2
+ // object is strict and every field required, so a value that is not exactly an artifact is refused, not coerced.
3
+
4
+ import {
5
+ type ArrayShape,
6
+ type AttachmentShape,
7
+ BUILTIN_ROLE_KEYS,
8
+ type ContractArtifact,
9
+ displayNameProblem,
10
+ type EndpointRow,
11
+ LOCALES,
12
+ mailTemplateProblems,
13
+ type MessageKind,
14
+ type NumberShape,
15
+ type OutputShape,
16
+ type RecordShape,
17
+ type SchemaShape,
18
+ type StringShape,
19
+ type TablePolicyArtifact,
20
+ type TupleShape,
21
+ } from '@fabrika/auth-core'
22
+ import { z } from 'zod/v4'
23
+ import { FORM_LIMITS } from './defaults'
24
+
25
+ const name = z.string().min(1)
26
+ const nullableName = name.nullable()
27
+ const names = z.array(name)
28
+ const record = <T extends z.core.$ZodType>(value: T) => z.record(z.string().min(1), value)
29
+ /** A contract's actions are exact: IAM treats `*` as a pattern, which would widen every grant naming it. */
30
+ const action = name.refine((value) => !value.includes('*'), 'an action is exact; * is not an action')
31
+ const nullableAction = action.nullable()
32
+ const actions = z.array(action)
33
+ /** IAM's own role keys win over an application role of the same key at resolution. */
34
+ const roleKey = name.refine((key) => !BUILTIN_ROLE_KEYS.includes(key), 'a built-in IAM role key is not a contract role name')
35
+
36
+ const stringShape: z.ZodType<StringShape> = z.strictObject({
37
+ type: z.literal('string'),
38
+ format: z.enum(['uuid', 'email', 'url', 'datetime', 'date', 'time', 'duration']).nullable(),
39
+ minLength: z.int().nonnegative().nullable(),
40
+ maxLength: z.int().nonnegative().nullable(),
41
+ pattern: z.string().nullable(),
42
+ startsWith: z.string().nullable(),
43
+ endsWith: z.string().nullable(),
44
+ includes: z.string().nullable(),
45
+ })
46
+
47
+ const numberShape: z.ZodType<NumberShape> = z.strictObject({
48
+ type: z.literal('number'),
49
+ int: z.boolean(),
50
+ min: z.number().nullable(),
51
+ exclusiveMin: z.boolean(),
52
+ max: z.number().nullable(),
53
+ exclusiveMax: z.boolean(),
54
+ multipleOf: z.number().positive().nullable(),
55
+ })
56
+
57
+ /** A `SchemaShape` read back as JSON. */
58
+ export const schemaShapeSchema: z.ZodType<SchemaShape> = z.lazy(() =>
59
+ z.union([
60
+ stringShape,
61
+ numberShape,
62
+ z.strictObject({ type: z.enum(['boolean', 'null']) }),
63
+ z.strictObject({ type: z.literal('literal'), value: z.union([z.string(), z.number(), z.boolean(), z.null()]) }),
64
+ z.strictObject({ type: z.literal('enum'), values: z.array(z.string()) }),
65
+ arrayShape,
66
+ tupleShape,
67
+ recordShape,
68
+ z.strictObject({ type: z.literal('object'), fields: z.record(z.string(), schemaShapeSchema) }),
69
+ z.strictObject({ type: z.literal('optional'), inner: schemaShapeSchema }),
70
+ z.strictObject({ type: z.literal('nullable'), inner: schemaShapeSchema }),
71
+ z.strictObject({ type: z.literal('union'), options: z.array(schemaShapeSchema) }),
72
+ ])
73
+ )
74
+
75
+ const arrayShape: z.ZodType<ArrayShape> = z.lazy(() =>
76
+ z.strictObject({
77
+ type: z.literal('array'),
78
+ items: schemaShapeSchema,
79
+ minItems: z.int().nonnegative().nullable(),
80
+ maxItems: z.int().nonnegative().nullable(),
81
+ })
82
+ )
83
+
84
+ const tupleShape: z.ZodType<TupleShape> = z.lazy(() =>
85
+ z.strictObject({ type: z.literal('tuple'), items: z.array(schemaShapeSchema), rest: schemaShapeSchema.nullable() })
86
+ )
87
+
88
+ const recordShape: z.ZodType<RecordShape> = z.lazy(() =>
89
+ z.strictObject({ type: z.literal('record'), key: schemaShapeSchema, value: schemaShapeSchema })
90
+ )
91
+
92
+ const attachmentShape: z.ZodType<AttachmentShape> = z.strictObject({
93
+ type: z.literal('attachment'),
94
+ format: z.enum(['csv', 'xlsx']),
95
+ filename: name,
96
+ maxBytes: z.int().positive(),
97
+ maxRows: z.int().positive(),
98
+ columns: z.array(z.strictObject({ name: z.string(), shape: schemaShapeSchema })).min(1),
99
+ })
100
+
101
+ /** What a procedure answers: a schema shape, or an attachment's file. */
102
+ export const outputShapeSchema: z.ZodType<OutputShape> = z.union([schemaShapeSchema, attachmentShape])
103
+
104
+ const caller = z.union([z.enum(['human', 'machine', 'link']), z.templateLiteral(['app:', z.string().min(1)])])
105
+ const rpcCaller = z.enum(['human', 'machine'])
106
+
107
+ const accessForm = z.discriminatedUnion('form', [
108
+ z.strictObject({ form: z.literal('over'), dimension: name }),
109
+ z.strictObject({ form: z.literal('in'), dimension: name, from: name }),
110
+ z.strictObject({ form: z.literal('on'), dimension: name, from: nullableName }),
111
+ z.strictObject({ form: z.literal('creates'), dimension: name }),
112
+ z.strictObject({ form: z.literal('app') }),
113
+ ])
114
+
115
+ const requirement = z.discriminatedUnion('kind', [
116
+ z.strictObject({ kind: z.literal('member') }),
117
+ z.strictObject({ kind: z.literal('action'), action, access: accessForm }),
118
+ ])
119
+
120
+ const rowBase = {
121
+ id: name,
122
+ source: z.strictObject({ kind: z.enum(['mount', 'export', 'files']), name }),
123
+ method: z.enum(['GET', 'POST']),
124
+ path: z.string().startsWith('/'),
125
+ admission: z.strictObject({ contentTypes: z.tuple([name], name), maxBytes: z.int().positive().nullable() }).nullable(),
126
+ input: schemaShapeSchema.nullable(),
127
+ output: outputShapeSchema.nullable(),
128
+ }
129
+
130
+ const endpointRow: z.ZodType<EndpointRow> = z.union([
131
+ z.strictObject({
132
+ ...rowBase,
133
+ kind: z.enum(['query', 'mutation', 'spa', 'upload', 'download']),
134
+ callers: z.tuple([caller], caller),
135
+ requirement,
136
+ }),
137
+ z.strictObject({
138
+ ...rowBase,
139
+ kind: z.enum(['health', 'form', 'webhook', 'link-spa']),
140
+ callers: z.tuple([]),
141
+ requirement: z.null(),
142
+ }),
143
+ // A public SPA is a fixed static row: GET only, no body, no schemas.
144
+ z.strictObject({
145
+ ...rowBase,
146
+ kind: z.literal('public-spa'),
147
+ method: z.literal('GET'),
148
+ admission: z.null(),
149
+ input: z.null(),
150
+ output: z.null(),
151
+ callers: z.tuple([]),
152
+ requirement: z.null(),
153
+ }),
154
+ ])
155
+
156
+ const scopeBinding = z.discriminatedUnion('kind', [
157
+ z.strictObject({ kind: z.literal('app') }),
158
+ z.strictObject({ kind: z.literal('dimension'), dimension: name }),
159
+ ])
160
+
161
+ const formRate = z.int().positive().max(FORM_LIMITS.maxPerMinute)
162
+ const applicationVariable = z.string().regex(/^[A-Z][A-Z0-9_]*$/)
163
+
164
+ const mount = z.discriminatedUnion('kind', [
165
+ z.strictObject({ kind: z.literal('rpc'), prefix: name, callers: z.tuple([rpcCaller], rpcCaller) }),
166
+ z.strictObject({
167
+ kind: z.literal('link'),
168
+ prefix: name,
169
+ spa: name,
170
+ dimension: name,
171
+ grants: actions,
172
+ issuedBy: action,
173
+ maxDays: z.int().positive(),
174
+ }),
175
+ z.strictObject({ kind: z.literal('spa'), prefix: name, dir: name, public: z.array(applicationVariable).nonempty().optional() }),
176
+ z.strictObject({ kind: z.literal('health'), path: name }),
177
+ z.strictObject({
178
+ kind: z.literal('form'),
179
+ path: name,
180
+ appendTo: name,
181
+ rate: z.strictObject({ perClient: formRate.optional(), perForm: formRate.optional() }).optional(),
182
+ honeypot: name.optional(),
183
+ challenge: z.strictObject({ provider: z.literal('turnstile'), secret: applicationVariable, siteKey: applicationVariable.optional() }).optional(),
184
+ then: name.optional(),
185
+ }),
186
+ z.strictObject({ kind: z.literal('webhook'), path: name, scheme: z.literal('github-sha256'), boundTo: scopeBinding, actions }),
187
+ ])
188
+
189
+ const cspSource = z.strictObject({ origin: name, purpose: name })
190
+
191
+ const tablePolicy: z.ZodType<TablePolicyArtifact> = z.strictObject({
192
+ scope: z.discriminatedUnion('mode', [
193
+ z.strictObject({
194
+ mode: z.literal('dimension'),
195
+ dimension: name,
196
+ key: name,
197
+ container: z.strictObject({ dimension: name, column: name }).nullable(),
198
+ }),
199
+ z.strictObject({ mode: z.literal('child'), parent: name, key: name, column: name, inherit: names }),
200
+ z.strictObject({ mode: z.literal('inbox'), form: name }),
201
+ z.strictObject({ mode: z.literal('reference'), reason: name }),
202
+ z.strictObject({ mode: z.literal('app'), reason: name, key: name }),
203
+ ]),
204
+ actions: z.strictObject({
205
+ read: action,
206
+ insert: nullableAction,
207
+ update: nullableAction,
208
+ delete: nullableAction,
209
+ appRead: z.array(action).nonempty().optional(),
210
+ appCount: z.array(action).nonempty().optional(),
211
+ }),
212
+ insert: names,
213
+ update: names,
214
+ version: nullableName,
215
+ createdBy: nullableName,
216
+ columns: record(z.strictObject({ read: action.optional(), write: action.optional() })),
217
+ })
218
+
219
+ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObject({
220
+ app: name,
221
+ dimensions: record(z.strictObject({
222
+ in: nullableName,
223
+ creator: nullableName,
224
+ members: z.strictObject({ manage: action, list: action }).nullable(),
225
+ directory: z.literal('labels').optional(),
226
+ })),
227
+ actions: z.record(action, z.string()),
228
+ roles: z.record(
229
+ roleKey,
230
+ z.strictObject({
231
+ grants: actions,
232
+ member: z.strictObject({ scope: name, assignableBy: z.array(roleKey) }).nullable(),
233
+ external: z.literal(true).optional(),
234
+ }).refine((role) => role.external !== true || role.member !== null, {
235
+ path: ['external'],
236
+ message: 'only a membership role admits external principals',
237
+ }),
238
+ ),
239
+ mounts: record(mount),
240
+ files: record(z.strictObject({
241
+ owner: name,
242
+ read: action,
243
+ write: action,
244
+ delete: action,
245
+ maxBytes: z.int().positive(),
246
+ types: names,
247
+ maxPerOwner: z.int().positive().nullable(),
248
+ link: z.union([z.boolean(), z.array(name).nonempty()]),
249
+ scan: z.literal(true).optional(),
250
+ })),
251
+ jobs: record(z.strictObject({
252
+ schedule: name,
253
+ actions,
254
+ scope: scopeBinding,
255
+ timeout: nullableName,
256
+ retries: z.int().nonnegative(),
257
+ })),
258
+ exports: record(z.strictObject({ version: z.int().positive(), consumers: names, audience: name })),
259
+ imports: record(z.strictObject({
260
+ app: name,
261
+ export: name,
262
+ version: z.int().positive(),
263
+ digest: name,
264
+ procedures: names,
265
+ usedBy: names,
266
+ timeoutMs: z.int().positive().nullable(),
267
+ })),
268
+ csp: z.strictObject({
269
+ connectSrc: z.array(cspSource),
270
+ imgSrc: z.array(cspSource),
271
+ frameAncestors: z.array(cspSource),
272
+ scriptSrc: z.array(cspSource).nonempty().optional(),
273
+ frameSrc: z.array(cspSource).nonempty().optional(),
274
+ styleSrc: z.array(cspSource).nonempty().optional(),
275
+ fontSrc: z.array(cspSource).nonempty().optional(),
276
+ }),
277
+ tables: record(tablePolicy),
278
+ endpoints: z.array(endpointRow).superRefine((rows, context) => {
279
+ for (const [index, row] of rows.entries()) {
280
+ if (row.source.kind !== 'export' && row.callers.some((caller) => caller.startsWith('app:'))) {
281
+ context.addIssue({ code: 'custom', path: [index, 'callers'], message: 'an app:<id> caller reaches only an export row' })
282
+ }
283
+ if (row.source.kind === 'export' && row.requirement?.kind !== 'action') {
284
+ context.addIssue({ code: 'custom', path: [index, 'requirement'], message: 'an export row requires an action' })
285
+ }
286
+ if (row.source.kind === 'export' && row.requirement?.kind === 'action' && row.requirement.access.form === 'app') {
287
+ context.addIssue({ code: 'custom', path: [index, 'requirement'], message: 'an export row reads in one dimension, never app-wide' })
288
+ }
289
+ }
290
+ }),
291
+ reservedPrefixes: names,
292
+ declared: record(z.strictObject({
293
+ sql: z.enum(['read', 'write']).nullable(),
294
+ unscoped: record(z.enum(['read', 'write'])),
295
+ connect: names,
296
+ issues: nullableName,
297
+ })).optional(),
298
+ directories: record(z.strictObject({ dimension: name, lists: z.boolean() })).optional(),
299
+ locale: z.enum(LOCALES).optional(),
300
+ displayName: z.string().refine((value) => displayNameProblem(value) === null, 'not a display name').optional(),
301
+ mail: z.strictObject({ invitation: mailLocales('invitation'), enrollment: mailLocales('enrollment'), reset: mailLocales('reset') }).partial()
302
+ .optional(),
303
+ members: z.strictObject({ manage: action, list: action }).optional(),
304
+ directory: z.literal('labels').optional(),
305
+ })
306
+
307
+ function mailLocales(kind: MessageKind) {
308
+ const template = z.strictObject({ subject: z.string(), text: z.string(), html: z.string().optional() }).superRefine((value, context) => {
309
+ for (const message of mailTemplateProblems(kind, value)) context.addIssue({ code: 'custom', message })
310
+ })
311
+ return z.partialRecord(z.enum(LOCALES), template)
312
+ }
313
+
314
+ /** Parses JSON that should be a contract artifact; throws a `ZodError` naming every mismatch. */
315
+ export function parseContractArtifact(value: unknown): ContractArtifact {
316
+ return contractArtifactSchema.parse(value)
317
+ }
@@ -0,0 +1,74 @@
1
+ // `attachment()`: a procedure output that answers a file. The handler returns rows of the row schema, and the
2
+ // framework encodes them, so the quoting, the formula escaping and the size bound are right once. The value is the
3
+ // zod schema of what the handler returns (the rows' array), marked with what it declares beside it.
4
+
5
+ import { z } from 'zod/v4'
6
+ import { type AttachmentDeclaration, unknownOptions } from './declaration'
7
+ import type { InputSchema, NotLoose, Schema } from './define'
8
+
9
+ const ATTACHMENT = Symbol('fabrika.attachment')
10
+
11
+ /** What `attachment()` adds to the rows' array schema: its declaration and its row schema. */
12
+ export interface AttachmentMark {
13
+ readonly declaration: AttachmentDeclaration
14
+ readonly row: InputSchema
15
+ }
16
+
17
+ /** The marks `attachmentOutput` made: a property another module set on a schema is no attachment. */
18
+ const marks = new WeakSet<object>()
19
+
20
+ /** An attachment output of rows `R`: the handler returns `z.input<R>[]`, and the caller receives a file. */
21
+ export type Attachment<R extends InputSchema> = z.ZodArray<R> & AttachmentBrand
22
+
23
+ /** What tells an attachment output from a JSON one in the types. */
24
+ export interface AttachmentBrand {
25
+ readonly [ATTACHMENT]: AttachmentMark
26
+ }
27
+
28
+ export interface AttachmentOptions<R> {
29
+ /**
30
+ * `'csv'`: RFC 4180, UTF-8 with a byte order mark, a header line of the row's field names. `'xlsx'`: one sheet, the
31
+ * header row of the field names, then a number, boolean or text cell per value, and a date cell for a string whose
32
+ * schema is `z.iso.date()` or `z.iso.datetime()`.
33
+ */
34
+ readonly format: 'csv' | 'xlsx'
35
+ /** The file name the browser saves: `[A-Za-z0-9._-]`, ending in `.csv` or `.xlsx`, as the format. */
36
+ readonly filename: string
37
+ /** The row schema: a strict object of scalar fields, in the file's column order. */
38
+ readonly row: R
39
+ /** The encoded file's limit; an encoding over it fails the invocation and sends no body. */
40
+ readonly maxBytes: number
41
+ /** At most this many rows; 10 000 when left out. */
42
+ readonly maxRows?: number
43
+ }
44
+
45
+ /**
46
+ * The output of a procedure that answers a file of `row`s. Like every builder it validates nothing: `compileContract`
47
+ * and the checker refuse a format, a name, a limit or a column outside what an attachment may be.
48
+ */
49
+ export function attachment<R extends InputSchema>(options: AttachmentOptions<R & NotLoose<R>>): Attachment<R> {
50
+ return attachmentOutput(options.row, {
51
+ format: options.format,
52
+ filename: options.filename,
53
+ maxBytes: options.maxBytes,
54
+ ...(options.maxRows === undefined ? {} : { maxRows: options.maxRows }),
55
+ unknownOptions: unknownOptions(options, 'attachment'),
56
+ })
57
+ }
58
+
59
+ /** The rows' array schema marked with `declaration`; the static reader builds the evaluated value with it. */
60
+ export function attachmentOutput<R extends InputSchema>(row: R, declaration: AttachmentDeclaration): Attachment<R> {
61
+ const mark: AttachmentMark = Object.freeze({ declaration: Object.freeze({ ...declaration }), row })
62
+ marks.add(mark)
63
+ return Object.assign(z.array(row), { [ATTACHMENT]: mark })
64
+ }
65
+
66
+ /** The mark of an `attachment()` output, or `null` for any other schema. */
67
+ export function attachmentOf(schema: Schema): AttachmentMark | null {
68
+ const mark: unknown = Reflect.get(schema, ATTACHMENT)
69
+ return isMark(mark) ? mark : null
70
+ }
71
+
72
+ function isMark(value: unknown): value is AttachmentMark {
73
+ return typeof value === 'object' && value !== null && marks.has(value)
74
+ }