@fabricorg/sdui-release 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -20,23 +20,35 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ RELEASE_CHANNEL_POINTER_FORMAT_VERSION: () => RELEASE_CHANNEL_POINTER_FORMAT_VERSION,
23
24
  SDUI_COMPONENT_PACK_FORMAT_VERSION: () => SDUI_COMPONENT_PACK_FORMAT_VERSION,
24
25
  SDUI_DOCUMENT_FORMAT_VERSION: () => SDUI_DOCUMENT_FORMAT_VERSION,
25
26
  SDUI_DOCUMENT_JSON_SCHEMA: () => SDUI_DOCUMENT_JSON_SCHEMA,
26
27
  SDUI_RELEASE_FORMAT_VERSION: () => SDUI_RELEASE_FORMAT_VERSION,
27
28
  SDUI_RELEASE_JSON_SCHEMA: () => SDUI_RELEASE_JSON_SCHEMA,
29
+ SDUI_RELEASE_V3_FORMAT_VERSION: () => SDUI_RELEASE_V3_FORMAT_VERSION,
28
30
  SDUI_TOKEN_SET_FORMAT_VERSION: () => SDUI_TOKEN_SET_FORMAT_VERSION,
31
+ activateReleaseChannelPointer: () => activateReleaseChannelPointer,
32
+ assertReleaseChannelPointer: () => assertReleaseChannelPointer,
29
33
  assertSduiAuthorizationGrants: () => assertSduiAuthorizationGrants,
30
34
  assertSduiComponentPack: () => assertSduiComponentPack,
31
35
  assertSduiDocument: () => assertSduiDocument,
32
36
  assertSduiFragment: () => assertSduiFragment,
33
37
  assertSduiRelease: () => assertSduiRelease,
34
38
  assertSduiReleaseIntegrity: () => assertSduiReleaseIntegrity,
39
+ assertSduiReleaseV3: () => assertSduiReleaseV3,
35
40
  assertSduiReleaseValid: () => assertSduiReleaseValid,
36
41
  assertSduiTokenSet: () => assertSduiTokenSet,
42
+ canonicalizeJcs: () => canonicalizeJcs,
37
43
  computeReleaseDigest: () => computeReleaseDigest,
38
44
  createSduiChannel: () => createSduiChannel,
39
- validateSduiRelease: () => validateSduiRelease
45
+ deriveAuthorizationGrants: () => deriveAuthorizationGrants,
46
+ parseCapabilityRef: () => parseCapabilityRef,
47
+ runSduiChannelChecks: () => runSduiChannelChecks,
48
+ sduiChannelChecks: () => sduiChannelChecks,
49
+ validateSduiRelease: () => validateSduiRelease,
50
+ verifySignedExperienceArtifact: () => verifySignedExperienceArtifact,
51
+ verifySignedExperienceRelease: () => verifySignedExperienceRelease
40
52
  });
41
53
  module.exports = __toCommonJS(index_exports);
42
54
 
@@ -66,8 +78,9 @@ function requireArray(value, path) {
66
78
  if (!Array.isArray(value)) fail(path, "must be an array");
67
79
  return value;
68
80
  }
81
+ var CAPABILITY_REF = /^capability:\/\/([a-z][a-z0-9-]*)\/([a-z][a-z0-9._-]*(?:\/[a-z0-9._-]+)*)$/;
69
82
  function parseCapabilityRef(ref) {
70
- const match = /^capability:\/\/([a-z][a-z0-9-]*)\/(.+)$/.exec(ref);
83
+ const match = CAPABILITY_REF.exec(ref);
71
84
  if (!match) return void 0;
72
85
  return { namespace: match[1], name: match[2] };
73
86
  }
@@ -202,8 +215,20 @@ function assertSduiDocument(value, path = "document") {
202
215
  });
203
216
  }
204
217
  const tokenSet = requireRecord(document.tokenSet, `${path}.tokenSet`);
205
- requireString(tokenSet.name, `${path}.tokenSet.name`);
206
- requireString(tokenSet.version, `${path}.tokenSet.version`);
218
+ assertTokenSetReference(tokenSet, `${path}.tokenSet`);
219
+ if (document.supportedTokenSets !== void 0) {
220
+ const seenTokenSets = /* @__PURE__ */ new Set();
221
+ requireArray(document.supportedTokenSets, `${path}.supportedTokenSets`).forEach((entry, index) => {
222
+ const supportedPath = `${path}.supportedTokenSets[${index}]`;
223
+ const supported = requireRecord(entry, supportedPath);
224
+ assertTokenSetReference(supported, supportedPath);
225
+ const identity = `${String(supported.name)}\0${String(supported.version)}`;
226
+ if (seenTokenSets.has(identity)) {
227
+ fail(supportedPath, `declares token set "${String(supported.name)}@${String(supported.version)}" a second time`);
228
+ }
229
+ seenTokenSets.add(identity);
230
+ });
231
+ }
207
232
  requireArray(document.componentPacks, `${path}.componentPacks`).forEach((entry, index) => {
208
233
  const pack = requireRecord(entry, `${path}.componentPacks[${index}]`);
209
234
  requireString(pack.pack, `${path}.componentPacks[${index}].pack`);
@@ -283,6 +308,14 @@ function assertSduiRelease(value, path = "release") {
283
308
  }
284
309
  });
