@flow-like/widget-bundler 0.1.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/extract.ts ADDED
@@ -0,0 +1,1620 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import type {
4
+ WidgetCapabilities,
5
+ WidgetCspDirective,
6
+ WidgetCspPurpose,
7
+ WidgetNetworkInput,
8
+ WidgetUrlTemplate,
9
+ } from "@flow-like/widget-sdk";
10
+ import { isLlmKind } from "@flow-like/widget-sdk/llm";
11
+ import { validateInputValue } from "@flow-like/widget-sdk/validate";
12
+ import {
13
+ type CompletedConfig,
14
+ DEFAULT_CONFIG,
15
+ SchemaGenerator,
16
+ createFormatter,
17
+ createParser,
18
+ createProgram,
19
+ } from "ts-json-schema-generator";
20
+ import ts from "typescript";
21
+ import {
22
+ BASE_CONTRACT_VERSION,
23
+ CONTRACT_VERSION,
24
+ type ContractEvent,
25
+ type ContractInput,
26
+ type ContractQuery,
27
+ type JsonValue,
28
+ type WidgetContract,
29
+ normalizeCspPurposes,
30
+ validateContract,
31
+ validatePublishedCsp,
32
+ } from "./contract-types";
33
+ import {
34
+ cspReasonProblem,
35
+ foldWidgetCspReason,
36
+ reasonContainsAddress,
37
+ validateWidgetCspReason,
38
+ } from "./csp-reason";
39
+ import {
40
+ CSP_DIRECTIVES,
41
+ CSP_PURPOSE_KEYS,
42
+ CSP_SOURCE_REJECTION_MESSAGES,
43
+ type WidgetInputPathSegment,
44
+ flattenCspPurposes,
45
+ isCspDirective,
46
+ normalizeCspSource,
47
+ parseWidgetInputPath,
48
+ validateCspSource,
49
+ } from "./csp-source";
50
+ import { WILDCARD_PUBLIC_SUFFIX_MESSAGE, validateWildcardBases } from "./psl";
51
+
52
+ type JsonObject = { [key: string]: JsonValue };
53
+
54
+ export interface WidgetSizingConfig {
55
+ defaultHeight?: number;
56
+ resizable?: boolean;
57
+ maxHeight?: number;
58
+ }
59
+
60
+ export interface ExtractedWidgetConfig {
61
+ id: string;
62
+ name: string;
63
+ description: string;
64
+ sizing?: WidgetSizingConfig;
65
+ capabilities?: WidgetCapabilities;
66
+ /** Normalized, canonical purpose groups; absent when none are declared */
67
+ csp?: WidgetCspPurpose[];
68
+ fixtures?: Record<string, JsonValue>;
69
+ }
70
+
71
+ export interface ExtractResult {
72
+ contract: WidgetContract;
73
+ config: ExtractedWidgetConfig;
74
+ warnings: string[];
75
+ }
76
+
77
+ interface SectionMember {
78
+ name: string;
79
+ node: ts.PropertySignature;
80
+ typeNode: ts.TypeNode;
81
+ optional: boolean;
82
+ }
83
+
84
+ interface ResolvedSection {
85
+ name: string;
86
+ declaration: ts.InterfaceDeclaration | ts.TypeAliasDeclaration;
87
+ }
88
+
89
+ /**
90
+ * Statically derive a widget's `contract.json` and evaluated config from its
91
+ * `widget.config.ts` (`export default defineWidget<Inputs, Events, Queries>({...})`).
92
+ */
93
+ export function extractContract(widgetConfigPath: string): ExtractResult {
94
+ const absPath = resolve(widgetConfigPath);
95
+ if (!existsSync(absPath)) {
96
+ throw new Error(`Widget config not found: ${absPath}`);
97
+ }
98
+
99
+ // Package exports, path aliases and ESM resolution must match the widget build.
100
+ const tsconfig = ts.findConfigFile(dirname(absPath), ts.sys.fileExists);
101
+ const generatorConfig: CompletedConfig = {
102
+ ...DEFAULT_CONFIG,
103
+ path: absPath,
104
+ ...(tsconfig && { tsconfig }),
105
+ skipTypeCheck: true,
106
+ jsDoc: "extended",
107
+ extraTags: ["geometry", "llm", "mutation"],
108
+ topRef: false,
109
+ expose: "all",
110
+ additionalProperties: true,
111
+ sortProps: true,
112
+ };
113
+ const program = createProgram(generatorConfig);
114
+ const checker = program.getTypeChecker();
115
+ const sourceFile = program
116
+ .getSourceFiles()
117
+ .find((sf) => resolve(sf.fileName) === absPath);
118
+ if (!sourceFile) {
119
+ throw new Error(`Failed to load ${absPath} into the TypeScript program`);
120
+ }
121
+
122
+ const call = findDefineWidgetCall(sourceFile, absPath);
123
+ const [inputsArg, eventsArg, queriesArg] = call.typeArguments ?? [];
124
+ const inputsSection = inputsArg
125
+ ? resolveSectionType(inputsArg, checker, "Inputs", absPath)
126
+ : null;
127
+ const eventsSection = eventsArg
128
+ ? resolveSectionType(eventsArg, checker, "Events", absPath)
129
+ : null;
130
+ const queriesSection = queriesArg
131
+ ? resolveSectionType(queriesArg, checker, "Queries", absPath)
132
+ : null;
133
+
134
+ const configLiteral = unwrapExpression(
135
+ call.arguments[0] ?? missingConfig(absPath),
136
+ );
137
+ if (!ts.isObjectLiteralExpression(configLiteral)) {
138
+ throw new Error(
139
+ `defineWidget(...) in ${absPath} must be called with an object literal`,
140
+ );
141
+ }
142
+ const config = readWidgetConfig(
143
+ evaluateObjectLiteral(configLiteral, ""),
144
+ absPath,
145
+ );
146
+
147
+ const generator = new SchemaGenerator(
148
+ program,
149
+ createParser(program, generatorConfig),
150
+ createFormatter(generatorConfig),
151
+ generatorConfig,
152
+ );
153
+ const schemaFor = (typeName: string): JsonObject => {
154
+ try {
155
+ return generator.createSchema(typeName) as unknown as JsonObject;
156
+ } catch (e) {
157
+ throw new Error(
158
+ `Failed to derive a JSON Schema for type '${typeName}' in ${absPath}: ${e instanceof Error ? e.message : e}`,
159
+ );
160
+ }
161
+ };
162
+
163
+ const warnings: string[] = [];
164
+ const inputs = inputsSection
165
+ ? extractInputs(inputsSection, schemaFor, checker, config.id, warnings)
166
+ : {};
167
+ const events = eventsSection
168
+ ? extractEvents(eventsSection, schemaFor, checker)
169
+ : {};
170
+ const queries = queriesSection
171
+ ? extractQueries(queriesSection, schemaFor, checker)
172
+ : {};
173
+
174
+ const contract: WidgetContract = {
175
+ contractVersion: config.csp ? CONTRACT_VERSION : BASE_CONTRACT_VERSION,
176
+ id: config.id,
177
+ ...(config.capabilities && { capabilities: config.capabilities }),
178
+ ...(config.csp && { csp: config.csp }),
179
+ inputs,
180
+ events,
181
+ queries,
182
+ sizing: {
183
+ defaultHeight: config.sizing?.defaultHeight ?? 320,
184
+ resizable: config.sizing?.resizable ?? true,
185
+ ...(config.sizing?.maxHeight !== undefined && {
186
+ maxHeight: config.sizing.maxHeight,
187
+ }),
188
+ },
189
+ };
190
+
191
+ const errors = validateContract(contract);
192
+ if (errors.length === 0) {
193
+ errors.push(
194
+ ...validatePublishedCsp(contract),
195
+ ...networkInputSchemaErrors(contract),
196
+ );
197
+ }
198
+ if (errors.length > 0) {
199
+ throw new Error(
200
+ `Invalid contract for widget '${config.id}' (${absPath}): ${errors.join("; ")}`,
201
+ );
202
+ }
203
+ warnings.push(...networkInputWarnings(contract));
204
+
205
+ return { contract, config, warnings };
206
+ }
207
+
208
+ function missingConfig(path: string): never {
209
+ throw new Error(
210
+ `defineWidget(...) in ${path} is missing its config argument`,
211
+ );
212
+ }
213
+
214
+ function unwrapExpression(expr: ts.Expression): ts.Expression {
215
+ let current = expr;
216
+ while (
217
+ ts.isAsExpression(current) ||
218
+ ts.isSatisfiesExpression(current) ||
219
+ ts.isParenthesizedExpression(current) ||
220
+ ts.isNonNullExpression(current)
221
+ ) {
222
+ current = current.expression;
223
+ }
224
+ return current;
225
+ }
226
+
227
+ function findDefineWidgetCall(
228
+ sourceFile: ts.SourceFile,
229
+ path: string,
230
+ ): ts.CallExpression {
231
+ for (const statement of sourceFile.statements) {
232
+ if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue;
233
+ const expr = unwrapExpression(statement.expression);
234
+ if (!ts.isCallExpression(expr)) continue;
235
+ const callee = unwrapExpression(expr.expression);
236
+ const calleeName = ts.isIdentifier(callee)
237
+ ? callee.text
238
+ : ts.isPropertyAccessExpression(callee)
239
+ ? callee.name.text
240
+ : null;
241
+ if (calleeName === "defineWidget") return expr;
242
+ }
243
+ throw new Error(
244
+ `${path} must contain \`export default defineWidget<Inputs, Events, Queries>({ ... })\``,
245
+ );
246
+ }
247
+
248
+ function resolveSectionType(
249
+ typeArg: ts.TypeNode,
250
+ checker: ts.TypeChecker,
251
+ label: string,
252
+ path: string,
253
+ ): ResolvedSection | null {
254
+ if (ts.isTypeLiteralNode(typeArg)) {
255
+ if (typeArg.members.length === 0) return null;
256
+ throw new Error(
257
+ `${label} type argument in ${path} is an inline type literal; declare a named interface or type alias instead`,
258
+ );
259
+ }
260
+ if (!ts.isTypeReferenceNode(typeArg)) {
261
+ throw new Error(
262
+ `${label} type argument in ${path} must be a named interface or type alias`,
263
+ );
264
+ }
265
+ const declaration = resolveTypeDeclaration(typeArg.typeName, checker);
266
+ if (!declaration) {
267
+ throw new Error(
268
+ `Cannot resolve ${label} type '${typeArg.typeName.getText()}' in ${path}; it must be an interface or type alias declared in this file or imported from a sibling file`,
269
+ );
270
+ }
271
+ return { name: declaration.name.text, declaration };
272
+ }
273
+
274
+ function resolveTypeDeclaration(
275
+ typeName: ts.EntityName,
276
+ checker: ts.TypeChecker,
277
+ ): ts.InterfaceDeclaration | ts.TypeAliasDeclaration | null {
278
+ let symbol = checker.getSymbolAtLocation(typeName);
279
+ if (!symbol) return null;
280
+ if (symbol.flags & ts.SymbolFlags.Alias) {
281
+ symbol = checker.getAliasedSymbol(symbol);
282
+ }
283
+ for (const declaration of symbol.declarations ?? []) {
284
+ if (
285
+ ts.isInterfaceDeclaration(declaration) ||
286
+ ts.isTypeAliasDeclaration(declaration)
287
+ ) {
288
+ return declaration;
289
+ }
290
+ }
291
+ return null;
292
+ }
293
+
294
+ function sectionMembers(
295
+ section: ResolvedSection,
296
+ label: string,
297
+ ): SectionMember[] {
298
+ let members: ts.NodeArray<ts.TypeElement>;
299
+ if (ts.isInterfaceDeclaration(section.declaration)) {
300
+ members = section.declaration.members;
301
+ } else {
302
+ const aliased = section.declaration.type;
303
+ if (!ts.isTypeLiteralNode(aliased)) {
304
+ throw new Error(
305
+ `${label} type '${section.name}' must be an interface or an object type literal alias`,
306
+ );
307
+ }
308
+ members = aliased.members;
309
+ }
310
+
311
+ const result: SectionMember[] = [];
312
+ for (const member of members) {
313
+ if (!ts.isPropertySignature(member)) {
314
+ throw new Error(
315
+ `${label} type '${section.name}' may only contain plain properties (found ${ts.SyntaxKind[member.kind]})`,
316
+ );
317
+ }
318
+ const name =
319
+ ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)
320
+ ? member.name.text
321
+ : null;
322
+ if (name === null) {
323
+ throw new Error(
324
+ `${label} type '${section.name}' contains a computed property name; only plain identifiers are supported`,
325
+ );
326
+ }
327
+ if (!member.type) {
328
+ throw new Error(
329
+ `Property '${name}' of ${label} type '${section.name}' must have an explicit type annotation`,
330
+ );
331
+ }
332
+ result.push({
333
+ name,
334
+ node: member,
335
+ typeNode: unwrapTypeNode(member.type),
336
+ optional: member.questionToken !== undefined,
337
+ });
338
+ }
339
+ return result;
340
+ }
341
+
342
+ function unwrapTypeNode(node: ts.TypeNode): ts.TypeNode {
343
+ let current = node;
344
+ while (ts.isParenthesizedTypeNode(current)) {
345
+ current = current.type;
346
+ }
347
+ return current;
348
+ }
349
+
350
+ function isVoidLike(node: ts.TypeNode): boolean {
351
+ return (
352
+ node.kind === ts.SyntaxKind.VoidKeyword ||
353
+ node.kind === ts.SyntaxKind.UndefinedKeyword ||
354
+ node.kind === ts.SyntaxKind.NeverKeyword
355
+ );
356
+ }
357
+
358
+ function memberDescription(member: ts.PropertySignature): string | undefined {
359
+ for (const doc of ts.getJSDocCommentsAndTags(member)) {
360
+ if (!ts.isJSDoc(doc)) continue;
361
+ const text = ts.getTextOfJSDocComment(doc.comment)?.trim();
362
+ if (text) return text;
363
+ }
364
+ return undefined;
365
+ }
366
+
367
+ function memberHasTag(member: ts.PropertySignature, tagName: string): boolean {
368
+ return ts.getJSDocTags(member).some((tag) => tag.tagName.text === tagName);
369
+ }
370
+
371
+ function isJsonObject(value: JsonValue | undefined): value is JsonObject {
372
+ return typeof value === "object" && value !== null && !Array.isArray(value);
373
+ }
374
+
375
+ function requireSchemaObject(value: JsonValue, context: string): JsonObject {
376
+ if (!isJsonObject(value)) {
377
+ throw new Error(`Generated schema for ${context} is not an object`);
378
+ }
379
+ return value;
380
+ }
381
+
382
+ function schemaDefinitions(schema: JsonObject): Record<string, JsonValue> {
383
+ return isJsonObject(schema.definitions) ? schema.definitions : {};
384
+ }
385
+
386
+ function schemaProperties(schema: JsonObject): Record<string, JsonValue> {
387
+ return isJsonObject(schema.properties) ? schema.properties : {};
388
+ }
389
+
390
+ function schemaRequired(schema: JsonObject): Set<string> {
391
+ const required = schema.required;
392
+ return new Set(
393
+ Array.isArray(required)
394
+ ? required.filter((r): r is string => typeof r === "string")
395
+ : [],
396
+ );
397
+ }
398
+
399
+ const GEOMETRY_KINDS = new Set([
400
+ "Point",
401
+ "LineString",
402
+ "Polygon",
403
+ "MultiPoint",
404
+ "MultiLineString",
405
+ "MultiPolygon",
406
+ "GeometryCollection",
407
+ ]);
408
+
409
+ /** Geometry annotations select the shared geometry validator, including collections. */
410
+ function geometrySchema(
411
+ schema: JsonObject,
412
+ definitions: Record<string, JsonValue>,
413
+ context: string,
414
+ ): JsonObject {
415
+ const kind = schema.geometry;
416
+ if (
417
+ typeof kind !== "string" ||
418
+ (kind !== "Any" && !GEOMETRY_KINDS.has(kind))
419
+ ) {
420
+ throw new Error(
421
+ `Invalid @geometry '${String(kind)}' for ${context}; expected Any or ${[...GEOMETRY_KINDS].join(", ")}`,
422
+ );
423
+ }
424
+
425
+ const objectShape = (
426
+ value: JsonObject,
427
+ visited = new Set<string>(),
428
+ expectedKind = kind,
429
+ ): JsonObject => {
430
+ let shape = value;
431
+ while (typeof shape.$ref === "string" && !visited.has(shape.$ref)) {
432
+ visited.add(shape.$ref);
433
+ const definition = resolveDefinition(shape.$ref, definitions, context);
434
+ if (!isJsonObject(definition)) break;
435
+ const { $ref: _ref, ...rest } = shape;
436
+ shape = { ...definition, ...rest };
437
+ }
438
+ if (typeof shape.$ref === "string" && visited.has(shape.$ref)) return shape;
439
+ if (shape.type !== undefined && shape.type !== "object") {
440
+ throw new Error(
441
+ `@geometry ${kind} for ${context} must annotate a geometry object type; annotate the element type for arrays and maps`,
442
+ );
443
+ }
444
+ let hasBranches = false;
445
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
446
+ const branches = shape[key];
447
+ if (Array.isArray(branches) && branches.length > 0) {
448
+ hasBranches = true;
449
+ for (const branch of branches) {
450
+ if (isJsonObject(branch))
451
+ objectShape(branch, new Set(visited), expectedKind);
452
+ }
453
+ }
454
+ }
455
+ if (!hasBranches) {
456
+ const properties = schemaProperties(shape);
457
+ const type = properties.type;
458
+ const declaredKind = isJsonObject(type)
459
+ ? (type.const ??
460
+ (Array.isArray(type.enum) && type.enum.length === 1
461
+ ? type.enum[0]
462
+ : undefined))
463
+ : undefined;
464
+ if (
465
+ typeof declaredKind !== "string" ||
466
+ !GEOMETRY_KINDS.has(declaredKind) ||
467
+ (expectedKind !== "Any" && declaredKind !== expectedKind) ||
468
+ properties[
469
+ declaredKind === "GeometryCollection" ? "geometries" : "coordinates"
470
+ ] === undefined
471
+ ) {
472
+ throw new Error(
473
+ `@geometry ${kind} for ${context} must describe a compatible GeoJSON geometry object; check its type discriminant and coordinates or geometries, and annotate the element type for maps`,
474
+ );
475
+ }
476
+ const checkCoordinates = (
477
+ value: JsonValue | undefined,
478
+ depth: number,
479
+ ): void => {
480
+ let field = value;
481
+ const refs = new Set<string>();
482
+ while (
483
+ isJsonObject(field) &&
484
+ typeof field.$ref === "string" &&
485
+ !refs.has(field.$ref)
486
+ ) {
487
+ refs.add(field.$ref);
488
+ field = resolveDefinition(field.$ref, definitions, context);
489
+ }
490
+ const expected = depth > 0 ? "array" : "number";
491
+ if (
492
+ !isJsonObject(field) ||
493
+ (field.type !== expected &&
494
+ !(depth === 0 && field.type === "integer"))
495
+ ) {
496
+ throw new Error(
497
+ `@geometry ${kind} for ${context} has incompatible coordinates or geometries; expected ${expected}`,
498
+ );
499
+ }
500
+ if (depth === 0) return;
501
+ const items = Array.isArray(field.items) ? field.items : [field.items];
502
+ if (items.length === 0)
503
+ throw new Error(
504
+ `@geometry ${kind} for ${context} requires an element type`,
505
+ );
506
+ for (const item of items) {
507
+ if (declaredKind === "GeometryCollection") {
508
+ if (!isJsonObject(item))
509
+ throw new Error(
510
+ `@geometry ${kind} for ${context} requires geometry elements`,
511
+ );
512
+ objectShape(item, new Set(visited), "Any");
513
+ } else {
514
+ checkCoordinates(item, depth - 1);
515
+ }
516
+ }
517
+ };
518
+ const dimensions: Record<string, number> = {
519
+ Point: 1,
520
+ LineString: 2,
521
+ Polygon: 3,
522
+ MultiPoint: 2,
523
+ MultiLineString: 3,
524
+ MultiPolygon: 4,
525
+ GeometryCollection: 1,
526
+ };
527
+ checkCoordinates(
528
+ properties[
529
+ declaredKind === "GeometryCollection" ? "geometries" : "coordinates"
530
+ ],
531
+ dimensions[declaredKind] ?? 1,
532
+ );
533
+ }
534
+ return shape;
535
+ };
536
+ const shape = objectShape(schema);
537
+
538
+ // The contract carries a schema extension; the host converts it into the
539
+ // compact flow:geometry pin marker. The SDK validates the geometry profile.
540
+ const result: JsonObject = {
541
+ type: "object",
542
+ "x-flow-like-type": "geometry",
543
+ };
544
+ if (kind !== "Any") result["x-geometry"] = kind;
545
+ for (const key of [
546
+ "title",
547
+ "description",
548
+ "default",
549
+ "examples",
550
+ "deprecated",
551
+ ]) {
552
+ if (shape[key] !== undefined) result[key] = shape[key];
553
+ }
554
+ return result;
555
+ }
556
+
557
+ /**
558
+ * `@llm History`, `Response` or `ResponseChunk` selects a native Flow-Like
559
+ * model type. The contract keeps only the marker: Query Widget gives the pin
560
+ * the native schema, so model node outputs connect, and the SDK validates
561
+ * values against that schema.
562
+ */
563
+ function llmSchema(schema: JsonObject, context: string): JsonObject {
564
+ const kind = schema.llm;
565
+ if (!isLlmKind(kind)) {
566
+ throw new Error(
567
+ `Invalid @llm '${String(kind)}' for ${context}; expected History, Response or ResponseChunk`,
568
+ );
569
+ }
570
+ if (schema.type !== undefined && schema.type !== "object") {
571
+ throw new Error(
572
+ `@llm ${kind} for ${context} must annotate an object type; annotate the element type for arrays and maps`,
573
+ );
574
+ }
575
+ const result: JsonObject = {
576
+ type: "object",
577
+ "x-flow-like-type": "llm",
578
+ "x-llm": kind,
579
+ };
580
+ for (const key of [
581
+ "title",
582
+ "description",
583
+ "default",
584
+ "examples",
585
+ "deprecated",
586
+ ]) {
587
+ if (schema[key] !== undefined) result[key] = schema[key];
588
+ }
589
+ return result;
590
+ }
591
+
592
+ function resolveDefinition(
593
+ ref: string,
594
+ definitions: Record<string, JsonValue>,
595
+ context: string,
596
+ ): JsonValue {
597
+ const prefix = "#/definitions/";
598
+ if (!ref.startsWith(prefix)) {
599
+ throw new Error(
600
+ `Unsupported $ref '${ref}' while inlining the schema for ${context}`,
601
+ );
602
+ }
603
+ const encoded = ref.slice(prefix.length);
604
+ const definition =
605
+ definitions[decodeURIComponent(encoded)] ?? definitions[encoded];
606
+ if (definition === undefined) {
607
+ throw new Error(
608
+ `Unresolvable $ref '${ref}' while inlining the schema for ${context}`,
609
+ );
610
+ }
611
+ return definition;
612
+ }
613
+
614
+ /**
615
+ * Recursively resolve `#/definitions/...` refs so every emitted schema is
616
+ * standalone (the runtime validator does not support `$ref`). Fails on
617
+ * recursive types.
618
+ */
619
+ function inlineRefs(
620
+ value: JsonValue,
621
+ definitions: Record<string, JsonValue>,
622
+ stack: string[],
623
+ context: string,
624
+ schemaMap = false,
625
+ ): JsonValue {
626
+ if (Array.isArray(value)) {
627
+ return value.map((item) => inlineRefs(item, definitions, stack, context));
628
+ }
629
+ if (!isJsonObject(value)) return value;
630
+ if (schemaMap) {
631
+ return Object.fromEntries(
632
+ Object.entries(value).map(([key, entry]) => [
633
+ key,
634
+ inlineRefs(entry, definitions, stack, context),
635
+ ]),
636
+ );
637
+ }
638
+ if (value.geometry !== undefined) {
639
+ return geometrySchema(value, definitions, context);
640
+ }
641
+ if (value.llm !== undefined) {
642
+ return llmSchema(value, context);
643
+ }
644
+
645
+ const { $ref, $schema, definitions: _nested, ...rest } = value;
646
+ void $schema;
647
+ void _nested;
648
+
649
+ const inlinedRest: JsonObject = {};
650
+ for (const [key, entry] of Object.entries(rest)) {
651
+ // Annotation values are data, so a default containing "geometry" or
652
+ // "$ref" must not be interpreted as a schema.
653
+ inlinedRest[key] = ["default", "examples", "enum", "const"].includes(key)
654
+ ? entry
655
+ : inlineRefs(
656
+ entry,
657
+ definitions,
658
+ stack,
659
+ context,
660
+ ["properties", "patternProperties", "dependentSchemas"].includes(key),
661
+ );
662
+ }
663
+
664
+ if (typeof $ref !== "string") return inlinedRest;
665
+
666
+ const definition = resolveDefinition($ref, definitions, context);
667
+ const encoded = $ref.slice("#/definitions/".length);
668
+ const key = decodeURIComponent(encoded);
669
+ if (stack.includes(key)) {
670
+ throw new Error(
671
+ `Recursive type detected while inlining the schema for ${context} (cycle: ${[...stack, key].join(" -> ")}); widget contract schemas must be non-recursive`,
672
+ );
673
+ }
674
+ const inlinedDef = inlineRefs(
675
+ definition,
676
+ definitions,
677
+ [...stack, key],
678
+ context,
679
+ );
680
+ if (!isJsonObject(inlinedDef)) return inlinedDef;
681
+ return { ...inlinedDef, ...inlinedRest };
682
+ }
683
+
684
+ function extractInputs(
685
+ section: ResolvedSection,
686
+ schemaFor: (name: string) => JsonObject,
687
+ checker: ts.TypeChecker,
688
+ widgetId: string,
689
+ warnings: string[],
690
+ ): Record<string, ContractInput> {
691
+ const members = sectionMembers(section, "Inputs");
692
+ if (members.length === 0) return {};
693
+ const schema = schemaFor(section.name);
694
+ const properties = schemaProperties(schema);
695
+ const required = schemaRequired(schema);
696
+ const definitions = schemaDefinitions(schema);
697
+
698
+ const inputs: Record<string, ContractInput> = {};
699
+ for (const member of members) {
700
+ if (isVoidLike(member.typeNode)) {
701
+ throw new Error(
702
+ `Input '${member.name}' of widget '${widgetId}' cannot be void/undefined/never`,
703
+ );
704
+ }
705
+ const propertySchema = properties[member.name];
706
+ if (propertySchema === undefined) {
707
+ throw new Error(
708
+ `No schema was generated for input '${member.name}' of widget '${widgetId}'`,
709
+ );
710
+ }
711
+ const inlined = inlineRefs(
712
+ propertySchema,
713
+ definitions,
714
+ [],
715
+ `input '${member.name}' of widget '${widgetId}'`,
716
+ );
717
+ const optional = member.optional || !required.has(member.name);
718
+ const input = mapInputSchema(
719
+ isJsonObject(inlined) ? inlined : {},
720
+ optional,
721
+ );
722
+ if (input.default !== undefined && containsGeometrySchema(input.schema)) {
723
+ const result = validateInputValue(input, input.default);
724
+ if (!result.valid) {
725
+ throw new Error(
726
+ `Invalid @default for geometry input '${member.name}' of widget '${widgetId}': ${result.errors.join("; ")}`,
727
+ );
728
+ }
729
+ }
730
+ if (!optional && input.default === undefined) {
731
+ warnings.push(
732
+ `Input '${member.name}' of widget '${widgetId}' has no @default and is not optional; standalone dev and generated pin defaults will have no value`,
733
+ );
734
+ }
735
+ inputs[member.name] = input;
736
+ }
737
+ return inputs;
738
+ }
739
+
740
+ function containsGeometrySchema(value: JsonValue | undefined): boolean {
741
+ if (Array.isArray(value)) return value.some(containsGeometrySchema);
742
+ if (!isJsonObject(value)) return false;
743
+ if (value["x-flow-like-type"] === "geometry") return true;
744
+ return Object.entries(value).some(
745
+ ([key, entry]) =>
746
+ !["default", "examples", "enum", "const"].includes(key) &&
747
+ containsGeometrySchema(entry),
748
+ );
749
+ }
750
+
751
+ function mapInputSchema(schema: JsonObject, optional: boolean): ContractInput {
752
+ const type = schema.type;
753
+ const enumValues = Array.isArray(schema.enum) ? schema.enum : null;
754
+ const constValue = schema.const;
755
+
756
+ let input: ContractInput;
757
+ if (
758
+ type === "string" &&
759
+ enumValues &&
760
+ enumValues.length > 0 &&
761
+ enumValues.every((v): v is string => typeof v === "string")
762
+ ) {
763
+ input = { type: "enum", choices: enumValues };
764
+ } else if (type === "string" && typeof constValue === "string") {
765
+ input = { type: "enum", choices: [constValue] };
766
+ } else if (type === "string" && !enumValues) {
767
+ input = { type: "string" };
768
+ } else if (type === "boolean" && !enumValues && constValue === undefined) {
769
+ input = { type: "boolean" };
770
+ } else if (type === "integer" && !enumValues && constValue === undefined) {
771
+ input = { type: "integer" };
772
+ } else if (type === "number" && !enumValues && constValue === undefined) {
773
+ input = { type: "number" };
774
+ } else {
775
+ input = { type: "json", schema };
776
+ }
777
+
778
+ if (typeof schema.description === "string") {
779
+ input.description = schema.description;
780
+ }
781
+ if (schema.default !== undefined) {
782
+ input.default = schema.default;
783
+ }
784
+ if (input.type === "number" || input.type === "integer") {
785
+ if (typeof schema.minimum === "number") input.min = schema.minimum;
786
+ if (typeof schema.maximum === "number") input.max = schema.maximum;
787
+ }
788
+ if (optional) input.optional = true;
789
+ return input;
790
+ }
791
+
792
+ function extractEvents(
793
+ section: ResolvedSection,
794
+ schemaFor: (name: string) => JsonObject,
795
+ checker: ts.TypeChecker,
796
+ ): Record<string, ContractEvent> {
797
+ const members = sectionMembers(section, "Events");
798
+ if (members.length === 0) return {};
799
+ const needsSchema = members.some((m) => !isVoidLike(m.typeNode));
800
+ const schema = needsSchema ? schemaFor(section.name) : {};
801
+ const properties = schemaProperties(schema);
802
+ const definitions = schemaDefinitions(schema);
803
+
804
+ const events: Record<string, ContractEvent> = {};
805
+ for (const member of members) {
806
+ const description = memberDescription(member.node);
807
+ if (isVoidLike(member.typeNode)) {
808
+ events[member.name] = {
809
+ payloadSchema: null,
810
+ ...(description !== undefined && { description }),
811
+ };
812
+ continue;
813
+ }
814
+ const propertySchema = properties[member.name];
815
+ if (propertySchema === undefined) {
816
+ throw new Error(
817
+ `No payload schema was generated for event '${member.name}'`,
818
+ );
819
+ }
820
+ let payloadSchema = inlineRefs(
821
+ propertySchema,
822
+ definitions,
823
+ [],
824
+ `event '${member.name}'`,
825
+ );
826
+ if (
827
+ description !== undefined &&
828
+ isJsonObject(payloadSchema) &&
829
+ payloadSchema.description === description
830
+ ) {
831
+ const { description: _lifted, ...rest } = payloadSchema;
832
+ payloadSchema = rest;
833
+ }
834
+ events[member.name] = {
835
+ payloadSchema: requireSchemaObject(
836
+ payloadSchema,
837
+ `event '${member.name}'`,
838
+ ),
839
+ ...(description !== undefined && { description }),
840
+ };
841
+ }
842
+ return events;
843
+ }
844
+
845
+ function extractQueries(
846
+ section: ResolvedSection,
847
+ schemaFor: (name: string) => JsonObject,
848
+ checker: ts.TypeChecker,
849
+ ): Record<string, ContractQuery> {
850
+ const members = sectionMembers(section, "Queries");
851
+ if (members.length === 0) return {};
852
+ const schema = schemaFor(section.name);
853
+ const properties = schemaProperties(schema);
854
+ const definitions = schemaDefinitions(schema);
855
+
856
+ const queries: Record<string, ContractQuery> = {};
857
+ for (const member of members) {
858
+ const shape = queryShape(member, checker);
859
+ const description = memberDescription(member.node);
860
+ const mutation = memberHasTag(member.node, "mutation");
861
+ const inlined = inlineRefs(
862
+ properties[member.name] ?? {},
863
+ definitions,
864
+ [],
865
+ `query '${member.name}'`,
866
+ );
867
+ const queryProperties = isJsonObject(inlined)
868
+ ? schemaProperties(inlined)
869
+ : {};
870
+
871
+ const argsSchema = shape.args === null ? null : queryProperties.args;
872
+ if (argsSchema === undefined) {
873
+ throw new Error(
874
+ `No args schema was generated for query '${member.name}'`,
875
+ );
876
+ }
877
+ const resultSchema =
878
+ shape.returns === null ? null : queryProperties.returns;
879
+ if (resultSchema === undefined) {
880
+ throw new Error(
881
+ `No result schema was generated for query '${member.name}'`,
882
+ );
883
+ }
884
+
885
+ queries[member.name] = {
886
+ argsSchema:
887
+ argsSchema === null
888
+ ? null
889
+ : requireSchemaObject(argsSchema, `query '${member.name}' args`),
890
+ resultSchema:
891
+ resultSchema === null
892
+ ? null
893
+ : requireSchemaObject(resultSchema, `query '${member.name}' result`),
894
+ ...(description !== undefined && { description }),
895
+ ...(mutation && { mutation: true }),
896
+ };
897
+ }
898
+ return queries;
899
+ }
900
+
901
+ interface QueryShape {
902
+ /** `null` when `args: void` (or the member is missing) */
903
+ args: ts.TypeNode | null;
904
+ /** `null` when `returns: void` */
905
+ returns: ts.TypeNode | null;
906
+ }
907
+
908
+ function queryShape(
909
+ member: SectionMember,
910
+ checker: ts.TypeChecker,
911
+ ): QueryShape {
912
+ let literal: ts.TypeLiteralNode | null = null;
913
+ const typeNode = member.typeNode;
914
+ if (ts.isTypeLiteralNode(typeNode)) {
915
+ literal = typeNode;
916
+ } else if (ts.isTypeReferenceNode(typeNode)) {
917
+ const declaration = resolveTypeDeclaration(typeNode.typeName, checker);
918
+ if (declaration && ts.isTypeAliasDeclaration(declaration)) {
919
+ const aliased = unwrapTypeNode(declaration.type);
920
+ if (ts.isTypeLiteralNode(aliased)) literal = aliased;
921
+ } else if (declaration && ts.isInterfaceDeclaration(declaration)) {
922
+ return interfaceQueryShape(member.name, declaration);
923
+ }
924
+ }
925
+ if (!literal) {
926
+ throw new Error(
927
+ `Query '${member.name}' must be declared as \`{ args: ...; returns: ... }\``,
928
+ );
929
+ }
930
+ return literalQueryShape(member.name, literal.members);
931
+ }
932
+
933
+ function interfaceQueryShape(
934
+ queryName: string,
935
+ declaration: ts.InterfaceDeclaration,
936
+ ): QueryShape {
937
+ return literalQueryShape(queryName, declaration.members);
938
+ }
939
+
940
+ function literalQueryShape(
941
+ queryName: string,
942
+ members: ts.NodeArray<ts.TypeElement>,
943
+ ): QueryShape {
944
+ let args: ts.TypeNode | null = null;
945
+ let returns: ts.TypeNode | null | undefined;
946
+ for (const member of members) {
947
+ if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) {
948
+ continue;
949
+ }
950
+ if (!member.type) continue;
951
+ const type = unwrapTypeNode(member.type);
952
+ if (member.name.text === "args") {
953
+ args = isVoidLike(type) ? null : type;
954
+ } else if (member.name.text === "returns") {
955
+ returns = isVoidLike(type) ? null : type;
956
+ }
957
+ }
958
+ if (returns === undefined) {
959
+ throw new Error(
960
+ `Query '${queryName}' must declare a 'returns' member (\`{ args: ...; returns: ... }\`)`,
961
+ );
962
+ }
963
+ return { args, returns };
964
+ }
965
+
966
+ function evaluateObjectLiteral(
967
+ obj: ts.ObjectLiteralExpression,
968
+ path: string,
969
+ ): JsonObject {
970
+ const out: JsonObject = {};
971
+ for (const property of obj.properties) {
972
+ if (!ts.isPropertyAssignment(property)) {
973
+ throw new Error(
974
+ `Widget config${path ? ` property '${path}'` : ""} may only contain plain \`key: value\` literal assignments (no spreads, shorthands, or methods)`,
975
+ );
976
+ }
977
+ const name =
978
+ ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)
979
+ ? property.name.text
980
+ : null;
981
+ if (name === null) {
982
+ throw new Error(
983
+ `Widget config${path ? ` property '${path}'` : ""} contains a computed property name`,
984
+ );
985
+ }
986
+ const propertyPath = path ? `${path}.${name}` : name;
987
+ out[name] = evaluateExpression(property.initializer, propertyPath);
988
+ }
989
+ return out;
990
+ }
991
+
992
+ function evaluateExpression(expr: ts.Expression, path: string): JsonValue {
993
+ const e = unwrapExpression(expr);
994
+ if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) {
995
+ return e.text;
996
+ }
997
+ if (ts.isNumericLiteral(e)) return Number(e.text);
998
+ if (
999
+ ts.isPrefixUnaryExpression(e) &&
1000
+ e.operator === ts.SyntaxKind.MinusToken &&
1001
+ ts.isNumericLiteral(e.operand)
1002
+ ) {
1003
+ return -Number(e.operand.text);
1004
+ }
1005
+ if (e.kind === ts.SyntaxKind.TrueKeyword) return true;
1006
+ if (e.kind === ts.SyntaxKind.FalseKeyword) return false;
1007
+ if (e.kind === ts.SyntaxKind.NullKeyword) return null;
1008
+ if (ts.isArrayLiteralExpression(e)) {
1009
+ return e.elements.map((element, index) =>
1010
+ evaluateExpression(element, `${path}[${index}]`),
1011
+ );
1012
+ }
1013
+ if (ts.isObjectLiteralExpression(e)) {
1014
+ return evaluateObjectLiteral(e, path);
1015
+ }
1016
+ throw new Error(
1017
+ `Widget config property '${path}' must be a literal (string, number, boolean, null, array, or object); computed expressions are not supported`,
1018
+ );
1019
+ }
1020
+
1021
+ function readWidgetConfig(
1022
+ cfg: JsonObject,
1023
+ path: string,
1024
+ ): ExtractedWidgetConfig {
1025
+ const id = cfg.id;
1026
+ if (typeof id !== "string" || id.length === 0) {
1027
+ throw new Error(`Widget config in ${path} must declare a string 'id'`);
1028
+ }
1029
+ const name = cfg.name;
1030
+ if (typeof name !== "string" || name.length === 0) {
1031
+ throw new Error(`Widget config in ${path} must declare a string 'name'`);
1032
+ }
1033
+ const description =
1034
+ typeof cfg.description === "string" ? cfg.description : "";
1035
+
1036
+ let sizing: WidgetSizingConfig | undefined;
1037
+ if (cfg.sizing !== undefined) {
1038
+ if (!isJsonObject(cfg.sizing)) {
1039
+ throw new Error(`Widget config 'sizing' in ${path} must be an object`);
1040
+ }
1041
+ sizing = {};
1042
+ const { defaultHeight, resizable, maxHeight } = cfg.sizing;
1043
+ if (defaultHeight !== undefined) {
1044
+ if (typeof defaultHeight !== "number") {
1045
+ throw new Error(`'sizing.defaultHeight' in ${path} must be a number`);
1046
+ }
1047
+ sizing.defaultHeight = defaultHeight;
1048
+ }
1049
+ if (resizable !== undefined) {
1050
+ if (typeof resizable !== "boolean") {
1051
+ throw new Error(`'sizing.resizable' in ${path} must be a boolean`);
1052
+ }
1053
+ sizing.resizable = resizable;
1054
+ }
1055
+ if (maxHeight !== undefined) {
1056
+ if (typeof maxHeight !== "number") {
1057
+ throw new Error(`'sizing.maxHeight' in ${path} must be a number`);
1058
+ }
1059
+ sizing.maxHeight = maxHeight;
1060
+ }
1061
+ }
1062
+
1063
+ let capabilities: WidgetCapabilities | undefined;
1064
+ if (cfg.capabilities !== undefined) {
1065
+ if (!isJsonObject(cfg.capabilities))
1066
+ throw new Error(`Widget capabilities in ${path} must be an object`);
1067
+ capabilities = {};
1068
+ for (const [key, value] of Object.entries(cfg.capabilities)) {
1069
+ if (
1070
+ !["workers", "media", "microphone", "downloads", "wasm"].includes(
1071
+ key,
1072
+ ) ||
1073
+ typeof value !== "boolean"
1074
+ ) {
1075
+ throw new Error(`Invalid widget capability '${key}' in ${path}`);
1076
+ }
1077
+ capabilities[key as keyof WidgetCapabilities] = value;
1078
+ }
1079
+ }
1080
+
1081
+ const csp =
1082
+ cfg.csp === undefined ? undefined : readWidgetCsp(cfg.csp, id, path);
1083
+
1084
+ let fixtures: Record<string, JsonValue> | undefined;
1085
+ if (isJsonObject(cfg.dev) && cfg.dev.fixtures !== undefined) {
1086
+ if (!isJsonObject(cfg.dev.fixtures)) {
1087
+ throw new Error(
1088
+ `Widget config 'dev.fixtures' in ${path} must be an object`,
1089
+ );
1090
+ }
1091
+ fixtures = cfg.dev.fixtures;
1092
+ }
1093
+
1094
+ return {
1095
+ id,
1096
+ name,
1097
+ description,
1098
+ ...(sizing !== undefined && { sizing }),
1099
+ ...(capabilities !== undefined && { capabilities }),
1100
+ ...(csp !== undefined && { csp }),
1101
+ ...(fixtures !== undefined && { fixtures }),
1102
+ };
1103
+ }
1104
+
1105
+ const NETWORK_INPUT_KEYS = ["path", "directives", "template"];
1106
+ const TEMPLATE_KEYS = ["subdomains", "subdomainsInput"];
1107
+
1108
+ function invalidCsp(
1109
+ kind: string,
1110
+ value: JsonValue | undefined,
1111
+ location: string,
1112
+ id: string,
1113
+ reason: string,
1114
+ ): Error {
1115
+ return new Error(
1116
+ `Invalid widget csp ${kind} ${JSON.stringify(value)} in ${location} for widget ${id}: ${reason}`,
1117
+ );
1118
+ }
1119
+
1120
+ function invalidCspShape(
1121
+ location: string,
1122
+ id: string,
1123
+ expectation: string,
1124
+ ): Error {
1125
+ return new Error(
1126
+ `Invalid widget csp ${location} for widget ${id}: must be ${expectation}`,
1127
+ );
1128
+ }
1129
+
1130
+ function rejectUnknownKeys(
1131
+ value: JsonObject,
1132
+ allowed: readonly string[],
1133
+ location: string,
1134
+ id: string,
1135
+ ): void {
1136
+ for (const key of Object.keys(value)) {
1137
+ if (!allowed.includes(key)) {
1138
+ throw invalidCsp(
1139
+ "key",
1140
+ key,
1141
+ location,
1142
+ id,
1143
+ `allowed keys are ${allowed.join(", ")}`,
1144
+ );
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ function readStringList(
1150
+ value: JsonValue,
1151
+ location: string,
1152
+ id: string,
1153
+ kind: string,
1154
+ ): string[] {
1155
+ if (!Array.isArray(value)) {
1156
+ throw invalidCspShape(location, id, "an array of string literals");
1157
+ }
1158
+ return value.map((entry) => {
1159
+ if (typeof entry !== "string") {
1160
+ throw invalidCsp(kind, entry, location, id, "must be a string");
1161
+ }
1162
+ return entry;
1163
+ });
1164
+ }
1165
+
1166
+ function readCspSources(
1167
+ value: JsonValue,
1168
+ directive: WidgetCspDirective,
1169
+ location: string,
1170
+ id: string,
1171
+ ): string[] {
1172
+ return readStringList(value, location, id, "source").map((source) => {
1173
+ const normalized = normalizeCspSource(source);
1174
+ const rejection = validateCspSource(directive, normalized);
1175
+ if (rejection !== null) {
1176
+ throw invalidCsp(
1177
+ "source",
1178
+ source,
1179
+ location,
1180
+ id,
1181
+ CSP_SOURCE_REJECTION_MESSAGES[rejection],
1182
+ );
1183
+ }
1184
+ if (validateWildcardBases([normalized]).length > 0) {
1185
+ throw invalidCsp(
1186
+ "source",
1187
+ source,
1188
+ location,
1189
+ id,
1190
+ WILDCARD_PUBLIC_SUFFIX_MESSAGE,
1191
+ );
1192
+ }
1193
+ return source;
1194
+ });
1195
+ }
1196
+
1197
+ function readUrlTemplate(
1198
+ value: JsonValue,
1199
+ location: string,
1200
+ id: string,
1201
+ ): WidgetUrlTemplate {
1202
+ if (!isJsonObject(value)) {
1203
+ throw invalidCspShape(
1204
+ location,
1205
+ id,
1206
+ "an object ({ subdomains?, subdomainsInput? })",
1207
+ );
1208
+ }
1209
+ rejectUnknownKeys(value, TEMPLATE_KEYS, location, id);
1210
+ const template: WidgetUrlTemplate = {};
1211
+ if (value.subdomains !== undefined) {
1212
+ template.subdomains = readStringList(
1213
+ value.subdomains,
1214
+ `${location}.subdomains`,
1215
+ id,
1216
+ "subdomain",
1217
+ );
1218
+ }
1219
+ if (value.subdomainsInput !== undefined) {
1220
+ if (typeof value.subdomainsInput !== "string") {
1221
+ throw invalidCsp(
1222
+ "subdomainsInput",
1223
+ value.subdomainsInput,
1224
+ `${location}.subdomainsInput`,
1225
+ id,
1226
+ "must be a string naming a widget input",
1227
+ );
1228
+ }
1229
+ template.subdomainsInput = value.subdomainsInput;
1230
+ }
1231
+ return template;
1232
+ }
1233
+
1234
+ function readNetworkInput(
1235
+ value: JsonValue,
1236
+ location: string,
1237
+ id: string,
1238
+ ): WidgetNetworkInput {
1239
+ if (!isJsonObject(value)) {
1240
+ throw invalidCspShape(
1241
+ location,
1242
+ id,
1243
+ "an object ({ path, directives, template? })",
1244
+ );
1245
+ }
1246
+ rejectUnknownKeys(value, NETWORK_INPUT_KEYS, location, id);
1247
+ if (typeof value.path !== "string") {
1248
+ throw invalidCsp(
1249
+ "input path",
1250
+ value.path,
1251
+ `${location}.path`,
1252
+ id,
1253
+ "must be a string such as tileUrl or layers[].url",
1254
+ );
1255
+ }
1256
+ const directives = readStringList(
1257
+ value.directives ?? null,
1258
+ `${location}.directives`,
1259
+ id,
1260
+ "directive",
1261
+ ).map((directive) => {
1262
+ if (!isCspDirective(directive)) {
1263
+ throw invalidCsp(
1264
+ "directive",
1265
+ directive,
1266
+ `${location}.directives`,
1267
+ id,
1268
+ `only ${CSP_DIRECTIVES.join(", ")} can be extended`,
1269
+ );
1270
+ }
1271
+ return directive;
1272
+ });
1273
+ return {
1274
+ path: value.path,
1275
+ directives,
1276
+ ...(value.template !== undefined && {
1277
+ template: readUrlTemplate(value.template, `${location}.template`, id),
1278
+ }),
1279
+ };
1280
+ }
1281
+
1282
+ function readCspPurpose(
1283
+ value: JsonValue,
1284
+ location: string,
1285
+ id: string,
1286
+ ): WidgetCspPurpose {
1287
+ if (!isJsonObject(value)) {
1288
+ throw invalidCspShape(
1289
+ location,
1290
+ id,
1291
+ "an object ({ reason, connectSrc?, imgSrc?, fontSrc?, mediaSrc?, styleSrc?, inputs? })",
1292
+ );
1293
+ }
1294
+ for (const key of Object.keys(value)) {
1295
+ if (!(CSP_PURPOSE_KEYS as readonly string[]).includes(key)) {
1296
+ throw invalidCsp(
1297
+ "key",
1298
+ key,
1299
+ location,
1300
+ id,
1301
+ `only reason, inputs and the directives ${CSP_DIRECTIVES.join(", ")} are allowed; scripts, frames and workers stay limited to the bundle`,
1302
+ );
1303
+ }
1304
+ }
1305
+ if (typeof value.reason !== "string") {
1306
+ throw invalidCspShape(
1307
+ location,
1308
+ id,
1309
+ "an object with a string 'reason' that tells the viewer why the widget needs these sources",
1310
+ );
1311
+ }
1312
+ const purpose: WidgetCspPurpose = { reason: value.reason };
1313
+ for (const directive of CSP_DIRECTIVES) {
1314
+ const sources = value[directive];
1315
+ if (sources === undefined) continue;
1316
+ purpose[directive] = readCspSources(
1317
+ sources,
1318
+ directive,
1319
+ `${location}.${directive}`,
1320
+ id,
1321
+ );
1322
+ }
1323
+ if (value.inputs !== undefined) {
1324
+ if (!Array.isArray(value.inputs)) {
1325
+ throw invalidCspShape(
1326
+ `${location}.inputs`,
1327
+ id,
1328
+ "an array of { path, directives, template? }",
1329
+ );
1330
+ }
1331
+ purpose.inputs = value.inputs.map((input, index) =>
1332
+ readNetworkInput(input, `${location}.inputs[${index}]`, id),
1333
+ );
1334
+ }
1335
+ return purpose;
1336
+ }
1337
+
1338
+ function checkCspReasons(purposes: readonly WidgetCspPurpose[], id: string) {
1339
+ const owners = new Map<string, number>();
1340
+ purposes.forEach((purpose, index) => {
1341
+ const location = `csp[${index}].reason`;
1342
+ const rejection =
1343
+ validateWidgetCspReason(
1344
+ purpose.reason,
1345
+ (purpose.inputs?.length ?? 0) > 0,
1346
+ ) ??
1347
+ (reasonContainsAddress(purpose.reason)
1348
+ ? "reason-contains-address"
1349
+ : null);
1350
+ if (rejection !== null) {
1351
+ throw invalidCsp(
1352
+ "reason",
1353
+ purpose.reason,
1354
+ location,
1355
+ id,
1356
+ cspReasonProblem(rejection),
1357
+ );
1358
+ }
1359
+ const folded = foldWidgetCspReason(purpose.reason);
1360
+ const first = owners.get(folded);
1361
+ if (first !== undefined) {
1362
+ throw invalidCsp(
1363
+ "reason",
1364
+ purpose.reason,
1365
+ location,
1366
+ id,
1367
+ `${cspReasonProblem("reason-duplicate")}; csp[${first}].reason reads the same`,
1368
+ );
1369
+ }
1370
+ owners.set(folded, index);
1371
+ });
1372
+ }
1373
+
1374
+ /**
1375
+ * Reads `csp` as an array of purpose groups made of string literals,
1376
+ * normalizes it (reasons NFC with collapsed whitespace, sources lowercased
1377
+ * and punycoded, lists sorted and deduplicated, inputs sorted by path) and
1378
+ * rejects sources, wildcard bases and reasons the hub publish would refuse.
1379
+ */
1380
+ function readWidgetCsp(
1381
+ value: JsonValue,
1382
+ id: string,
1383
+ path: string,
1384
+ ): WidgetCspPurpose[] | undefined {
1385
+ if (!Array.isArray(value)) {
1386
+ throw invalidCspShape(
1387
+ `in ${path}`,
1388
+ id,
1389
+ 'an array of purpose groups, e.g. csp: [{ reason: "Loads map tiles from MapTiler", connectSrc: ["https://api.maptiler.com"] }]',
1390
+ );
1391
+ }
1392
+ const purposes = normalizeCspPurposes(
1393
+ value.map((purpose, index) => readCspPurpose(purpose, `csp[${index}]`, id)),
1394
+ );
1395
+ checkCspReasons(purposes, id);
1396
+ return purposes.length > 0 ? purposes : undefined;
1397
+ }
1398
+
1399
+ interface NetworkInputSlot {
1400
+ purpose: number;
1401
+ input: WidgetNetworkInput;
1402
+ }
1403
+
1404
+ function networkInputSlots(contract: WidgetContract): NetworkInputSlot[] {
1405
+ return (contract.csp ?? []).flatMap((purpose, index) =>
1406
+ (purpose.inputs ?? []).map((input) => ({ purpose: index, input })),
1407
+ );
1408
+ }
1409
+
1410
+ function schemaBranches(schema: JsonObject): JsonObject[] {
1411
+ const branches = [schema];
1412
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
1413
+ const list = schema[key];
1414
+ if (!Array.isArray(list)) continue;
1415
+ for (const branch of list) {
1416
+ if (isJsonObject(branch)) branches.push(...schemaBranches(branch));
1417
+ }
1418
+ }
1419
+ return branches;
1420
+ }
1421
+
1422
+ function schemaHasType(schema: JsonObject, type: string): boolean {
1423
+ return (
1424
+ schema.type === type ||
1425
+ (Array.isArray(schema.type) && schema.type.includes(type))
1426
+ );
1427
+ }
1428
+
1429
+ function schemaValueChildren(schema: JsonObject): JsonObject[] {
1430
+ const children: JsonObject[] = [];
1431
+ if (isJsonObject(schema.additionalProperties)) {
1432
+ children.push(schema.additionalProperties);
1433
+ }
1434
+ if (isJsonObject(schema.patternProperties)) {
1435
+ children.push(
1436
+ ...Object.values(schema.patternProperties).filter(isJsonObject),
1437
+ );
1438
+ }
1439
+ return children;
1440
+ }
1441
+
1442
+ function schemaChildren(
1443
+ schema: JsonObject,
1444
+ segment: WidgetInputPathSegment,
1445
+ ): JsonObject[] {
1446
+ switch (segment.kind) {
1447
+ case "key": {
1448
+ const property = schemaProperties(schema)[segment.key];
1449
+ return isJsonObject(property) ? [property] : schemaValueChildren(schema);
1450
+ }
1451
+ case "items": {
1452
+ if (!schemaHasType(schema, "array")) return [];
1453
+ const items = [schema.items, schema.prefixItems].flatMap((entry) =>
1454
+ Array.isArray(entry) ? entry : [entry],
1455
+ );
1456
+ return items.filter(isJsonObject);
1457
+ }
1458
+ case "values":
1459
+ return schemaValueChildren(schema);
1460
+ }
1461
+ }
1462
+
1463
+ function reachesString(
1464
+ schema: JsonObject,
1465
+ segments: readonly WidgetInputPathSegment[],
1466
+ ): boolean {
1467
+ const [segment, ...rest] = segments;
1468
+ return schemaBranches(schema).some((branch) =>
1469
+ segment === undefined
1470
+ ? schemaHasType(branch, "string")
1471
+ : schemaChildren(branch, segment).some((child) =>
1472
+ reachesString(child, rest),
1473
+ ),
1474
+ );
1475
+ }
1476
+
1477
+ /**
1478
+ * Bundler-only slot check (§14.2.3 rule 5): each network input path must
1479
+ * resolve to `type: string` through the generated input schema, with `[]` on
1480
+ * arrays and `.*` on objects with `additionalProperties` or
1481
+ * `patternProperties`. Roots the contract rules reject are skipped.
1482
+ */
1483
+ export function networkInputSchemaErrors(contract: WidgetContract): string[] {
1484
+ const errors: string[] = [];
1485
+ for (const { purpose, input } of networkInputSlots(contract)) {
1486
+ const parsed = parseWidgetInputPath(input.path);
1487
+ if (parsed === null) continue;
1488
+ const inputs = contract.inputs ?? {};
1489
+ const root = Object.hasOwn(inputs, parsed.root)
1490
+ ? inputs[parsed.root]
1491
+ : undefined;
1492
+ if (root === undefined) continue;
1493
+ const schema: JsonObject =
1494
+ root.type === "string" ? { type: "string" } : (root.schema ?? {});
1495
+ if (
1496
+ (root.type === "string" || root.type === "json") &&
1497
+ !reachesString(schema, parsed.segments)
1498
+ ) {
1499
+ errors.push(
1500
+ `Widget '${contract.id}': csp purpose ${purpose}: input "${input.path}" does not reach a string through the schema of input "${parsed.root}" ("[]" needs an array, ".*" an object with additionalProperties or patternProperties)`,
1501
+ );
1502
+ }
1503
+ }
1504
+ return errors;
1505
+ }
1506
+
1507
+ function valuesAtPath(
1508
+ value: JsonValue | undefined,
1509
+ segments: readonly WidgetInputPathSegment[],
1510
+ ): string[] {
1511
+ let current: JsonValue[] = value === undefined ? [] : [value];
1512
+ for (const segment of segments) {
1513
+ current = current.flatMap((entry): JsonValue[] => {
1514
+ if (segment.kind === "items") return Array.isArray(entry) ? entry : [];
1515
+ if (!isJsonObject(entry)) return [];
1516
+ if (segment.kind === "values") return Object.values(entry);
1517
+ const child = Object.hasOwn(entry, segment.key)
1518
+ ? entry[segment.key]
1519
+ : undefined;
1520
+ return child === undefined ? [] : [child];
1521
+ });
1522
+ }
1523
+ return current.filter((entry): entry is string => typeof entry === "string");
1524
+ }
1525
+
1526
+ const URL_AUTHORITY = /^([a-z][a-z0-9+.-]*):\/\/([^/\\?#]*)/i;
1527
+
1528
+ function templateSubdomains(
1529
+ contract: WidgetContract,
1530
+ template: WidgetUrlTemplate | undefined,
1531
+ ): string[] {
1532
+ if (!template) return [];
1533
+ if ((template.subdomains?.length ?? 0) > 0) return template.subdomains ?? [];
1534
+ const name = template.subdomainsInput;
1535
+ const inputs = contract.inputs ?? {};
1536
+ const fallback =
1537
+ name !== undefined && Object.hasOwn(inputs, name)
1538
+ ? inputs[name]?.default
1539
+ : undefined;
1540
+ if (typeof fallback === "string") return Array.from(fallback);
1541
+ return Array.isArray(fallback)
1542
+ ? fallback.filter((entry): entry is string => typeof entry === "string")
1543
+ : [];
1544
+ }
1545
+
1546
+ /** Origins a default URL resolves to; `{s}` expands, other placeholders stay. */
1547
+ function defaultUrlOrigins(
1548
+ value: string,
1549
+ subdomains: readonly string[],
1550
+ ): string[] {
1551
+ const match = URL_AUTHORITY.exec(value.trim());
1552
+ const scheme = match?.[1]?.toLowerCase();
1553
+ if (!match || (scheme !== "https" && scheme !== "wss")) return [];
1554
+ const authority = match[2] ?? "";
1555
+ const host = authority
1556
+ .slice(authority.lastIndexOf("@") + 1)
1557
+ .replace(/:\d*$/, "")
1558
+ .replace(/\.$/, "");
1559
+ if (host === "" || host.startsWith("[")) return [];
1560
+ const hosts =
1561
+ host.includes("{s}") && subdomains.length > 0
1562
+ ? subdomains.map((label) => host.replaceAll("{s}", label))
1563
+ : [host];
1564
+ return hosts.map((entry) => normalizeCspSource(`${scheme}://${entry}`));
1565
+ }
1566
+
1567
+ function originDeclared(origin: string, sources: readonly string[]): boolean {
1568
+ const separator = origin.indexOf("://");
1569
+ const scheme = origin.slice(0, separator);
1570
+ const host = origin.slice(separator + 3);
1571
+ return sources.some((source) => {
1572
+ if (source === origin) return true;
1573
+ const prefix = `${scheme}://*.`;
1574
+ return (
1575
+ source.startsWith(prefix) &&
1576
+ host.endsWith(`.${source.slice(prefix.length)}`)
1577
+ );
1578
+ });
1579
+ }
1580
+
1581
+ /**
1582
+ * Bundler-only slot lints (§14.2.3 rule 5): an input that feeds `mediaSrc`
1583
+ * without `connectSrc`, and a `@default` URL whose origin no static source
1584
+ * declares (the SDK merges defaults inside the widget, so the host never
1585
+ * approves them).
1586
+ */
1587
+ export function networkInputWarnings(contract: WidgetContract): string[] {
1588
+ const warnings: string[] = [];
1589
+ const declared = flattenCspPurposes(contract.csp ?? []);
1590
+ for (const { purpose, input } of networkInputSlots(contract)) {
1591
+ const prefix = `Widget '${contract.id}': csp purpose ${purpose}: input "${input.path}"`;
1592
+ if (
1593
+ input.directives.includes("mediaSrc") &&
1594
+ !input.directives.includes("connectSrc")
1595
+ ) {
1596
+ warnings.push(
1597
+ `${prefix} feeds mediaSrc without connectSrc; hls.js and MSE players fetch through connectSrc, native HLS uses mediaSrc`,
1598
+ );
1599
+ }
1600
+ const parsed = parseWidgetInputPath(input.path);
1601
+ const inputs = contract.inputs ?? {};
1602
+ if (parsed === null || !Object.hasOwn(inputs, parsed.root)) continue;
1603
+ const subdomains = templateSubdomains(contract, input.template);
1604
+ const origins = new Set(
1605
+ valuesAtPath(inputs[parsed.root]?.default, parsed.segments).flatMap(
1606
+ (value) => defaultUrlOrigins(value, subdomains),
1607
+ ),
1608
+ );
1609
+ for (const origin of origins) {
1610
+ const missing = input.directives.filter(
1611
+ (directive) => !originDeclared(origin, declared[directive] ?? []),
1612
+ );
1613
+ if (missing.length === 0) continue;
1614
+ warnings.push(
1615
+ `${prefix} has a @default URL on ${origin} that no static ${missing.join(", ")} source declares; the SDK merges defaults inside the widget, so the host never sees or approves them. Declare the origin statically or send the URL as an input value`,
1616
+ );
1617
+ }
1618
+ }
1619
+ return warnings;
1620
+ }