agentic-zi-ui-schema 0.0.1

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/src/index.ts ADDED
@@ -0,0 +1,543 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * ui.v1 — a safe, declarative UI schema.
5
+ * - No executable JS
6
+ * - Events map to action descriptors
7
+ * - Client uses allowlists to render
8
+ */
9
+
10
+ // ------------------ Guardrails ------------------
11
+
12
+ export const UI_LIMITS = {
13
+ MAX_NODES: 500,
14
+ MAX_DEPTH: 30,
15
+ MAX_TEXT_LEN: 10_000,
16
+ MAX_CLASSNAME_LEN: 1_000,
17
+ MAX_ID_LEN: 200,
18
+ MAX_URL_LEN: 2_000,
19
+ MAX_KEY_LEN: 200,
20
+ } as const;
21
+
22
+ export const UI_ALLOWED_TAGS = [
23
+ "a",
24
+ "article",
25
+ "aside",
26
+ "blockquote",
27
+ "br",
28
+ "button",
29
+ "code",
30
+ "div",
31
+ "em",
32
+ "figcaption",
33
+ "figure",
34
+ "fieldset",
35
+ "footer",
36
+ "form",
37
+ "h1",
38
+ "h2",
39
+ "h3",
40
+ "h4",
41
+ "header",
42
+ "hr",
43
+ "img",
44
+ "input",
45
+ "label",
46
+ "li",
47
+ "main",
48
+ "nav",
49
+ "ol",
50
+ "option",
51
+ "p",
52
+ "path",
53
+ "pre",
54
+ "section",
55
+ "select",
56
+ "small",
57
+ "span",
58
+ "strong",
59
+ "svg",
60
+ "table",
61
+ "tbody",
62
+ "td",
63
+ "textarea",
64
+ "tfoot",
65
+ "th",
66
+ "thead",
67
+ "tr",
68
+ "ul",
69
+ ] as const;
70
+
71
+ export const UI_ALLOWED_PROPS = [
72
+ "alt",
73
+ "aria-label",
74
+ "aria-controls",
75
+ "aria-current",
76
+ "aria-describedby",
77
+ "aria-disabled",
78
+ "aria-expanded",
79
+ "aria-hidden",
80
+ "aria-invalid",
81
+ "aria-labelledby",
82
+ "aria-live",
83
+ "aria-pressed",
84
+ "aria-required",
85
+ "aria-selected",
86
+ "aria-checked",
87
+ "aria-valuemax",
88
+ "aria-valuemin",
89
+ "aria-valuenow",
90
+ "aria-valuetext",
91
+ "aria-busy",
92
+ "checked",
93
+ "clipRule",
94
+ "cols",
95
+ "cx",
96
+ "cy",
97
+ "d",
98
+ "decoding",
99
+ "disabled",
100
+ "fill",
101
+ "fillRule",
102
+ "height",
103
+ "htmlFor",
104
+ "href",
105
+ "loading",
106
+ "max",
107
+ "maxLength",
108
+ "min",
109
+ "minLength",
110
+ "name",
111
+ "opacity",
112
+ "placeholder",
113
+ "preserveAspectRatio",
114
+ "rel",
115
+ "role",
116
+ "r",
117
+ "rows",
118
+ "src",
119
+ "stroke",
120
+ "strokeLinecap",
121
+ "strokeLinejoin",
122
+ "strokeWidth",
123
+ "step",
124
+ "target",
125
+ "title",
126
+ "type",
127
+ "value",
128
+ "viewBox",
129
+ "width",
130
+ "x",
131
+ "y",
132
+ ] as const;
133
+
134
+ export const UI_ALLOWED_EVENTS = [
135
+ "onClick",
136
+ "onChange",
137
+ "onSubmit",
138
+ "onMouseEnter",
139
+ "onMouseLeave",
140
+ "onMouseOver",
141
+ "onMouseOut",
142
+ "onMouseDown",
143
+ "onMouseUp",
144
+ "onMouseMove",
145
+ "onFocus",
146
+ "onBlur",
147
+ ] as const;
148
+
149
+ const zSafeId = z.string().min(1).max(UI_LIMITS.MAX_ID_LEN);
150
+ const zSafeText = z.string().max(UI_LIMITS.MAX_TEXT_LEN);
151
+ const zSafeClassName = z.string().max(UI_LIMITS.MAX_CLASSNAME_LEN);
152
+ const zSafeKey = z.string().min(1).max(UI_LIMITS.MAX_KEY_LEN);
153
+ const zSafePropValue = z.union([z.string(), z.number(), z.boolean()]);
154
+
155
+ const UI_BASE_PROP_KEYS = new Set<string>(UI_ALLOWED_PROPS);
156
+
157
+ // ------------------ Actions ------------------
158
+
159
+ export const UiActionTypeSchema = z.enum([
160
+ "FLOW_EVENT",
161
+ "OPEN_URL",
162
+ "COPY",
163
+ "TOAST",
164
+ "PATCH_STATE",
165
+ ]);
166
+
167
+ export type UiActionType = z.infer<typeof UiActionTypeSchema>;
168
+
169
+ export const UiActionSchema = z.discriminatedUnion("type", [
170
+ z.object({
171
+ type: z.literal("FLOW_EVENT"),
172
+ name: z.string().min(1).max(200),
173
+ payload: z.record(z.string(), z.unknown()).optional(),
174
+ }),
175
+ z.object({
176
+ type: z.literal("OPEN_URL"),
177
+ url: z.string().min(1).max(UI_LIMITS.MAX_URL_LEN),
178
+ target: z.enum(["_blank", "_self"]).optional(),
179
+ }),
180
+ z.object({
181
+ type: z.literal("COPY"),
182
+ text: zSafeText,
183
+ }),
184
+ z.object({
185
+ type: z.literal("TOAST"),
186
+ message: z.string().min(1).max(500),
187
+ level: z.enum(["info", "success", "warning", "error"]).optional(),
188
+ }),
189
+ z.object({
190
+ // Local component state patch. (POC: flat key/value)
191
+ type: z.literal("PATCH_STATE"),
192
+ key: z.string().min(1).max(UI_LIMITS.MAX_KEY_LEN),
193
+ valueFrom: z
194
+ .enum(["event.target.value", "event.target.checked"])
195
+ .optional(),
196
+ value: z.unknown().optional(),
197
+ }),
198
+ ]);
199
+
200
+ export type UiAction = z.infer<typeof UiActionSchema>;
201
+
202
+ // ------------------ Events ------------------
203
+
204
+ export const UiEventMapSchema = z
205
+ .object({
206
+ onClick: UiActionSchema.optional(),
207
+ onChange: UiActionSchema.optional(),
208
+ onSubmit: UiActionSchema.optional(),
209
+ onMouseEnter: UiActionSchema.optional(),
210
+ onMouseLeave: UiActionSchema.optional(),
211
+ onMouseOver: UiActionSchema.optional(),
212
+ onMouseOut: UiActionSchema.optional(),
213
+ onMouseDown: UiActionSchema.optional(),
214
+ onMouseUp: UiActionSchema.optional(),
215
+ onMouseMove: UiActionSchema.optional(),
216
+ onFocus: UiActionSchema.optional(),
217
+ onBlur: UiActionSchema.optional(),
218
+ })
219
+ .strict();
220
+
221
+ export type UiEventMap = z.infer<typeof UiEventMapSchema>;
222
+
223
+ // ------------------ Node schema ------------------
224
+
225
+ /**
226
+ * We keep props limited. No dangerouslySetInnerHTML.
227
+ * You can expand allowed props gradually.
228
+ */
229
+ const UiPropsSchema = z
230
+ .object({
231
+ // common
232
+ title: z.string().max(500).optional(),
233
+ role: z.string().max(200).optional(),
234
+ "aria-label": z.string().max(200).optional(),
235
+ "aria-controls": z.string().max(200).optional(),
236
+ "aria-current": zSafePropValue.optional(),
237
+ "aria-describedby": z.string().max(200).optional(),
238
+ "aria-disabled": zSafePropValue.optional(),
239
+ "aria-expanded": zSafePropValue.optional(),
240
+ "aria-hidden": zSafePropValue.optional(),
241
+ "aria-invalid": zSafePropValue.optional(),
242
+ "aria-labelledby": z.string().max(200).optional(),
243
+ "aria-live": z.string().max(50).optional(),
244
+ "aria-pressed": zSafePropValue.optional(),
245
+ "aria-required": zSafePropValue.optional(),
246
+ "aria-selected": zSafePropValue.optional(),
247
+ "aria-checked": zSafePropValue.optional(),
248
+ "aria-valuemax": zSafePropValue.optional(),
249
+ "aria-valuemin": zSafePropValue.optional(),
250
+ "aria-valuenow": zSafePropValue.optional(),
251
+ "aria-valuetext": z.string().max(200).optional(),
252
+ "aria-busy": zSafePropValue.optional(),
253
+
254
+ // input-ish
255
+ placeholder: z.string().max(200).optional(),
256
+ type: z.string().max(50).optional(),
257
+ name: z.string().max(200).optional(),
258
+ value: z.union([z.string(), z.number(), z.boolean()]).optional(),
259
+ checked: z.union([z.boolean(), z.string()]).optional(),
260
+ disabled: z.union([z.boolean(), z.string()]).optional(),
261
+
262
+ // links + media
263
+ href: z.string().max(UI_LIMITS.MAX_URL_LEN).optional(),
264
+ target: z.enum(["_blank", "_self"]).optional(),
265
+ rel: z.string().max(200).optional(),
266
+ src: z.string().max(UI_LIMITS.MAX_URL_LEN).optional(),
267
+ alt: z.string().max(500).optional(),
268
+ loading: z.enum(["eager", "lazy"]).optional(),
269
+ decoding: z.enum(["auto", "async", "sync"]).optional(),
270
+
271
+ // sizing + constraints
272
+ width: z.union([z.string(), z.number()]).optional(),
273
+ height: z.union([z.string(), z.number()]).optional(),
274
+ rows: z.number().int().min(1).max(1000).optional(),
275
+ cols: z.number().int().min(1).max(2000).optional(),
276
+ min: z.union([z.string(), z.number()]).optional(),
277
+ max: z.union([z.string(), z.number()]).optional(),
278
+ step: z.union([z.string(), z.number()]).optional(),
279
+ minLength: z.number().int().min(0).max(10000).optional(),
280
+ maxLength: z.number().int().min(0).max(10000).optional(),
281
+
282
+ // label
283
+ htmlFor: z.string().max(200).optional(),
284
+
285
+ // svg-ish
286
+ viewBox: z.string().max(200).optional(),
287
+ preserveAspectRatio: z.string().max(200).optional(),
288
+ fill: z.string().max(200).optional(),
289
+ fillRule: z.string().max(50).optional(),
290
+ clipRule: z.string().max(50).optional(),
291
+ stroke: z.string().max(200).optional(),
292
+ strokeWidth: z.union([z.string(), z.number()]).optional(),
293
+ strokeLinecap: z.string().max(50).optional(),
294
+ strokeLinejoin: z.string().max(50).optional(),
295
+ d: z.string().max(2000).optional(),
296
+ cx: z.union([z.string(), z.number()]).optional(),
297
+ cy: z.union([z.string(), z.number()]).optional(),
298
+ r: z.union([z.string(), z.number()]).optional(),
299
+ x: z.union([z.string(), z.number()]).optional(),
300
+ y: z.union([z.string(), z.number()]).optional(),
301
+ opacity: z.union([z.string(), z.number()]).optional(),
302
+ })
303
+ .catchall(zSafePropValue)
304
+ .superRefine((props, ctx) => {
305
+ if (!props) return;
306
+ for (const key of Object.keys(props)) {
307
+ if (UI_BASE_PROP_KEYS.has(key)) continue;
308
+ if (key.startsWith("aria-") || key.startsWith("data-")) {
309
+ if (key.length > UI_LIMITS.MAX_KEY_LEN) {
310
+ ctx.addIssue({
311
+ code: z.ZodIssueCode.custom,
312
+ message: `Prop key too long: ${key}`,
313
+ path: [key],
314
+ });
315
+ }
316
+ continue;
317
+ }
318
+ ctx.addIssue({
319
+ code: z.ZodIssueCode.custom,
320
+ message: `Unsupported prop: ${key}`,
321
+ path: [key],
322
+ });
323
+ }
324
+ })
325
+ .optional();
326
+
327
+ export const UiBindSchema = z
328
+ .object({
329
+ // bind node value/checked to local state key
330
+ valueKey: zSafeKey.optional(),
331
+ checkedKey: zSafeKey.optional(),
332
+ })
333
+ .strict()
334
+ .optional();
335
+
336
+ export type UiBind = z.infer<typeof UiBindSchema>;
337
+
338
+ export const UiNodeSchema: z.ZodType<any> = z.lazy(() =>
339
+ z
340
+ .object({
341
+ id: zSafeId.optional(),
342
+ type: z.string().min(1).max(50), // allowlist enforced on client
343
+ className: zSafeClassName.optional(),
344
+ props: UiPropsSchema,
345
+ events: UiEventMapSchema.optional(),
346
+ bind: UiBindSchema,
347
+ widgetId: zSafeId.optional(),
348
+ widgetProps: z.record(zSafeKey, z.unknown()).optional(),
349
+ slots: z
350
+ .record(
351
+ zSafeKey,
352
+ z.array(z.union([zSafeText, UiNodeSchema])).max(UI_LIMITS.MAX_NODES)
353
+ )
354
+ .optional(),
355
+ slotName: zSafeKey.optional(),
356
+ children: z
357
+ .array(z.union([zSafeText, UiNodeSchema]))
358
+ .max(UI_LIMITS.MAX_NODES) // soft guard; hard guard below
359
+ .optional(),
360
+ })
361
+ .strict()
362
+ .superRefine((node, ctx) => {
363
+ const isWidget = node.type === "widget";
364
+ const isSlot = node.type === "slot";
365
+
366
+ if (isWidget) {
367
+ if (!node.widgetId) {
368
+ ctx.addIssue({
369
+ code: z.ZodIssueCode.custom,
370
+ message: "widgetId is required for widget nodes",
371
+ });
372
+ }
373
+ if (node.className || node.props || node.events || node.bind) {
374
+ ctx.addIssue({
375
+ code: z.ZodIssueCode.custom,
376
+ message:
377
+ "widget nodes cannot use className/props/events/bind; use widgetProps and slots",
378
+ });
379
+ }
380
+ if (node.children) {
381
+ ctx.addIssue({
382
+ code: z.ZodIssueCode.custom,
383
+ message: "widget nodes must use slots instead of children",
384
+ });
385
+ }
386
+ if (node.slotName) {
387
+ ctx.addIssue({
388
+ code: z.ZodIssueCode.custom,
389
+ message: "widget nodes cannot define slotName",
390
+ });
391
+ }
392
+ } else if (isSlot) {
393
+ if (!node.slotName) {
394
+ ctx.addIssue({
395
+ code: z.ZodIssueCode.custom,
396
+ message: "slotName is required for slot nodes",
397
+ });
398
+ }
399
+ if (
400
+ node.className ||
401
+ node.props ||
402
+ node.events ||
403
+ node.bind ||
404
+ node.children ||
405
+ node.widgetId ||
406
+ node.widgetProps ||
407
+ node.slots
408
+ ) {
409
+ ctx.addIssue({
410
+ code: z.ZodIssueCode.custom,
411
+ message: "slot nodes cannot include other node fields",
412
+ });
413
+ }
414
+ } else {
415
+ if (node.widgetId || node.widgetProps || node.slots || node.slotName) {
416
+ ctx.addIssue({
417
+ code: z.ZodIssueCode.custom,
418
+ message: "reserved widget/slot fields are not allowed on UI nodes",
419
+ });
420
+ }
421
+ }
422
+ })
423
+ );
424
+
425
+ export type UiNode = z.infer<typeof UiNodeSchema>;
426
+
427
+ // ------------------ Messages ------------------
428
+
429
+ export const UiTemplateSchema = z
430
+ .object({
431
+ schema: z.literal("ui.v1"),
432
+ root: UiNodeSchema,
433
+ })
434
+ .strict();
435
+
436
+ export type UiTemplate = z.infer<typeof UiTemplateSchema>;
437
+
438
+ const WidgetPropSchema = z
439
+ .object({
440
+ type: z.enum(["string", "number", "boolean", "json"]),
441
+ required: z.boolean().optional(),
442
+ default: z.unknown().optional(),
443
+ })
444
+ .strict();
445
+
446
+ export const WidgetPolicySchema = z
447
+ .object({
448
+ allowedTags: z.array(z.string().min(1).max(50)).optional(),
449
+ allowedProps: z.array(zSafeKey).optional(),
450
+ allowedActions: z.array(UiActionTypeSchema).optional(),
451
+ maxNodes: z.number().int().min(1).max(UI_LIMITS.MAX_NODES).optional(),
452
+ maxDepth: z.number().int().min(1).max(UI_LIMITS.MAX_DEPTH).optional(),
453
+ })
454
+ .strict()
455
+ .optional();
456
+
457
+ export const WidgetDefinitionSchema = z
458
+ .object({
459
+ schema: z.literal("widget.v1"),
460
+ widgetId: zSafeId,
461
+ version: z.string().min(1).max(50),
462
+ template: UiTemplateSchema,
463
+ props: z.record(zSafeKey, WidgetPropSchema).optional(),
464
+ slots: z
465
+ .record(zSafeKey, z.object({ required: z.boolean().optional() }).strict())
466
+ .optional(),
467
+ policy: WidgetPolicySchema,
468
+ })
469
+ .strict();
470
+
471
+ export type WidgetDefinition = z.infer<typeof WidgetDefinitionSchema>;
472
+
473
+ export const FlowOutputSchema = z.discriminatedUnion("kind", [
474
+ z.object({ kind: z.literal("text"), text: zSafeText }),
475
+ z.object({ kind: z.literal("markdown"), markdown: zSafeText }),
476
+ z.object({
477
+ kind: z.literal("ui"),
478
+ templateId: z.string().min(1).max(200),
479
+ data: z.record(z.string(), z.unknown()).optional(),
480
+ }),
481
+ ]);
482
+
483
+ export type FlowOutput = z.infer<typeof FlowOutputSchema>;
484
+
485
+ export const WsClientEventSchema = z.discriminatedUnion("kind", [
486
+ z.object({
487
+ kind: z.literal("ui_event"),
488
+ name: z.string().min(1).max(200),
489
+ payload: z.record(z.string(), z.unknown()).optional(),
490
+ }),
491
+ ]);
492
+
493
+ export type WsClientEvent = z.infer<typeof WsClientEventSchema>;
494
+
495
+ export const WsServerMessageSchema = z.object({
496
+ kind: z.literal("flow_output"),
497
+ output: FlowOutputSchema,
498
+ });
499
+
500
+ export type WsServerMessage = z.infer<typeof WsServerMessageSchema>;
501
+
502
+ // ------------------ Hard guard helpers ------------------
503
+
504
+ export function countNodes(root: UiNode): { nodes: number; depth: number } {
505
+ let nodes = 0;
506
+ let maxDepth = 0;
507
+
508
+ function walk(node: UiNode, depth: number) {
509
+ nodes += 1;
510
+ if (depth > maxDepth) maxDepth = depth;
511
+
512
+ const children = node.children ?? [];
513
+ for (const c of children) {
514
+ if (typeof c === "string") continue;
515
+ walk(c, depth + 1);
516
+ }
517
+
518
+ const slots = node.slots ? Object.values(node.slots) : [];
519
+ for (const slotNodes of slots as any) {
520
+ for (const c of slotNodes) {
521
+ if (typeof c === "string") continue;
522
+ walk(c, depth + 1);
523
+ }
524
+ }
525
+ }
526
+
527
+ walk(root, 1);
528
+ return { nodes, depth: maxDepth };
529
+ }
530
+
531
+ export function assertWithinLimits(template: UiTemplate): void {
532
+ const { nodes, depth } = countNodes(template.root);
533
+ if (nodes > UI_LIMITS.MAX_NODES) {
534
+ throw new Error(
535
+ `UI template too large: nodes=${nodes} > ${UI_LIMITS.MAX_NODES}`
536
+ );
537
+ }
538
+ if (depth > UI_LIMITS.MAX_DEPTH) {
539
+ throw new Error(
540
+ `UI template too deep: depth=${depth} > ${UI_LIMITS.MAX_DEPTH}`
541
+ );
542
+ }
543
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "moduleResolution": "Bundler",
10
+ "strict": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src"]
14
+ }