285
310
  }
311
+ function assertTokenSetReference(reference, path) {
312
+ if (!/^[a-z][a-z0-9-]*$/.test(requireString(reference.name, `${path}.name`))) {
313
+ fail(`${path}.name`, "must be lowercase letters, numbers and hyphens");
314
+ }
315
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(requireString(reference.version, `${path}.version`))) {
316
+ fail(`${path}.version`, "must be a semver version");
317
+ }
318
+ }
286
319
 
287
320
  // validate.ts
288
321
  var import_assembly2 = require("@fabricorg/assembly");
@@ -315,7 +348,7 @@ function canonicalFragmentGrants(fragments) {
315
348
  );
316
349
  }
317
350
  function releaseDigest(document, tokenSet, componentPacks, grants, assemblyDigest) {
318
- const canonical = stableStringify({
351
+ const canonical2 = stableStringify({
319
352
  document: {
320
353
  formatVersion: document.formatVersion,
321
354
  name: document.name,
@@ -330,6 +363,16 @@ function releaseDigest(document, tokenSet, componentPacks, grants, assemblyDiges
330
363
  variants: [...document.variants].sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
331
364
  } : {},
332
365
  tokenSet: document.tokenSet,
366
+ // Which brands a document may be promoted against is part of what was
367
+ // authored. Sorted, and omitted when absent so a document declaring
368
+ // none digests exactly as it did before the field existed.
369
+ ...document.supportedTokenSets?.length ? {
370
+ supportedTokenSets: [...document.supportedTokenSets].sort((left, right) => {
371
+ if (left.name !== right.name) return left.name < right.name ? -1 : 1;
372
+ if (left.version !== right.version) return left.version < right.version ? -1 : 1;
373
+ return 0;
374
+ })
375
+ } : {},
333
376
  componentPacks: [...document.componentPacks].sort((a, b) => {
334
377
  const key = (e) => `${e.namespace}/${e.pack}`;
335
378
  return key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0;
@@ -355,7 +398,7 @@ function releaseDigest(document, tokenSet, componentPacks, grants, assemblyDiges
355
398
  },
356
399
  assemblyDigest
357
400
  });
358
- return (0, import_node_crypto.createHash)("sha256").update(canonical).digest("hex");
401
+ return (0, import_node_crypto.createHash)("sha256").update(canonical2).digest("hex");
359
402
  }
360
403
  function computeReleaseDigest(document, tokenSet, componentPacks, grants, assemblyDigest) {
361
404
  return releaseDigest(document, tokenSet, componentPacks, grants, assemblyDigest);
@@ -379,19 +422,54 @@ function buildKnownViews(contracts) {
379
422
  const views = /* @__PURE__ */ new Set();
380
423
  for (const contract of contracts) {
381
424
  for (const view of contract.views) {
382
- const name = view.name.startsWith(`${contract.namespace}/`) ? view.name.slice(contract.namespace.length + 1) : view.name;
383
- views.add(`${contract.namespace}/${name}`);
425
+ views.add(`${contract.namespace}/${shortViewName(view.name, contract.namespace)}`);
384
426
  }
385
427
  }
386
428
  return views;
387
429
  }
388
- function buildKnownActionIntents(contracts) {
430
+ function shortViewName(name, namespace) {
431
+ return name.startsWith(`${namespace}/`) ? name.slice(namespace.length + 1) : name;
432
+ }
433
+ function reportAmbiguousGrantedViews(contracts, grantedViews, add) {
434
+ const occurrences = /* @__PURE__ */ new Map();
435
+ for (const contract of contracts) {
436
+ for (const view of contract.views) {
437
+ const reference = `capability://${contract.namespace}/${shortViewName(view.name, contract.namespace)}`;
438
+ if (!grantedViews.has(reference)) continue;
439
+ occurrences.set(reference, [...occurrences.get(reference) ?? [], `${view.name}@${view.version}`]);
440
+ }
441
+ }
442
+ for (const [reference, declarations] of occurrences) {
443
+ if (declarations.length > 1) {
444
+ add(
445
+ "ambiguous_capability_reference",
446
+ `grants.views.${reference}`,
447
+ `"${reference}" is granted but addresses ${declarations.length} views (${declarations.join(", ")}); a versionless reference cannot pick one`
448
+ );
449
+ }
450
+ }
451
+ }
452
+ function buildKnownActionIntents(contracts, add, grantedIntents) {
389
453
  const intents = /* @__PURE__ */ new Set();
454
+ const occurrences = /* @__PURE__ */ new Map();
390
455
  for (const contract of contracts) {
391
456
  const experience = contract.extensions?.["fabric.experience/v1"];
392
457
  for (const intent of experience?.actionIntents ?? []) {
393
- const shortName = intent.name.startsWith(`${contract.namespace}.`) ? intent.name.slice(contract.namespace.length + 1) : intent.name;
394
- intents.add(`${contract.namespace}/${shortName}`);
458
+ const shortName2 = intent.name.startsWith(`${contract.namespace}.`) ? intent.name.slice(contract.namespace.length + 1) : intent.name;
459
+ const reference = `${contract.namespace}/${shortName2}`;
460
+ if (grantedIntents === void 0 || grantedIntents.has(`capability://${reference}`)) {
461
+ occurrences.set(reference, [...occurrences.get(reference) ?? [], intent.name]);
462
+ }
463
+ intents.add(reference);
464
+ }
465
+ }
466
+ for (const [reference, declarations] of occurrences) {
467
+ if (declarations.length > 1) {
468
+ add?.(
469
+ "ambiguous_capability_reference",
470
+ `contracts.actionIntents.${reference}`,
471
+ `"capability://${reference}" is declared ${declarations.length} times (${declarations.join(", ")}), so a route for it cannot be resolved`
472
+ );
395
473
  }
396
474
  }
397
475
  return intents;
@@ -585,18 +663,13 @@ function validateSduiRelease(input) {
585
663
  const add = (code, path, message) => {
586
664
  findings.push({ code, path, message });
587
665
  };
588
- if (input.document.tokenSet.name !== input.tokenSet.name) {
589
- add(
590
- "missing_token_set",
591
- "document.tokenSet.name",
592
- `document references token set "${input.document.tokenSet.name}" but the provided set is "${input.tokenSet.name}"`
593
- );
594
- }
595
- if (input.document.tokenSet.version !== input.tokenSet.version) {
666
+ const promotableTokenSets = [input.document.tokenSet, ...input.document.supportedTokenSets ?? []];
667
+ const promotedAgainst = promotableTokenSets.some((candidate) => candidate.name === input.tokenSet.name && candidate.version === input.tokenSet.version);
668
+ if (!promotedAgainst) {
596
669
  add(
597
670
  "missing_token_set",
598
- "document.tokenSet.version",
599
- `document references token set version "${input.document.tokenSet.version}" but the provided set is version "${input.tokenSet.version}"`
671
+ "document.tokenSet",
672
+ `document supports token sets [${promotableTokenSets.map((set) => `${set.name}@${set.version}`).join(", ")}] but the provided set is "${input.tokenSet.name}@${input.tokenSet.version}"`
600
673
  );
601
674
  }
602
675
  const packsByKey = /* @__PURE__ */ new Map();
@@ -712,9 +785,21 @@ function validateSduiRelease(input) {
712
785
  }
713
786
  const coreComponents = new Set(input.coreComponents ?? []);
714
787
  const knownViews = buildKnownViews(contracts);
715
- const knownIntents = buildKnownActionIntents(contracts);
716
788
  const grantedViews = buildGrantedReferences(input.grants.views, "grants.views");
717
789
  const grantedIntents = buildGrantedReferences(input.grants.intents, "grants.intents");
790
+ const knownIntents = buildKnownActionIntents(contracts, add, grantedIntents);
791
+ reportAmbiguousGrantedViews(contracts, grantedViews, add);
792
+ const contractNamespaces = /* @__PURE__ */ new Set();
793
+ for (const contract of contracts) {
794
+ if (contractNamespaces.has(contract.namespace)) {
795
+ add(
796
+ "ambiguous_capability_reference",
797
+ `contracts.${contract.namespace}`,
798
+ `contract for "${contract.namespace}" is supplied more than once`
799
+ );
800
+ }
801
+ contractNamespaces.add(contract.namespace);
802
+ }
718
803
  validateIntentActionDeclarations(contracts, add);
719
804
  const fragmentGrants = /* @__PURE__ */ new Map();
720
805
  for (const [fragmentId, declared] of Object.entries(input.grants.fragments ?? {})) {
@@ -839,6 +924,221 @@ function collectCoreComponents(fragment) {
839
924
  }
840
925
  return used;
841
926
  }
927
+ async function refusalReason(run, expect) {
928
+ try {
929
+ await run();
930
+ return null;
931
+ } catch (error) {
932
+ const message = error instanceof Error ? error.message : String(error);
933
+ return expect.test(message) ? message : null;
934
+ }
935
+ }
936
+ function sduiChannelChecks() {
937
+ return [
938
+ {
939
+ id: "fabric.sdui-channel.verified-release.v1",
940
+ async run(subject) {
941
+ assertSduiReleaseIntegrity(subject.validRelease);
942
+ const result = subject.channel.consume(subject.validRelease);
943
+ if (result.release.releaseDigest !== subject.validRelease.releaseDigest) {
944
+ throw new Error("channel returned a release other than the one it was given");
945
+ }
946
+ return [subject.validRelease.releaseDigest];
947
+ }
948
+ },
949
+ {
950
+ id: "fabric.sdui-channel.tampered-release-denial.v1",
951
+ async run(subject) {
952
+ const tampered = {
953
+ ...subject.validRelease,
954
+ document: { ...subject.validRelease.document, name: `${subject.validRelease.document.name}-tampered` }
955
+ };
956
+ const reason = await refusalReason(() => subject.channel.consume(tampered), /digest|integrity/i);
957
+ if (reason === null) {
958
+ throw new Error("channel did not refuse a release whose content no longer matches its digest, citing the digest");
959
+ }
960
+ for (const [label, mutate] of [
961
+ ["version", (release) => ({ ...release, document: { ...release.document, version: "9.9.9" } })],
962
+ ["grants", (release) => ({ ...release, grants: { ...release.grants, views: [...release.grants.views, "capability://smuggled/view"] } })],
963
+ ["assembly", (release) => ({ ...release, assemblyDigest: "f".repeat(64) })]
964
+ ]) {
965
+ const edited = mutate(subject.validRelease);
966
+ if (await refusalReason(() => subject.channel.consume(edited), /digest|integrity/i) === null) {
967
+ throw new Error(`channel accepted a release whose ${label} was edited after promotion`);
968
+ }
969
+ }
970
+ return ["tampered document refused", "edited version, grants and assembly refused"];
971
+ }
972
+ },
973
+ {
974
+ id: "fabric.sdui-channel.unpromoted-document-denial.v1",
975
+ async run(subject) {
976
+ const expected = /digest|integrity|release|formatVersion|must be/i;
977
+ const unpromoted = { document: subject.validRelease.document };
978
+ if (await refusalReason(() => subject.channel.consume(unpromoted), expected) === null) {
979
+ throw new Error("channel did not refuse a bare document that was never promoted, citing what was wrong with it");
980
+ }
981
+ const digestless = { ...subject.validRelease, releaseDigest: void 0 };
982
+ if (await refusalReason(() => subject.channel.consume(digestless), expected) === null) {
983
+ throw new Error("channel did not refuse a release carrying no digest, citing what was wrong with it");
984
+ }
985
+ return ["bare document refused", "digestless release refused"];
986
+ }
987
+ },
988
+ {
989
+ id: "fabric.sdui-channel.reports-missing-vocabulary.v1",
990
+ async run(subject) {
991
+ const used = collectCoreComponents(subject.validRelease.document.root);
992
+ for (const variant of subject.validRelease.document.variants ?? []) {
993
+ for (const component of collectCoreComponents(variant.root)) used.add(component);
994
+ }
995
+ if (!used.has(subject.unimplementedCoreComponent)) {
996
+ throw new Error(
997
+ `conformance subject is vacuous: validRelease never uses "${subject.unimplementedCoreComponent}", so the channel is never asked to report it`
998
+ );
999
+ }
1000
+ if (subject.channel.coreComponents.includes(subject.unimplementedCoreComponent)) {
1001
+ throw new Error(
1002
+ `conformance subject is contradictory: the channel implements "${subject.unimplementedCoreComponent}"`
1003
+ );
1004
+ }
1005
+ const result = subject.channel.consume(subject.validRelease);
1006
+ if (result.compatible) {
1007
+ throw new Error("channel reported compatible for a release using a component it does not implement");
1008
+ }
1009
+ if (!result.missingCoreComponents.includes(subject.unimplementedCoreComponent)) {
1010
+ throw new Error("channel reported incompatible without naming the component it cannot render");
1011
+ }
1012
+ return [`missing: ${result.missingCoreComponents.join(", ")}`];
1013
+ }
1014
+ },
1015
+ {
1016
+ id: "fabric.sdui-channel.consumption-is-pure.v1",
1017
+ async run(subject) {
1018
+ const before = JSON.stringify(subject.validRelease);
1019
+ subject.channel.consume(subject.validRelease);
1020
+ subject.channel.consume(subject.validRelease);
1021
+ if (JSON.stringify(subject.validRelease) !== before) {
1022
+ throw new Error("channel mutated the release it consumed");
1023
+ }
1024
+ return ["release unchanged after two consumptions"];
1025
+ }
1026
+ }
1027
+ ];
1028
+ }
1029
+ async function runSduiChannelChecks(subject) {
1030
+ const checks = [];
1031
+ for (const check of sduiChannelChecks()) {
1032
+ try {
1033
+ checks.push({ id: check.id, status: "passed", evidence: await check.run(subject) });
1034
+ } catch (error) {
1035
+ checks.push({
1036
+ id: check.id,
1037
+ status: "failed",
1038
+ evidence: [],
1039
+ error: error instanceof Error ? error.message : String(error)
1040
+ });
1041
+ }
1042
+ }
1043
+ return { passed: checks.every((check) => check.status === "passed"), checks };
1044
+ }
1045
+
1046
+ // references.ts
1047
+ function collectDocumentReferences(document) {
1048
+ const views = /* @__PURE__ */ new Set();
1049
+ const intents = /* @__PURE__ */ new Set();
1050
+ const trees = [document.root, ...(document.variants ?? []).map((variant) => variant.root)];
1051
+ for (const tree of trees) walk(tree, views, intents);
1052
+ return { views, intents };
1053
+ }
1054
+ function walk(fragment, views, intents) {
1055
+ if (fragment.dataRef) views.add(fragment.dataRef);
1056
+ for (const action of Object.values(fragment.actions ?? {})) {
1057
+ intents.add(action.intent);
1058
+ if (action.params) collectValues(action.params, views);
1059
+ }
1060
+ if (fragment.props) collectValues(fragment.props, views);
1061
+ for (const child of fragment.children ?? []) walk(child, views, intents);
1062
+ }
1063
+ function collectValues(props, views) {
1064
+ for (const value of Object.values(props)) collectValue(value, views);
1065
+ }
1066
+ function collectValue(value, views) {
1067
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && "$ref" in value) {
1068
+ views.add(value.$ref);
1069
+ for (const [key, sibling] of Object.entries(value)) {
1070
+ if (key !== "$ref") collectValue(sibling, views);
1071
+ }
1072
+ return;
1073
+ }
1074
+ if (Array.isArray(value)) {
1075
+ for (const item of value) collectValue(item, views);
1076
+ return;
1077
+ }
1078
+ if (value !== null && typeof value === "object") collectValues(value, views);
1079
+ }
1080
+
1081
+ // entitlements.ts
1082
+ function deriveAuthorizationGrants(contracts, held, options = {}) {
1083
+ const undeclared = options.undeclaredRequirements ?? "withhold";
1084
+ const bound = options.document ? collectDocumentReferences(options.document) : void 0;
1085
+ const permissions = new Set(held.permissions ?? []);
1086
+ const entitlements = new Set(held.entitlements ?? []);
1087
+ const featureFlags = new Set(held.featureFlags ?? []);
1088
+ const views = [];
1089
+ const intents = [];
1090
+ const withheld = [];
1091
+ for (const contract of contracts) {
1092
+ if (undeclared === "withhold" && !declaresAnyRequirement(contract)) {
1093
+ withheld.push({ namespace: contract.namespace, missing: { kind: "permission", name: "(none declared)" } });
1094
+ continue;
1095
+ }
1096
+ const missing = firstMissingRequirement(contract, { permissions, entitlements, featureFlags });
1097
+ if (missing) {
1098
+ withheld.push({ namespace: contract.namespace, missing });
1099
+ continue;
1100
+ }
1101
+ for (const view of contract.views) {
1102
+ const reference = `capability://${contract.namespace}/${shortName(view.name, contract.namespace, "/")}`;
1103
+ if (!bound || bound.views.has(reference)) views.push(reference);
1104
+ }
1105
+ for (const intent of actionIntentsOf(contract)) {
1106
+ const reference = `capability://${contract.namespace}/${shortName(intent.name, contract.namespace, ".")}`;
1107
+ if (!bound || bound.intents.has(reference)) intents.push(reference);
1108
+ }
1109
+ }
1110
+ return {
1111
+ grants: { views: [...new Set(views)].sort(), intents: [...new Set(intents)].sort() },
1112
+ withheld: withheld.sort((left, right) => left.namespace < right.namespace ? -1 : left.namespace > right.namespace ? 1 : 0)
1113
+ };
1114
+ }
1115
+ function declaresAnyRequirement(contract) {
1116
+ const requirements = contract.requirements;
1117
+ return Boolean(
1118
+ requirements && ((requirements.permissions?.length ?? 0) > 0 || (requirements.entitlements?.length ?? 0) > 0 || (requirements.featureFlags?.length ?? 0) > 0)
1119
+ );
1120
+ }
1121
+ function firstMissingRequirement(contract, held) {
1122
+ const requirements = contract.requirements;
1123
+ for (const name of requirements?.permissions ?? []) {
1124
+ if (!held.permissions.has(name)) return { kind: "permission", name };
1125
+ }
1126
+ for (const name of requirements?.entitlements ?? []) {
1127
+ if (!held.entitlements.has(name)) return { kind: "entitlement", name };
1128
+ }
1129
+ for (const name of requirements?.featureFlags ?? []) {
1130
+ if (!held.featureFlags.has(name)) return { kind: "featureFlag", name };
1131
+ }
1132
+ return void 0;
1133
+ }
1134
+ function actionIntentsOf(contract) {
1135
+ const experience = contract.extensions?.["fabric.experience/v1"];
1136
+ return experience?.actionIntents ?? [];
1137
+ }
1138
+ function shortName(name, namespace, separator) {
1139
+ const prefix = `${namespace}${separator}`;
1140
+ return name.startsWith(prefix) ? name.slice(prefix.length) : name;
1141
+ }
842
1142
 
843
1143
  // schemas.ts
844
1144
  var SDUI_DOCUMENT_JSON_SCHEMA = {
@@ -870,24 +1170,280 @@ var SDUI_RELEASE_JSON_SCHEMA = {
870
1170
  grants: { type: "object", required: ["views", "intents"] }
871
1171
  }
872
1172
  };
1173
+
1174
+ // contracts.ts
1175
+ var SDUI_RELEASE_V3_FORMAT_VERSION = 3;
1176
+ var RELEASE_CHANNEL_POINTER_FORMAT_VERSION = 1;
1177
+ var encoder = new TextEncoder();
1178
+ var decoder = new TextDecoder("utf-8", { fatal: true });
1179
+ var HEX_DIGEST = /^[a-f0-9]{64}$/;
1180
+ function canonicalizeJcs(value) {
1181
+ return canonical(value, 0);
1182
+ }
1183
+ function canonical(value, depth) {
1184
+ if (depth > 64) throw new Error("Canonical JSON exceeds the maximum depth of 64.");
1185
+ if (value === null) return "null";
1186
+ if (typeof value === "boolean") return value ? "true" : "false";
1187
+ if (typeof value === "number") {
1188
+ if (!Number.isFinite(value)) throw new Error("Canonical JSON numbers must be finite.");
1189
+ return Object.is(value, -0) ? "0" : JSON.stringify(value);
1190
+ }
1191
+ if (typeof value === "string") {
1192
+ assertValidUnicode(value);
1193
+ return JSON.stringify(value);
1194
+ }
1195
+ if (Array.isArray(value)) return `[${value.map((entry) => canonical(entry, depth + 1)).join(",")}]`;
1196
+ if (typeof value !== "object") throw new Error(`Canonical JSON cannot encode ${typeof value}.`);
1197
+ const record = value;
1198
+ const keys = Object.keys(record).sort();
1199
+ return `{${keys.map((key) => {
1200
+ assertValidUnicode(key);
1201
+ return `${JSON.stringify(key)}:${canonical(record[key], depth + 1)}`;
1202
+ }).join(",")}}`;
1203
+ }
1204
+ function assertValidUnicode(value) {
1205
+ for (let index = 0; index < value.length; index += 1) {
1206
+ const code = value.charCodeAt(index);
1207
+ if (code >= 55296 && code <= 56319) {
1208
+ const next = value.charCodeAt(index + 1);
1209
+ if (!(next >= 56320 && next <= 57343)) throw new Error("Canonical JSON contains an unpaired Unicode surrogate.");
1210
+ index += 1;
1211
+ } else if (code >= 56320 && code <= 57343) {
1212
+ throw new Error("Canonical JSON contains an unpaired Unicode surrogate.");
1213
+ }
1214
+ }
1215
+ }
1216
+ async function verifySignedExperienceArtifact(input) {
1217
+ const maxBytes = input.maxBytes ?? 1048576;
1218
+ if (encoder.encode(input.compactJws).byteLength > maxBytes) {
1219
+ throw new Error(`Signed experience artifact exceeds the ${maxBytes}-byte size limit.`);
1220
+ }
1221
+ const parts = input.compactJws.split(".");
1222
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) throw new Error("Signed experience artifact must be a compact JWS.");
1223
+ const header = parseSegment(parts[0], "protected header");
1224
+ if (header.alg !== "ES256") throw new Error("Signed experience artifacts must use ES256.");
1225
+ if (header.typ !== input.expectedType) throw new Error(`Signed experience artifact type must be ${input.expectedType}.`);
1226
+ if (typeof header.kid !== "string" || !header.kid) throw new Error("Signed experience artifact requires a key id.");
1227
+ const payloadBytes = decodeBase64Url(parts[1]);
1228
+ const payload = parseJsonBytes(payloadBytes, "payload");
1229
+ if (typeof payload.issuer !== "string" || !payload.issuer) throw new Error("Signed experience artifact requires an issuer.");
1230
+ const key = await input.trust.resolveKey({ keyId: header.kid, issuer: payload.issuer, algorithm: "ES256" });
1231
+ if (!key || key.algorithm !== "ES256") throw new Error("Signed experience artifact has no trusted key.");
1232
+ const signature = decodeBase64Url(parts[2]);
1233
+ if (signature.byteLength !== 64) throw new Error("ES256 signatures must be 64-byte JOSE signatures.");
1234
+ const verified = await input.crypto.verify({
1235
+ algorithm: "ES256",
1236
+ key: key.key,
1237
+ signingInput: encoder.encode(`${parts[0]}.${parts[1]}`),
1238
+ signature
1239
+ });
1240
+ if (!verified) throw new Error("Signed experience artifact signature is invalid.");
1241
+ return payload;
1242
+ }
1243
+ async function verifySignedExperienceRelease(input) {
1244
+ const payload = await verifySignedExperienceArtifact({
1245
+ compactJws: input.compactJws,
1246
+ expectedType: "fabric-experience-release+jws",
1247
+ crypto: input.crypto,
1248
+ trust: input.trust,
1249
+ ...input.maxBytes === void 0 ? {} : { maxBytes: input.maxBytes }
1250
+ });
1251
+ assertSduiReleaseV3(payload);
1252
+ if (payload.issuer !== input.expected.issuer || payload.application !== input.expected.application || payload.assemblyDigest !== input.expected.assemblyDigest) {
1253
+ throw new Error("Signed experience release does not match the expected issuer, application, and assembly.");
1254
+ }
1255
+ const { releaseDigest: releaseDigest2, ...body } = payload;
1256
+ const digest = await input.crypto.digestSha256(encoder.encode(canonicalizeJcs(body)));
1257
+ const expectedDigest = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1258
+ if (releaseDigest2 !== expectedDigest) throw new Error("Signed experience release digest does not match its canonical content.");
1259
+ return payload;
1260
+ }
1261
+ function assertSduiReleaseV3(value) {
1262
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("SDUI Release v3 must be an object.");
1263
+ const release = value;
1264
+ if (release.formatVersion !== SDUI_RELEASE_V3_FORMAT_VERSION || release.kind !== "experience-release") {
1265
+ throw new Error("SDUI Release v3 has an unsupported format.");
1266
+ }
1267
+ for (const field of ["issuer", "application", "releaseDigest", "assemblyDigest"]) {
1268
+ if (typeof release[field] !== "string" || !release[field]) throw new Error(`SDUI Release v3 ${field} is required.`);
1269
+ }
1270
+ if (!HEX_DIGEST.test(release.releaseDigest) || !HEX_DIGEST.test(release.assemblyDigest)) {
1271
+ throw new Error("SDUI Release v3 digests must be lowercase SHA-256 values.");
1272
+ }
1273
+ if (!release.document || typeof release.document !== "object" || !release.tokenSet || typeof release.tokenSet !== "object" || !Array.isArray(release.componentPacks) || !release.routes || typeof release.routes !== "object" || !Array.isArray(release.routes.views) || !Array.isArray(release.routes.intents) || !Array.isArray(release.effectiveGrants)) {
1274
+ throw new Error("SDUI Release v3 is structurally incomplete.");
1275
+ }
1276
+ const routes = release.routes;
1277
+ assertUniqueRecords(routes.views, "view route", (route) => {
1278
+ const record = assertRecord(route, "view route");
1279
+ assertCapabilityReference(record.reference, "view route");
1280
+ assertNonEmptyString(record.name, "view route name");
1281
+ assertNonEmptyString(record.version, "view route version");
1282
+ if (record.parameterSchemaDigest !== void 0) {
1283
+ assertHexDigest(record.parameterSchemaDigest, "view route parameterSchemaDigest");
1284
+ }
1285
+ return record.reference;
1286
+ });
1287
+ assertUniqueRecords(routes.intents, "intent route", (route) => {
1288
+ const record = assertRecord(route, "intent route");
1289
+ assertCapabilityReference(record.reference, "intent route");
1290
+ assertNonEmptyString(record.actionId, "intent route actionId");
1291
+ if (!Number.isSafeInteger(record.version) || record.version < 1) {
1292
+ throw new Error("SDUI Release v3 intent route version must be a positive integer.");
1293
+ }
1294
+ if (record.parameterSchemaDigest !== void 0) {
1295
+ assertHexDigest(record.parameterSchemaDigest, "intent route parameterSchemaDigest");
1296
+ }
1297
+ return record.reference;
1298
+ });
1299
+ assertUniqueRecords(release.effectiveGrants, "effective grant", (grant) => {
1300
+ const record = assertRecord(grant, "effective grant");
1301
+ assertNonEmptyString(record.treeId, "effective grant treeId");
1302
+ assertNonEmptyString(record.fragmentId, "effective grant fragmentId");
1303
+ if (!Array.isArray(record.views) || !record.views.every((item) => typeof item === "string") || !Array.isArray(record.intents) || !record.intents.every((item) => typeof item === "string")) {
1304
+ throw new Error("SDUI Release v3 effective grant routes must be string arrays.");
1305
+ }
1306
+ return `${record.treeId}\0${record.fragmentId}`;
1307
+ });
1308
+ }
1309
+ function assertRecord(value, label) {
1310
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`SDUI Release v3 ${label} must be an object.`);
1311
+ return value;
1312
+ }
1313
+ function assertNonEmptyString(value, label) {
1314
+ if (typeof value !== "string" || !value) throw new Error(`SDUI Release v3 ${label} is required.`);
1315
+ }
1316
+ function assertHexDigest(value, label) {
1317
+ if (typeof value !== "string" || !HEX_DIGEST.test(value)) throw new Error(`SDUI Release v3 ${label} must be a lowercase SHA-256 value.`);
1318
+ }
1319
+ function assertCapabilityReference(value, label) {
1320
+ if (typeof value !== "string" || !value.startsWith("capability://")) throw new Error(`SDUI Release v3 ${label} reference is invalid.`);
1321
+ }
1322
+ function assertUniqueRecords(values, label, identity) {
1323
+ const seen = /* @__PURE__ */ new Set();
1324
+ for (const value of values) {
1325
+ const key = identity(value);
1326
+ if (seen.has(key)) throw new Error(`SDUI Release v3 has a duplicate ${label}.`);
1327
+ seen.add(key);
1328
+ }
1329
+ }
1330
+ async function activateReleaseChannelPointer(input) {
1331
+ const payload = await verifySignedExperienceArtifact({
1332
+ compactJws: input.compactJws,
1333
+ expectedType: "fabric-release-channel-pointer+jws",
1334
+ crypto: input.crypto,
1335
+ trust: input.trust
1336
+ });
1337
+ assertReleaseChannelPointer(payload);
1338
+ if (payload.issuer !== input.expected.issuer || payload.application !== input.expected.application || payload.channel !== input.expected.channel) {
1339
+ throw new Error("Release channel pointer does not match the expected issuer, application, and channel.");
1340
+ }
1341
+ const now = (input.now ?? /* @__PURE__ */ new Date()).getTime();
1342
+ if (payload.notBefore !== void 0 && now < parseTimestamp(payload.notBefore, "notBefore")) {
1343
+ throw new Error("Release channel pointer is not active yet.");
1344
+ }
1345
+ if (payload.expiresAt !== void 0 && now >= parseTimestamp(payload.expiresAt, "expiresAt")) {
1346
+ throw new Error("Release channel pointer has expired.");
1347
+ }
1348
+ const generationKey = `${payload.issuer}:${payload.application}:${payload.channel}`;
1349
+ const pointerDigestBytes = await input.crypto.digestSha256(encoder.encode(canonicalizeJcs(payload)));
1350
+ const pointerDigest = [...pointerDigestBytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1351
+ const current = await input.generations.get(generationKey);
1352
+ if (current !== void 0 && payload.generation < current.generation) {
1353
+ throw new Error("Release channel pointer is older than the accepted generation.");
1354
+ }
1355
+ if (current?.generation === payload.generation && current.pointerDigest !== pointerDigest) {
1356
+ throw new Error("Release channel generation is already bound to a different signed pointer.");
1357
+ }
1358
+ if (current?.generation !== payload.generation) {
1359
+ const accepted = await input.generations.acceptIfHigher(generationKey, payload.generation, pointerDigest);
1360
+ if (!accepted) {
1361
+ const raced = await input.generations.get(generationKey);
1362
+ if (raced?.generation !== payload.generation || raced.pointerDigest !== pointerDigest) {
1363
+ throw new Error("Release channel pointer lost an activation race to a different generation.");
1364
+ }
1365
+ }
1366
+ }
1367
+ return payload;
1368
+ }
1369
+ function assertReleaseChannelPointer(value) {
1370
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Release channel pointer must be an object.");
1371
+ const pointer = value;
1372
+ if (pointer.formatVersion !== RELEASE_CHANNEL_POINTER_FORMAT_VERSION || pointer.kind !== "release-channel-pointer") {
1373
+ throw new Error("Release channel pointer has an unsupported format.");
1374
+ }
1375
+ for (const field of ["issuer", "application", "channel", "issuedAt"]) {
1376
+ if (typeof pointer[field] !== "string" || !pointer[field]) throw new Error(`Release channel pointer ${field} is required.`);
1377
+ }
1378
+ if (!Number.isSafeInteger(pointer.generation) || pointer.generation < 0) throw new Error("Release channel pointer generation must be a non-negative safe integer.");
1379
+ if (!HEX_DIGEST.test(String(pointer.activeReleaseDigest)) || !HEX_DIGEST.test(String(pointer.assemblyDigest))) {
1380
+ throw new Error("Release channel pointer digests must be lowercase SHA-256 values.");
1381
+ }
1382
+ if (pointer.fallbackReleaseDigests !== void 0 && (!Array.isArray(pointer.fallbackReleaseDigests) || !pointer.fallbackReleaseDigests.every((digest) => typeof digest === "string" && HEX_DIGEST.test(digest)) || new Set(pointer.fallbackReleaseDigests).size !== pointer.fallbackReleaseDigests.length)) {
1383
+ throw new Error("Release channel pointer fallbackReleaseDigests must contain unique lowercase SHA-256 values.");
1384
+ }
1385
+ parseTimestamp(pointer.issuedAt, "issuedAt");
1386
+ }
1387
+ function parseSegment(segment, label) {
1388
+ return parseJsonBytes(decodeBase64Url(segment), label);
1389
+ }
1390
+ function parseJsonBytes(bytes, label) {
1391
+ let value;
1392
+ try {
1393
+ value = JSON.parse(decoder.decode(bytes));
1394
+ } catch {
1395
+ throw new Error(`Signed experience artifact ${label} is not valid UTF-8 JSON.`);
1396
+ }
1397
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Signed experience artifact ${label} must be an object.`);
1398
+ return value;
1399
+ }
1400
+ function decodeBase64Url(value) {
1401
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("Signed experience artifact contains invalid base64url.");
1402
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
1403
+ let binary;
1404
+ try {
1405
+ binary = atob(padded);
1406
+ } catch {
1407
+ throw new Error("Signed experience artifact contains invalid base64url.");
1408
+ }
1409
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
1410
+ }
1411
+ function parseTimestamp(value, field) {
1412
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value)) throw new Error(`Release channel pointer ${field} must be RFC 3339 with an explicit offset.`);
1413
+ const parsed = Date.parse(value);
1414
+ if (Number.isNaN(parsed)) throw new Error(`Release channel pointer ${field} is invalid.`);
1415
+ return parsed;
1416
+ }
873
1417
  // Annotate the CommonJS export names for ESM import in node:
874
1418
  0 && (module.exports = {
1419
+ RELEASE_CHANNEL_POINTER_FORMAT_VERSION,
875
1420
  SDUI_COMPONENT_PACK_FORMAT_VERSION,
876
1421
  SDUI_DOCUMENT_FORMAT_VERSION,
877
1422
  SDUI_DOCUMENT_JSON_SCHEMA,
878
1423
  SDUI_RELEASE_FORMAT_VERSION,
879
1424
  SDUI_RELEASE_JSON_SCHEMA,
1425
+ SDUI_RELEASE_V3_FORMAT_VERSION,
880
1426
  SDUI_TOKEN_SET_FORMAT_VERSION,
1427
+ activateReleaseChannelPointer,
1428
+ assertReleaseChannelPointer,
881
1429
  assertSduiAuthorizationGrants,
882
1430
  assertSduiComponentPack,
883
1431
  assertSduiDocument,
884
1432
  assertSduiFragment,
885
1433
  assertSduiRelease,
886
1434
  assertSduiReleaseIntegrity,
1435
+ assertSduiReleaseV3,
887
1436
  assertSduiReleaseValid,
888
1437
  assertSduiTokenSet,
1438
+ canonicalizeJcs,
889
1439
  computeReleaseDigest,
890
1440
  createSduiChannel,
891
- validateSduiRelease
1441
+ deriveAuthorizationGrants,
1442
+ parseCapabilityRef,
1443
+ runSduiChannelChecks,
1444
+ sduiChannelChecks,
1445
+ validateSduiRelease,
1446
+ verifySignedExperienceArtifact,
1447
+ verifySignedExperienceRelease
892
1448
  });
893
1449
  //# sourceMappingURL=index.cjs.map