@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.
@@ -0,0 +1,759 @@
1
+ // Producer-side (strict, canonical) mirror of packages/wasm/schema/src/widget.rs
2
+ // and of the purpose-group rules in widget_policy.rs;
3
+ // @flow-like/widget-sdk ships the consumer-side (loose) mirror. The
4
+ // assignability assertion at the bottom keeps the two from drifting.
5
+
6
+ import type {
7
+ WidgetContract as SdkWidgetContract,
8
+ WidgetCapabilities,
9
+ WidgetCspPurpose,
10
+ WidgetNetworkInput,
11
+ WidgetUrlTemplate,
12
+ } from "@flow-like/widget-sdk";
13
+ import {
14
+ type WidgetCspReasonRejection,
15
+ cspReasonProblem,
16
+ foldWidgetCspReason,
17
+ normalizeCspReason,
18
+ reasonContainsAddress,
19
+ validateWidgetCspReason,
20
+ } from "./csp-reason";
21
+ import {
22
+ CSP_DIRECTIVES,
23
+ CSP_NETWORK_INPUT_KEYS,
24
+ CSP_PURPOSE_KEYS,
25
+ CSP_SOURCE_REJECTION_MESSAGES,
26
+ CSP_TEMPLATE_KEYS,
27
+ HOST_RESERVED_INPUT_KEYS,
28
+ MAX_WIDGET_CSP_PURPOSES,
29
+ MAX_WIDGET_CSP_SOURCES,
30
+ MAX_WIDGET_CSP_SOURCE_BYTES,
31
+ MAX_WIDGET_INPUT_PATH_SEGMENTS,
32
+ MAX_WIDGET_NETWORK_INPUTS,
33
+ MAX_WIDGET_TEMPLATE_SUBDOMAINS,
34
+ canonicalCspSources,
35
+ compareCspDirectives,
36
+ compareCspSources,
37
+ cspSourceBytes,
38
+ cspSourceCount,
39
+ flattenCspPurposes,
40
+ isCspDirective,
41
+ isDnsLabel,
42
+ isStrictlyAscending,
43
+ normalizeCspSource,
44
+ parseWidgetInputPath,
45
+ validateCspSource,
46
+ } from "./csp-source";
47
+ import { validateWildcardBases } from "./psl";
48
+
49
+ export type JsonValue =
50
+ | string
51
+ | number
52
+ | boolean
53
+ | null
54
+ | JsonValue[]
55
+ | { [key: string]: JsonValue };
56
+
57
+ export type JsonObject = { [key: string]: JsonValue };
58
+
59
+ /** Current contract version; contracts declaring `csp` must use it */
60
+ export const CONTRACT_VERSION = 2;
61
+ /** Version of contracts without `csp`, readable by hosts that predate `csp` */
62
+ export const BASE_CONTRACT_VERSION = 1;
63
+ export const WIDGET_PROTOCOL = "flw/1";
64
+
65
+ export type ContractInputType =
66
+ | "string"
67
+ | "number"
68
+ | "integer"
69
+ | "boolean"
70
+ | "enum"
71
+ | "json";
72
+
73
+ export interface ContractInput {
74
+ type: ContractInputType;
75
+ description?: string;
76
+ default?: JsonValue;
77
+ choices?: string[];
78
+ min?: number;
79
+ max?: number;
80
+ schema?: JsonObject;
81
+ optional?: boolean;
82
+ }
83
+
84
+ export interface ContractEvent {
85
+ payloadSchema: JsonObject | null;
86
+ description?: string;
87
+ }
88
+
89
+ export interface ContractQuery {
90
+ argsSchema: JsonObject | null;
91
+ resultSchema: JsonObject | null;
92
+ description?: string;
93
+ mutation?: boolean;
94
+ }
95
+
96
+ export interface WidgetSizing {
97
+ defaultHeight: number;
98
+ resizable: boolean;
99
+ maxHeight?: number;
100
+ }
101
+
102
+ export interface WidgetContract {
103
+ contractVersion: number;
104
+ id: string;
105
+ inputs: Record<string, ContractInput>;
106
+ events: Record<string, ContractEvent>;
107
+ queries: Record<string, ContractQuery>;
108
+ sizing: WidgetSizing;
109
+ capabilities?: WidgetCapabilities;
110
+ /** Purpose groups; present only with `contractVersion` 2 */
111
+ csp?: WidgetCspPurpose[];
112
+ }
113
+
114
+ /** Mirrors `is_valid_package_id` in packages/wasm/schema/src/widget_frame.rs */
115
+ export function isValidPackageId(id: string): boolean {
116
+ return id !== "." && id !== ".." && /^[A-Za-z0-9._-]+$/.test(id);
117
+ }
118
+
119
+ export function isValidWidgetId(id: string): boolean {
120
+ return (
121
+ id.length > 0 &&
122
+ !id.startsWith("-") &&
123
+ !id.endsWith("-") &&
124
+ /^[a-z0-9-]+$/.test(id)
125
+ );
126
+ }
127
+
128
+ export function isValidMemberKey(key: string): boolean {
129
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key);
130
+ }
131
+
132
+ function defaultMatchesType(value: JsonValue, input: ContractInput): boolean {
133
+ switch (input.type) {
134
+ case "string":
135
+ return typeof value === "string";
136
+ case "number":
137
+ return typeof value === "number";
138
+ case "integer":
139
+ return typeof value === "number" && Number.isInteger(value);
140
+ case "boolean":
141
+ return typeof value === "boolean";
142
+ case "enum":
143
+ return typeof value === "string" && (input.choices ?? []).includes(value);
144
+ case "json":
145
+ return true;
146
+ }
147
+ }
148
+
149
+ /** Mirrors `WidgetContract::validate` in packages/wasm/schema/src/widget.rs */
150
+ export function validateContract(contract: WidgetContract): string[] {
151
+ const errors: string[] = [];
152
+
153
+ const hasCsp = contract.csp !== undefined && contract.csp !== null;
154
+ if (contract.contractVersion === CONTRACT_VERSION && !hasCsp) {
155
+ errors.push(
156
+ `Widget '${contract.id}' uses contractVersion ${CONTRACT_VERSION} without csp; contracts without csp must use contractVersion ${BASE_CONTRACT_VERSION}`,
157
+ );
158
+ } else if (contract.contractVersion === BASE_CONTRACT_VERSION && hasCsp) {
159
+ errors.push(
160
+ `Widget '${contract.id}' declares csp and must use contractVersion ${CONTRACT_VERSION}`,
161
+ );
162
+ } else if (
163
+ contract.contractVersion !== CONTRACT_VERSION &&
164
+ contract.contractVersion !== BASE_CONTRACT_VERSION
165
+ ) {
166
+ errors.push(
167
+ `Unsupported contractVersion ${contract.contractVersion} for widget '${contract.id}' (supported: ${BASE_CONTRACT_VERSION}, or ${CONTRACT_VERSION} with csp)`,
168
+ );
169
+ }
170
+
171
+ if (hasCsp) {
172
+ const shapeErrors = cspShapeErrors(contract.id, contract.csp);
173
+ errors.push(
174
+ ...(shapeErrors.length > 0
175
+ ? shapeErrors
176
+ : validateCspPurposes(
177
+ contract.id,
178
+ contract.csp as WidgetCspPurpose[],
179
+ contract.inputs ?? {},
180
+ )),
181
+ );
182
+ }
183
+
184
+ if (!isValidWidgetId(contract.id)) {
185
+ errors.push(
186
+ `Invalid widget id '${contract.id}': must be non-empty lowercase kebab-case ([a-z0-9-])`,
187
+ );
188
+ }
189
+
190
+ for (const [key, input] of Object.entries(contract.inputs ?? {})) {
191
+ if (!isValidMemberKey(key)) {
192
+ errors.push(
193
+ `Invalid input key '${key}' in widget '${contract.id}': must match [a-zA-Z_][a-zA-Z0-9_]*`,
194
+ );
195
+ }
196
+ if (input.type === "enum" && (input.choices?.length ?? 0) === 0) {
197
+ errors.push(
198
+ `Enum input '${key}' in widget '${contract.id}' must declare non-empty choices`,
199
+ );
200
+ }
201
+ if (
202
+ input.min !== undefined &&
203
+ input.max !== undefined &&
204
+ input.min > input.max
205
+ ) {
206
+ errors.push(
207
+ `Input '${key}' in widget '${contract.id}' has min ${input.min} > max ${input.max}`,
208
+ );
209
+ }
210
+ if (
211
+ input.default !== undefined &&
212
+ !defaultMatchesType(input.default, input)
213
+ ) {
214
+ errors.push(
215
+ `Default value for input '${key}' in widget '${contract.id}' does not match its declared type`,
216
+ );
217
+ }
218
+ }
219
+
220
+ for (const key of Object.keys(contract.events ?? {})) {
221
+ if (!isValidMemberKey(key)) {
222
+ errors.push(
223
+ `Invalid event key '${key}' in widget '${contract.id}': must match [a-zA-Z_][a-zA-Z0-9_]*`,
224
+ );
225
+ }
226
+ }
227
+
228
+ for (const key of Object.keys(contract.queries ?? {})) {
229
+ if (!isValidMemberKey(key)) {
230
+ errors.push(
231
+ `Invalid query key '${key}' in widget '${contract.id}': must match [a-zA-Z_][a-zA-Z0-9_]*`,
232
+ );
233
+ }
234
+ }
235
+
236
+ return errors;
237
+ }
238
+
239
+ function sortedEntries<T>(map: Record<string, T>): [string, T][] {
240
+ return Object.entries(map).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
241
+ }
242
+
243
+ /**
244
+ * Checks the hub publish runs on top of `WidgetContract::validate` and that
245
+ * need the public suffix list: wildcard bases (`wildcard-public-suffix`) and
246
+ * reason rule 8 (`reason-contains-address`). The bundler runs them at build
247
+ * and in `validate`.
248
+ */
249
+ export function validatePublishedCsp(contract: WidgetContract): string[] {
250
+ if (!Array.isArray(contract.csp)) return [];
251
+ const errors: string[] = [];
252
+ contract.csp.forEach((purpose, index) => {
253
+ if (
254
+ typeof purpose?.reason === "string" &&
255
+ reasonContainsAddress(purpose.reason)
256
+ ) {
257
+ errors.push(
258
+ `Widget '${contract.id}': csp purpose ${index}: ${cspReasonProblem("reason-contains-address")}`,
259
+ );
260
+ }
261
+ });
262
+ const declared = flattenCspPurposes(contract.csp);
263
+ const sources = new Set(Object.values(declared).flat());
264
+ for (const error of validateWildcardBases(sources)) {
265
+ errors.push(`Widget '${contract.id}': ${error}`);
266
+ }
267
+ return errors;
268
+ }
269
+
270
+ /**
271
+ * Rebuild the contract with serde field order, BTreeMap-sorted keys, and the
272
+ * same skip-serializing semantics as the Rust types, so the emitted JSON
273
+ * matches what `serde_json` would produce. Every `csp` purpose is
274
+ * canonicalized in place (group order kept), an empty `csp` is dropped, and
275
+ * `contractVersion` follows from whether any purpose remains.
276
+ */
277
+ export function canonicalizeContract(contract: WidgetContract): WidgetContract {
278
+ const purposes = (contract.csp ?? []).map(canonicalizeCspPurpose);
279
+ const csp = purposes.length > 0 ? purposes : undefined;
280
+ const inputs: Record<string, ContractInput> = {};
281
+ for (const [key, input] of sortedEntries(contract.inputs)) {
282
+ inputs[key] = {
283
+ type: input.type,
284
+ ...(input.description !== undefined && {
285
+ description: input.description,
286
+ }),
287
+ ...(input.default !== undefined && { default: input.default }),
288
+ ...(input.choices !== undefined && { choices: input.choices }),
289
+ ...(input.min !== undefined && { min: input.min }),
290
+ ...(input.max !== undefined && { max: input.max }),
291
+ ...(input.schema !== undefined && { schema: input.schema }),
292
+ ...(input.optional === true && { optional: true }),
293
+ };
294
+ }
295
+
296
+ const events: Record<string, ContractEvent> = {};
297
+ for (const [key, event] of sortedEntries(contract.events)) {
298
+ events[key] = {
299
+ payloadSchema: event.payloadSchema ?? null,
300
+ ...(event.description !== undefined && {
301
+ description: event.description,
302
+ }),
303
+ };
304
+ }
305
+
306
+ const queries: Record<string, ContractQuery> = {};
307
+ for (const [key, query] of sortedEntries(contract.queries)) {
308
+ queries[key] = {
309
+ argsSchema: query.argsSchema ?? null,
310
+ resultSchema: query.resultSchema ?? null,
311
+ ...(query.description !== undefined && {
312
+ description: query.description,
313
+ }),
314
+ ...(query.mutation === true && { mutation: true }),
315
+ };
316
+ }
317
+
318
+ return {
319
+ contractVersion: csp ? CONTRACT_VERSION : BASE_CONTRACT_VERSION,
320
+ id: contract.id,
321
+ inputs,
322
+ events,
323
+ queries,
324
+ sizing: {
325
+ defaultHeight: contract.sizing.defaultHeight,
326
+ resizable: contract.sizing.resizable,
327
+ ...(contract.sizing.maxHeight !== undefined && {
328
+ maxHeight: contract.sizing.maxHeight,
329
+ }),
330
+ },
331
+ ...(contract.capabilities && {
332
+ capabilities: Object.fromEntries(
333
+ (["workers", "media", "microphone", "wasm", "downloads"] as const)
334
+ .filter((key) => contract.capabilities?.[key] !== undefined)
335
+ .map((key) => [key, contract.capabilities?.[key]]),
336
+ ),
337
+ }),
338
+ ...(csp && { csp }),
339
+ };
340
+ }
341
+
342
+ export function contractToJson(contract: WidgetContract): string {
343
+ return JSON.stringify(canonicalizeContract(contract), null, 2);
344
+ }
345
+
346
+ function canonicalTemplate(
347
+ template: WidgetUrlTemplate | undefined,
348
+ ): WidgetUrlTemplate | undefined {
349
+ if (!template) return undefined;
350
+ const subdomains = canonicalCspSources(template.subdomains ?? []);
351
+ const subdomainsInput = template.subdomainsInput ?? undefined;
352
+ if (subdomains.length === 0 && subdomainsInput === undefined) {
353
+ return undefined;
354
+ }
355
+ return {
356
+ ...(subdomains.length > 0 && { subdomains }),
357
+ ...(subdomainsInput !== undefined && { subdomainsInput }),
358
+ };
359
+ }
360
+
361
+ function canonicalNetworkInput(input: WidgetNetworkInput): WidgetNetworkInput {
362
+ const template = canonicalTemplate(input.template);
363
+ return {
364
+ path: input.path,
365
+ directives: [...new Set(input.directives)].sort(compareCspDirectives),
366
+ ...(template && { template }),
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Canonical form (`WidgetCspPurpose::canonicalize` plus serde field order):
372
+ * source lists sorted and deduplicated, directives in `CSP_DIRECTIVES`
373
+ * order, template subdomains sorted, empty templates removed and inputs
374
+ * sorted by path.
375
+ */
376
+ export function canonicalizeCspPurpose(
377
+ purpose: WidgetCspPurpose,
378
+ ): WidgetCspPurpose {
379
+ const canonical: WidgetCspPurpose = { reason: purpose.reason };
380
+ for (const directive of CSP_DIRECTIVES) {
381
+ const sources = canonicalCspSources(purpose[directive] ?? []);
382
+ if (sources.length > 0) canonical[directive] = sources;
383
+ }
384
+ const inputs = (purpose.inputs ?? [])
385
+ .map(canonicalNetworkInput)
386
+ .sort((a, b) => compareCspSources(a.path, b.path));
387
+ if (inputs.length > 0) canonical.inputs = inputs;
388
+ return canonical;
389
+ }
390
+
391
+ /**
392
+ * Authoring normalization: reasons are NFC with collapsed whitespace, sources
393
+ * are lowercased and punycoded, then every purpose is canonicalized. Group
394
+ * order is kept.
395
+ */
396
+ export function normalizeCspPurposes(
397
+ purposes: readonly WidgetCspPurpose[],
398
+ ): WidgetCspPurpose[] {
399
+ return purposes.map((purpose) => {
400
+ const normalized: WidgetCspPurpose = {
401
+ ...purpose,
402
+ reason: normalizeCspReason(purpose.reason),
403
+ };
404
+ for (const directive of CSP_DIRECTIVES) {
405
+ const sources = purpose[directive];
406
+ if (sources) normalized[directive] = sources.map(normalizeCspSource);
407
+ }
408
+ return canonicalizeCspPurpose(normalized);
409
+ });
410
+ }
411
+
412
+ function isRecord(value: unknown): value is Record<string, unknown> {
413
+ return typeof value === "object" && value !== null && !Array.isArray(value);
414
+ }
415
+
416
+ function isStringArray(value: unknown): value is string[] {
417
+ return (
418
+ Array.isArray(value) && value.every((entry) => typeof entry === "string")
419
+ );
420
+ }
421
+
422
+ function unknownKeys(
423
+ value: Record<string, unknown>,
424
+ allowed: readonly string[],
425
+ ): string[] {
426
+ return Object.keys(value).filter((key) => !allowed.includes(key));
427
+ }
428
+
429
+ function templateShapeErrors(template: unknown): string[] {
430
+ if (template === undefined || template === null) return [];
431
+ if (!isRecord(template)) return ["template must be an object"];
432
+ const errors = unknownKeys(template, CSP_TEMPLATE_KEYS).map(
433
+ (key) =>
434
+ `template declares unknown key "${key}" (allowed: ${CSP_TEMPLATE_KEYS.join(", ")})`,
435
+ );
436
+ if (
437
+ template.subdomains !== undefined &&
438
+ !isStringArray(template.subdomains)
439
+ ) {
440
+ errors.push("template subdomains must be an array of strings");
441
+ }
442
+ const input = template.subdomainsInput;
443
+ if (input !== undefined && input !== null && typeof input !== "string") {
444
+ errors.push("template subdomainsInput must be a string");
445
+ }
446
+ return errors;
447
+ }
448
+
449
+ function networkInputShapeErrors(input: unknown): string[] {
450
+ if (!isRecord(input)) return ["must be an object"];
451
+ const errors = unknownKeys(input, CSP_NETWORK_INPUT_KEYS).map(
452
+ (key) =>
453
+ `declares unknown key "${key}" (allowed: ${CSP_NETWORK_INPUT_KEYS.join(", ")})`,
454
+ );
455
+ if (typeof input.path !== "string") errors.push("path must be a string");
456
+ if (!Array.isArray(input.directives)) {
457
+ errors.push("directives must be an array");
458
+ } else {
459
+ for (const directive of input.directives) {
460
+ if (typeof directive !== "string" || !isCspDirective(directive)) {
461
+ errors.push(
462
+ `declares unknown directive ${JSON.stringify(directive)} (allowed: ${CSP_DIRECTIVES.join(", ")})`,
463
+ );
464
+ }
465
+ }
466
+ }
467
+ errors.push(...templateShapeErrors(input.template));
468
+ return errors;
469
+ }
470
+
471
+ function purposeShapeErrors(purpose: unknown): string[] {
472
+ if (!isRecord(purpose)) return ["must be an object"];
473
+ const errors = unknownKeys(purpose, CSP_PURPOSE_KEYS).map(
474
+ (key) =>
475
+ `declares unknown key "${key}" (allowed: ${CSP_PURPOSE_KEYS.join(", ")})`,
476
+ );
477
+ if (typeof purpose.reason !== "string") {
478
+ errors.push("must declare a string reason");
479
+ }
480
+ for (const directive of CSP_DIRECTIVES) {
481
+ const sources = purpose[directive];
482
+ if (sources !== undefined && !isStringArray(sources)) {
483
+ errors.push(`${directive} must be an array of strings`);
484
+ }
485
+ }
486
+ if (purpose.inputs !== undefined) {
487
+ if (!Array.isArray(purpose.inputs)) {
488
+ errors.push("inputs must be an array");
489
+ } else {
490
+ purpose.inputs.forEach((input, index) => {
491
+ for (const error of networkInputShapeErrors(input)) {
492
+ errors.push(`input ${index}: ${error}`);
493
+ }
494
+ });
495
+ }
496
+ }
497
+ return errors;
498
+ }
499
+
500
+ /**
501
+ * What serde refuses to parse (`deny_unknown_fields`, required fields, the
502
+ * array shape). Semantic rules run only on a well-formed declaration.
503
+ */
504
+ export function cspShapeErrors(widgetId: string, csp: unknown): string[] {
505
+ if (!Array.isArray(csp)) {
506
+ return [
507
+ `Widget '${widgetId}': csp must be an array of purpose groups ({ reason, connectSrc?, imgSrc?, fontSrc?, mediaSrc?, styleSrc?, inputs? })`,
508
+ ];
509
+ }
510
+ return csp.flatMap((purpose, index) =>
511
+ purposeShapeErrors(purpose).map(
512
+ (error) => `Widget '${widgetId}': csp purpose ${index}: ${error}`,
513
+ ),
514
+ );
515
+ }
516
+
517
+ /** The input fields validation reads; `ContractInput` satisfies it. */
518
+ export interface CspContractInput {
519
+ readonly type: string;
520
+ }
521
+
522
+ function isReservedInputKey(key: string): boolean {
523
+ return HOST_RESERVED_INPUT_KEYS.includes(key);
524
+ }
525
+
526
+ function templateProblems(
527
+ template: WidgetUrlTemplate,
528
+ inputs: Readonly<Record<string, CspContractInput>>,
529
+ ): string[] {
530
+ const problems: string[] = [];
531
+ const subdomains = template.subdomains ?? [];
532
+ const name = template.subdomainsInput ?? undefined;
533
+ if (subdomains.length === 0 && name === undefined) {
534
+ problems.push(
535
+ "template declares no subdomains or subdomainsInput; omit it",
536
+ );
537
+ }
538
+ if (subdomains.length > MAX_WIDGET_TEMPLATE_SUBDOMAINS) {
539
+ problems.push(
540
+ `template declares ${subdomains.length} subdomains; at most ${MAX_WIDGET_TEMPLATE_SUBDOMAINS} are allowed`,
541
+ );
542
+ }
543
+ for (const subdomain of subdomains) {
544
+ if (!isDnsLabel(subdomain)) {
545
+ problems.push(
546
+ `template subdomain "${subdomain}" is not a lowercase DNS label`,
547
+ );
548
+ }
549
+ }
550
+ if (!isStrictlyAscending(subdomains)) {
551
+ problems.push(
552
+ "template subdomains must be sorted ascending without duplicates",
553
+ );
554
+ }
555
+ if (name !== undefined) {
556
+ if (isReservedInputKey(name)) {
557
+ problems.push(`subdomainsInput "${name}" is reserved by the host`);
558
+ } else {
559
+ const type = Object.hasOwn(inputs, name) ? inputs[name]?.type : undefined;
560
+ if (type === undefined) {
561
+ problems.push(`subdomainsInput "${name}" is not a contract input`);
562
+ } else if (type !== "string" && type !== "json") {
563
+ problems.push(
564
+ `subdomainsInput "${name}" must name a string or json input`,
565
+ );
566
+ }
567
+ }
568
+ }
569
+ return problems;
570
+ }
571
+
572
+ function networkInputProblems(
573
+ input: WidgetNetworkInput,
574
+ inputs: Readonly<Record<string, CspContractInput>>,
575
+ ): string[] {
576
+ const problems: string[] = [];
577
+ const parsed = parseWidgetInputPath(input.path);
578
+ if (parsed === null) {
579
+ problems.push(
580
+ 'path must match root *( "." key / "[]" / ".*" ) with keys [A-Za-z_][A-Za-z0-9_]*',
581
+ );
582
+ } else {
583
+ const { root, segments } = parsed;
584
+ const count = segments.length + 1;
585
+ if (count > MAX_WIDGET_INPUT_PATH_SEGMENTS) {
586
+ problems.push(
587
+ `path has ${count} segments; at most ${MAX_WIDGET_INPUT_PATH_SEGMENTS} are allowed`,
588
+ );
589
+ }
590
+ if (isReservedInputKey(root)) {
591
+ problems.push(`root "${root}" is reserved by the host`);
592
+ } else {
593
+ const type = Object.hasOwn(inputs, root) ? inputs[root]?.type : undefined;
594
+ if (type === undefined) {
595
+ problems.push(`root "${root}" is not a contract input`);
596
+ } else if (type === "string" && segments.length > 0) {
597
+ problems.push(
598
+ `root "${root}" is a string input and must be the whole path`,
599
+ );
600
+ } else if (type !== "string" && type !== "json") {
601
+ problems.push(
602
+ `root "${root}" must be a string or json input; declare static sources for fixed choices`,
603
+ );
604
+ }
605
+ }
606
+ }
607
+ if (input.directives.length === 0) problems.push("declares no directives");
608
+ if (!isStrictlyAscending(input.directives, compareCspDirectives)) {
609
+ problems.push(
610
+ "directives must follow connectSrc, imgSrc, fontSrc, mediaSrc, styleSrc order without duplicates",
611
+ );
612
+ }
613
+ if (input.template) {
614
+ problems.push(...templateProblems(input.template, inputs));
615
+ }
616
+ return problems;
617
+ }
618
+
619
+ function purposeReasonRejection(
620
+ purpose: WidgetCspPurpose,
621
+ ): WidgetCspReasonRejection | null {
622
+ return validateWidgetCspReason(
623
+ purpose.reason,
624
+ (purpose.inputs?.length ?? 0) > 0,
625
+ );
626
+ }
627
+
628
+ function purposeProblems(
629
+ purpose: WidgetCspPurpose,
630
+ inputs: Readonly<Record<string, CspContractInput>>,
631
+ reasonRejection: WidgetCspReasonRejection | null,
632
+ ): string[] {
633
+ const problems: string[] = [];
634
+ const networkInputs = purpose.inputs ?? [];
635
+ if (reasonRejection !== null) {
636
+ problems.push(cspReasonProblem(reasonRejection));
637
+ }
638
+ if (cspSourceCount(purpose) === 0 && networkInputs.length === 0) {
639
+ problems.push("declares no sources and no inputs");
640
+ }
641
+ for (const directive of CSP_DIRECTIVES) {
642
+ const sources = purpose[directive] ?? [];
643
+ for (const source of sources) {
644
+ const sourceRejection = validateCspSource(directive, source);
645
+ if (sourceRejection !== null) {
646
+ problems.push(
647
+ `Invalid csp source "${source}" in ${directive}: ${CSP_SOURCE_REJECTION_MESSAGES[sourceRejection]}`,
648
+ );
649
+ }
650
+ }
651
+ if (!isStrictlyAscending(sources)) {
652
+ problems.push(`${directive} must be sorted ascending without duplicates`);
653
+ }
654
+ }
655
+ if (!isStrictlyAscending(networkInputs.map((input) => input.path))) {
656
+ problems.push("inputs must be sorted by path without duplicates");
657
+ }
658
+ for (const input of networkInputs) {
659
+ for (const problem of networkInputProblems(input, inputs)) {
660
+ problems.push(`input "${input.path}": ${problem}`);
661
+ }
662
+ }
663
+ return problems;
664
+ }
665
+
666
+ function purposeSources(purpose: WidgetCspPurpose): string[] {
667
+ return canonicalCspSources(
668
+ CSP_DIRECTIVES.flatMap((directive) => purpose[directive] ?? []),
669
+ );
670
+ }
671
+
672
+ /**
673
+ * Mirrors `validate_csp_purposes`: every PSL-free rule of §14.2.3 and reason
674
+ * rules 1–7 and 9–12, with the same messages and order. Purpose indexes are
675
+ * zero-based. Expects a declaration that passed {@link cspShapeErrors}.
676
+ */
677
+ export function validateCspPurposes(
678
+ widgetId: string,
679
+ purposes: readonly WidgetCspPurpose[],
680
+ inputs: Readonly<Record<string, CspContractInput>>,
681
+ ): string[] {
682
+ if (purposes.length === 0) {
683
+ return [
684
+ `Widget '${widgetId}' declares an empty csp; omit csp when it declares no purposes`,
685
+ ];
686
+ }
687
+ const errors: string[] = [];
688
+ if (purposes.length > MAX_WIDGET_CSP_PURPOSES) {
689
+ errors.push(
690
+ `Widget '${widgetId}': csp declares ${purposes.length} purposes; at most ${MAX_WIDGET_CSP_PURPOSES} are allowed`,
691
+ );
692
+ }
693
+ const sourceOwners = new Map<string, number>();
694
+ const inputOwners = new Map<string, number>();
695
+ const reasonOwners = new Map<string, number>();
696
+ purposes.forEach((purpose, index) => {
697
+ const reasonRejection = purposeReasonRejection(purpose);
698
+ for (const problem of purposeProblems(purpose, inputs, reasonRejection)) {
699
+ errors.push(`Widget '${widgetId}': csp purpose ${index}: ${problem}`);
700
+ }
701
+ for (const source of purposeSources(purpose)) {
702
+ const first = sourceOwners.get(source);
703
+ if (first === undefined) sourceOwners.set(source, index);
704
+ else
705
+ errors.push(
706
+ `Widget '${widgetId}': csp source "${source}" is declared in purposes ${first} and ${index}`,
707
+ );
708
+ }
709
+ const paths = canonicalCspSources(
710
+ (purpose.inputs ?? []).map((input) => input.path),
711
+ );
712
+ for (const path of paths) {
713
+ const first = inputOwners.get(path);
714
+ if (first === undefined) inputOwners.set(path, index);
715
+ else
716
+ errors.push(
717
+ `Widget '${widgetId}': csp input "${path}" is declared in purposes ${first} and ${index}`,
718
+ );
719
+ }
720
+ if (reasonRejection === null) {
721
+ const folded = foldWidgetCspReason(purpose.reason);
722
+ const first = reasonOwners.get(folded);
723
+ if (first === undefined) reasonOwners.set(folded, index);
724
+ else
725
+ errors.push(
726
+ `Widget '${widgetId}': csp purposes ${first} and ${index}: ${cspReasonProblem("reason-duplicate")}`,
727
+ );
728
+ }
729
+ });
730
+ const inputCount = purposes.reduce(
731
+ (count, purpose) => count + (purpose.inputs?.length ?? 0),
732
+ 0,
733
+ );
734
+ if (inputCount > MAX_WIDGET_NETWORK_INPUTS) {
735
+ errors.push(
736
+ `Widget '${widgetId}': csp declares ${inputCount} network inputs; at most ${MAX_WIDGET_NETWORK_INPUTS} are allowed`,
737
+ );
738
+ }
739
+ const declared = flattenCspPurposes(purposes);
740
+ const count = cspSourceCount(declared);
741
+ if (count > MAX_WIDGET_CSP_SOURCES) {
742
+ errors.push(
743
+ `Widget '${widgetId}': csp declares ${count} sources; at most ${MAX_WIDGET_CSP_SOURCES} are allowed`,
744
+ );
745
+ }
746
+ const bytes = cspSourceBytes(declared);
747
+ if (bytes > MAX_WIDGET_CSP_SOURCE_BYTES) {
748
+ errors.push(
749
+ `Widget '${widgetId}': csp sources take ${bytes} bytes; at most ${MAX_WIDGET_CSP_SOURCE_BYTES} are allowed`,
750
+ );
751
+ }
752
+ return errors;
753
+ }
754
+
755
+ type AssertAssignable<_T extends U, U> = never;
756
+ export type ContractShapeMatchesSdk = AssertAssignable<
757
+ WidgetContract,
758
+ SdkWidgetContract
759
+ >;