@fabricorg/sdui-release 0.3.0 → 0.6.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,56 @@ 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"];
457
+ const declaredActions = new Set(contract.actions.map((action) => action.actionId));
392
458
  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}`);
459
+ if (!declaredActions.has(intent.actionId)) continue;
460
+ const shortName2 = intent.name.startsWith(`${contract.namespace}.`) ? intent.name.slice(contract.namespace.length + 1) : intent.name;
461
+ const reference = `${contract.namespace}/${shortName2}`;
462
+ if (grantedIntents === void 0 || grantedIntents.has(`capability://${reference}`)) {
463
+ occurrences.set(reference, [...occurrences.get(reference) ?? [], intent.name]);
464
+ }
465
+ intents.add(reference);
466
+ }
467
+ }
468
+ for (const [reference, declarations] of occurrences) {
469
+ if (declarations.length > 1) {
470
+ add?.(
471
+ "ambiguous_capability_reference",
472
+ `contracts.actionIntents.${reference}`,
473
+ `"capability://${reference}" is declared ${declarations.length} times (${declarations.join(", ")}), so a route for it cannot be resolved`
474
+ );
395
475
  }
396
476
  }
397
477
  return intents;
@@ -585,18 +665,13 @@ function validateSduiRelease(input) {
585
665
  const add = (code, path, message) => {
586
666
  findings.push({ code, path, message });
587
667
  };
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) {
668
+ const promotableTokenSets = [input.document.tokenSet, ...input.document.supportedTokenSets ?? []];
669
+ const promotedAgainst = promotableTokenSets.some((candidate) => candidate.name === input.tokenSet.name && candidate.version === input.tokenSet.version);
670
+ if (!promotedAgainst) {
596
671
  add(
597
672
  "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}"`
673
+ "document.tokenSet",
674
+ `document supports token sets [${promotableTokenSets.map((set) => `${set.name}@${set.version}`).join(", ")}] but the provided set is "${input.tokenSet.name}@${input.tokenSet.version}"`
600
675
  );
601
676
  }
602
677
  const packsByKey = /* @__PURE__ */ new Map();
@@ -712,9 +787,43 @@ function validateSduiRelease(input) {
712
787
  }
713
788
  const coreComponents = new Set(input.coreComponents ?? []);
714
789
  const knownViews = buildKnownViews(contracts);
715
- const knownIntents = buildKnownActionIntents(contracts);
716
790
  const grantedViews = buildGrantedReferences(input.grants.views, "grants.views");
717
791
  const grantedIntents = buildGrantedReferences(input.grants.intents, "grants.intents");
792
+ const contractNamespaces = /* @__PURE__ */ new Set();
793
+ const distinctContracts = contracts.filter((contract) => {
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
+ return false;
801
+ }
802
+ contractNamespaces.add(contract.namespace);
803
+ return true;
804
+ });
805
+ const knownIntents = buildKnownActionIntents(distinctContracts, add, grantedIntents);
806
+ reportAmbiguousGrantedViews(distinctContracts, grantedViews, add);
807
+ for (const reference of grantedViews) {
808
+ const parsed = parseCapabilityRef(reference);
809
+ if (parsed && !knownViews.has(`${parsed.namespace}/${parsed.name}`)) {
810
+ add(
811
+ "unauthorized_data_ref",
812
+ `grants.views.${reference}`,
813
+ `release grants "${reference}", which no supplied capability publishes; promotion would fail to route it`
814
+ );
815
+ }
816
+ }
817
+ for (const reference of grantedIntents) {
818
+ const parsed = parseCapabilityRef(reference);
819
+ if (parsed && !knownIntents.has(`${parsed.namespace}/${parsed.name}`)) {
820
+ add(
821
+ "mutation_bypass",
822
+ `grants.intents.${reference}`,
823
+ `release grants "${reference}", which no supplied capability declares as an intent; promotion would fail to route it`
824
+ );
825
+ }
826
+ }
718
827
  validateIntentActionDeclarations(contracts, add);
719
828
  const fragmentGrants = /* @__PURE__ */ new Map();
720
829
  for (const [fragmentId, declared] of Object.entries(input.grants.fragments ?? {})) {
@@ -839,6 +948,221 @@ function collectCoreComponents(fragment) {
839
948
  }
840
949
  return used;
841
950
  }
951
+ async function refusalReason(run, expect) {
952
+ try {
953
+ await run();
954
+ return null;
955
+ } catch (error) {
956
+ const message = error instanceof Error ? error.message : String(error);
957
+ return expect.test(message) ? message : null;
958
+ }
959
+ }
960
+ function sduiChannelChecks() {
961
+ return [
962
+ {
963
+ id: "fabric.sdui-channel.verified-release.v1",
964
+ async run(subject) {
965
+ assertSduiReleaseIntegrity(subject.validRelease);
966
+ const result = subject.channel.consume(subject.validRelease);
967
+ if (result.release.releaseDigest !== subject.validRelease.releaseDigest) {
968
+ throw new Error("channel returned a release other than the one it was given");
969
+ }
970
+ return [subject.validRelease.releaseDigest];
971
+ }
972
+ },
973
+ {
974
+ id: "fabric.sdui-channel.tampered-release-denial.v1",
975
+ async run(subject) {
976
+ const tampered = {
977
+ ...subject.validRelease,
978
+ document: { ...subject.validRelease.document, name: `${subject.validRelease.document.name}-tampered` }
979
+ };
980
+ const reason = await refusalReason(() => subject.channel.consume(tampered), /digest|integrity/i);
981
+ if (reason === null) {
982
+ throw new Error("channel did not refuse a release whose content no longer matches its digest, citing the digest");
983
+ }
984
+ for (const [label, mutate] of [
985
+ ["version", (release) => ({ ...release, document: { ...release.document, version: "9.9.9" } })],
986
+ ["grants", (release) => ({ ...release, grants: { ...release.grants, views: [...release.grants.views, "capability://smuggled/view"] } })],
987
+ ["assembly", (release) => ({ ...release, assemblyDigest: "f".repeat(64) })]
988
+ ]) {
989
+ const edited = mutate(subject.validRelease);
990
+ if (await refusalReason(() => subject.channel.consume(edited), /digest|integrity/i) === null) {
991
+ throw new Error(`channel accepted a release whose ${label} was edited after promotion`);
992
+ }
993
+ }
994
+ return ["tampered document refused", "edited version, grants and assembly refused"];
995
+ }
996
+ },
997
+ {
998
+ id: "fabric.sdui-channel.unpromoted-document-denial.v1",
999
+ async run(subject) {
1000
+ const expected = /digest|integrity|release|formatVersion|must be/i;
1001
+ const unpromoted = { document: subject.validRelease.document };
1002
+ if (await refusalReason(() => subject.channel.consume(unpromoted), expected) === null) {
1003
+ throw new Error("channel did not refuse a bare document that was never promoted, citing what was wrong with it");
1004
+ }
1005
+ const digestless = { ...subject.validRelease, releaseDigest: void 0 };
1006
+ if (await refusalReason(() => subject.channel.consume(digestless), expected) === null) {
1007
+ throw new Error("channel did not refuse a release carrying no digest, citing what was wrong with it");
1008
+ }
1009
+ return ["bare document refused", "digestless release refused"];
1010
+ }
1011
+ },
1012
+ {
1013
+ id: "fabric.sdui-channel.reports-missing-vocabulary.v1",
1014
+ async run(subject) {
1015
+ const used = collectCoreComponents(subject.validRelease.document.root);
1016
+ for (const variant of subject.validRelease.document.variants ?? []) {
1017
+ for (const component of collectCoreComponents(variant.root)) used.add(component);
1018
+ }
1019
+ if (!used.has(subject.unimplementedCoreComponent)) {
1020
+ throw new Error(
1021
+ `conformance subject is vacuous: validRelease never uses "${subject.unimplementedCoreComponent}", so the channel is never asked to report it`
1022
+ );
1023
+ }
1024
+ if (subject.channel.coreComponents.includes(subject.unimplementedCoreComponent)) {
1025
+ throw new Error(
1026
+ `conformance subject is contradictory: the channel implements "${subject.unimplementedCoreComponent}"`
1027
+ );
1028
+ }
1029
+ const result = subject.channel.consume(subject.validRelease);
1030
+ if (result.compatible) {
1031
+ throw new Error("channel reported compatible for a release using a component it does not implement");
1032
+ }
1033
+ if (!result.missingCoreComponents.includes(subject.unimplementedCoreComponent)) {
1034
+ throw new Error("channel reported incompatible without naming the component it cannot render");
1035
+ }
1036
+ return [`missing: ${result.missingCoreComponents.join(", ")}`];
1037
+ }
1038
+ },
1039
+ {
1040
+ id: "fabric.sdui-channel.consumption-is-pure.v1",
1041
+ async run(subject) {
1042
+ const before = JSON.stringify(subject.validRelease);
1043
+ subject.channel.consume(subject.validRelease);
1044
+ subject.channel.consume(subject.validRelease);
1045
+ if (JSON.stringify(subject.validRelease) !== before) {
1046
+ throw new Error("channel mutated the release it consumed");
1047
+ }
1048
+ return ["release unchanged after two consumptions"];
1049
+ }
1050
+ }
1051
+ ];
1052
+ }
1053
+ async function runSduiChannelChecks(subject) {
1054
+ const checks = [];
1055
+ for (const check of sduiChannelChecks()) {
1056
+ try {
1057
+ checks.push({ id: check.id, status: "passed", evidence: await check.run(subject) });
1058
+ } catch (error) {
1059
+ checks.push({
1060
+ id: check.id,
1061
+ status: "failed",
1062
+ evidence: [],
1063
+ error: error instanceof Error ? error.message : String(error)
1064
+ });
1065
+ }
1066
+ }
1067
+ return { passed: checks.every((check) => check.status === "passed"), checks };
1068
+ }
1069
+
1070
+ // references.ts
1071
+ function collectDocumentReferences(document) {
1072
+ const views = /* @__PURE__ */ new Set();
1073
+ const intents = /* @__PURE__ */ new Set();
1074
+ const trees = [document.root, ...(document.variants ?? []).map((variant) => variant.root)];
1075
+ for (const tree of trees) walk(tree, views, intents);
1076
+ return { views, intents };
1077
+ }
1078
+ function walk(fragment, views, intents) {
1079
+ if (fragment.dataRef) views.add(fragment.dataRef);
1080
+ for (const action of Object.values(fragment.actions ?? {})) {
1081
+ intents.add(action.intent);
1082
+ if (action.params) collectValues(action.params, views);
1083
+ }
1084
+ if (fragment.props) collectValues(fragment.props, views);
1085
+ for (const child of fragment.children ?? []) walk(child, views, intents);
1086
+ }
1087
+ function collectValues(props, views) {
1088
+ for (const value of Object.values(props)) collectValue(value, views);
1089
+ }
1090
+ function collectValue(value, views) {
1091
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && "$ref" in value) {
1092
+ views.add(value.$ref);
1093
+ for (const [key, sibling] of Object.entries(value)) {
1094
+ if (key !== "$ref") collectValue(sibling, views);
1095
+ }
1096
+ return;
1097
+ }
1098
+ if (Array.isArray(value)) {
1099
+ for (const item of value) collectValue(item, views);
1100
+ return;
1101
+ }
1102
+ if (value !== null && typeof value === "object") collectValues(value, views);
1103
+ }
1104
+
1105
+ // entitlements.ts
1106
+ function deriveAuthorizationGrants(contracts, held, options = {}) {
1107
+ const undeclared = options.undeclaredRequirements ?? "withhold";
1108
+ const bound = options.document ? collectDocumentReferences(options.document) : void 0;
1109
+ const permissions = new Set(held.permissions ?? []);
1110
+ const entitlements = new Set(held.entitlements ?? []);
1111
+ const featureFlags = new Set(held.featureFlags ?? []);
1112
+ const views = [];
1113
+ const intents = [];
1114
+ const withheld = [];
1115
+ for (const contract of contracts) {
1116
+ if (undeclared === "withhold" && !declaresAnyRequirement(contract)) {
1117
+ withheld.push({ namespace: contract.namespace, missing: { kind: "permission", name: "(none declared)" } });
1118
+ continue;
1119
+ }
1120
+ const missing = firstMissingRequirement(contract, { permissions, entitlements, featureFlags });
1121
+ if (missing) {
1122
+ withheld.push({ namespace: contract.namespace, missing });
1123
+ continue;
1124
+ }
1125
+ for (const view of contract.views) {
1126
+ const reference = `capability://${contract.namespace}/${shortName(view.name, contract.namespace, "/")}`;
1127
+ if (!bound || bound.views.has(reference)) views.push(reference);
1128
+ }
1129
+ for (const intent of actionIntentsOf(contract)) {
1130
+ const reference = `capability://${contract.namespace}/${shortName(intent.name, contract.namespace, ".")}`;
1131
+ if (!bound || bound.intents.has(reference)) intents.push(reference);
1132
+ }
1133
+ }
1134
+ return {
1135
+ grants: { views: [...new Set(views)].sort(), intents: [...new Set(intents)].sort() },
1136
+ withheld: withheld.sort((left, right) => left.namespace < right.namespace ? -1 : left.namespace > right.namespace ? 1 : 0)
1137
+ };
1138
+ }
1139
+ function declaresAnyRequirement(contract) {
1140
+ const requirements = contract.requirements;
1141
+ return Boolean(
1142
+ requirements && ((requirements.permissions?.length ?? 0) > 0 || (requirements.entitlements?.length ?? 0) > 0 || (requirements.featureFlags?.length ?? 0) > 0)
1143
+ );
1144
+ }
1145
+ function firstMissingRequirement(contract, held) {
1146
+ const requirements = contract.requirements;
1147
+ for (const name of requirements?.permissions ?? []) {
1148
+ if (!held.permissions.has(name)) return { kind: "permission", name };
1149
+ }
1150
+ for (const name of requirements?.entitlements ?? []) {
1151
+ if (!held.entitlements.has(name)) return { kind: "entitlement", name };
1152
+ }
1153
+ for (const name of requirements?.featureFlags ?? []) {
1154
+ if (!held.featureFlags.has(name)) return { kind: "featureFlag", name };
1155
+ }
1156
+ return void 0;
1157
+ }
1158
+ function actionIntentsOf(contract) {
1159
+ const experience = contract.extensions?.["fabric.experience/v1"];
1160
+ return experience?.actionIntents ?? [];
1161
+ }
1162
+ function shortName(name, namespace, separator) {
1163
+ const prefix = `${namespace}${separator}`;
1164
+ return name.startsWith(prefix) ? name.slice(prefix.length) : name;
1165
+ }
842
1166
 
843
1167
  // schemas.ts
844
1168
  var SDUI_DOCUMENT_JSON_SCHEMA = {
@@ -870,24 +1194,280 @@ var SDUI_RELEASE_JSON_SCHEMA = {
870
1194
  grants: { type: "object", required: ["views", "intents"] }
871
1195
  }
872
1196
  };
1197
+
1198
+ // contracts.ts
1199
+ var SDUI_RELEASE_V3_FORMAT_VERSION = 3;
1200
+ var RELEASE_CHANNEL_POINTER_FORMAT_VERSION = 1;
1201
+ var encoder = new TextEncoder();
1202
+ var decoder = new TextDecoder("utf-8", { fatal: true });
1203
+ var HEX_DIGEST = /^[a-f0-9]{64}$/;
1204
+ function canonicalizeJcs(value) {
1205
+ return canonical(value, 0);
1206
+ }
1207
+ function canonical(value, depth) {
1208
+ if (depth > 64) throw new Error("Canonical JSON exceeds the maximum depth of 64.");
1209
+ if (value === null) return "null";
1210
+ if (typeof value === "boolean") return value ? "true" : "false";
1211
+ if (typeof value === "number") {
1212
+ if (!Number.isFinite(value)) throw new Error("Canonical JSON numbers must be finite.");
1213
+ return Object.is(value, -0) ? "0" : JSON.stringify(value);
1214
+ }
1215
+ if (typeof value === "string") {
1216
+ assertValidUnicode(value);
1217
+ return JSON.stringify(value);
1218
+ }
1219
+ if (Array.isArray(value)) return `[${value.map((entry) => canonical(entry, depth + 1)).join(",")}]`;
1220
+ if (typeof value !== "object") throw new Error(`Canonical JSON cannot encode ${typeof value}.`);
1221
+ const record = value;
1222
+ const keys = Object.keys(record).sort();
1223
+ return `{${keys.map((key) => {
1224
+ assertValidUnicode(key);
1225
+ return `${JSON.stringify(key)}:${canonical(record[key], depth + 1)}`;
1226
+ }).join(",")}}`;
1227
+ }
1228
+ function assertValidUnicode(value) {
1229
+ for (let index = 0; index < value.length; index += 1) {
1230
+ const code = value.charCodeAt(index);
1231
+ if (code >= 55296 && code <= 56319) {
1232
+ const next = value.charCodeAt(index + 1);
1233
+ if (!(next >= 56320 && next <= 57343)) throw new Error("Canonical JSON contains an unpaired Unicode surrogate.");
1234
+ index += 1;
1235
+ } else if (code >= 56320 && code <= 57343) {
1236
+ throw new Error("Canonical JSON contains an unpaired Unicode surrogate.");
1237
+ }
1238
+ }
1239
+ }
1240
+ async function verifySignedExperienceArtifact(input) {
1241
+ const maxBytes = input.maxBytes ?? 1048576;
1242
+ if (encoder.encode(input.compactJws).byteLength > maxBytes) {
1243
+ throw new Error(`Signed experience artifact exceeds the ${maxBytes}-byte size limit.`);
1244
+ }
1245
+ const parts = input.compactJws.split(".");
1246
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) throw new Error("Signed experience artifact must be a compact JWS.");
1247
+ const header = parseSegment(parts[0], "protected header");
1248
+ if (header.alg !== "ES256") throw new Error("Signed experience artifacts must use ES256.");
1249
+ if (header.typ !== input.expectedType) throw new Error(`Signed experience artifact type must be ${input.expectedType}.`);
1250
+ if (typeof header.kid !== "string" || !header.kid) throw new Error("Signed experience artifact requires a key id.");
1251
+ const payloadBytes = decodeBase64Url(parts[1]);
1252
+ const payload = parseJsonBytes(payloadBytes, "payload");
1253
+ if (typeof payload.issuer !== "string" || !payload.issuer) throw new Error("Signed experience artifact requires an issuer.");
1254
+ const key = await input.trust.resolveKey({ keyId: header.kid, issuer: payload.issuer, algorithm: "ES256" });
1255
+ if (!key || key.algorithm !== "ES256") throw new Error("Signed experience artifact has no trusted key.");
1256
+ const signature = decodeBase64Url(parts[2]);
1257
+ if (signature.byteLength !== 64) throw new Error("ES256 signatures must be 64-byte JOSE signatures.");
1258
+ const verified = await input.crypto.verify({
1259
+ algorithm: "ES256",
1260
+ key: key.key,
1261
+ signingInput: encoder.encode(`${parts[0]}.${parts[1]}`),
1262
+ signature
1263
+ });
1264
+ if (!verified) throw new Error("Signed experience artifact signature is invalid.");
1265
+ return payload;
1266
+ }
1267
+ async function verifySignedExperienceRelease(input) {
1268
+ const payload = await verifySignedExperienceArtifact({
1269
+ compactJws: input.compactJws,
1270
+ expectedType: "fabric-experience-release+jws",
1271
+ crypto: input.crypto,
1272
+ trust: input.trust,
1273
+ ...input.maxBytes === void 0 ? {} : { maxBytes: input.maxBytes }
1274
+ });
1275
+ assertSduiReleaseV3(payload);
1276
+ if (payload.issuer !== input.expected.issuer || payload.application !== input.expected.application || payload.assemblyDigest !== input.expected.assemblyDigest) {
1277
+ throw new Error("Signed experience release does not match the expected issuer, application, and assembly.");
1278
+ }
1279
+ const { releaseDigest: releaseDigest2, ...body } = payload;
1280
+ const digest = await input.crypto.digestSha256(encoder.encode(canonicalizeJcs(body)));
1281
+ const expectedDigest = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1282
+ if (releaseDigest2 !== expectedDigest) throw new Error("Signed experience release digest does not match its canonical content.");
1283
+ return payload;
1284
+ }
1285
+ function assertSduiReleaseV3(value) {
1286
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("SDUI Release v3 must be an object.");
1287
+ const release = value;
1288
+ if (release.formatVersion !== SDUI_RELEASE_V3_FORMAT_VERSION || release.kind !== "experience-release") {
1289
+ throw new Error("SDUI Release v3 has an unsupported format.");
1290
+ }
1291
+ for (const field of ["issuer", "application", "releaseDigest", "assemblyDigest"]) {
1292
+ if (typeof release[field] !== "string" || !release[field]) throw new Error(`SDUI Release v3 ${field} is required.`);
1293
+ }
1294
+ if (!HEX_DIGEST.test(release.releaseDigest) || !HEX_DIGEST.test(release.assemblyDigest)) {
1295
+ throw new Error("SDUI Release v3 digests must be lowercase SHA-256 values.");
1296
+ }
1297
+ 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)) {
1298
+ throw new Error("SDUI Release v3 is structurally incomplete.");
1299
+ }
1300
+ const routes = release.routes;
1301
+ assertUniqueRecords(routes.views, "view route", (route) => {
1302
+ const record = assertRecord(route, "view route");
1303
+ assertCapabilityReference(record.reference, "view route");
1304
+ assertNonEmptyString(record.name, "view route name");
1305
+ assertNonEmptyString(record.version, "view route version");
1306
+ if (record.parameterSchemaDigest !== void 0) {
1307
+ assertHexDigest(record.parameterSchemaDigest, "view route parameterSchemaDigest");
1308
+ }
1309
+ return record.reference;
1310
+ });
1311
+ assertUniqueRecords(routes.intents, "intent route", (route) => {
1312
+ const record = assertRecord(route, "intent route");
1313
+ assertCapabilityReference(record.reference, "intent route");
1314
+ assertNonEmptyString(record.actionId, "intent route actionId");
1315
+ if (!Number.isSafeInteger(record.version) || record.version < 1) {
1316
+ throw new Error("SDUI Release v3 intent route version must be a positive integer.");
1317
+ }
1318
+ if (record.parameterSchemaDigest !== void 0) {
1319
+ assertHexDigest(record.parameterSchemaDigest, "intent route parameterSchemaDigest");
1320
+ }
1321
+ return record.reference;
1322
+ });
1323
+ assertUniqueRecords(release.effectiveGrants, "effective grant", (grant) => {
1324
+ const record = assertRecord(grant, "effective grant");
1325
+ assertNonEmptyString(record.treeId, "effective grant treeId");
1326
+ assertNonEmptyString(record.fragmentId, "effective grant fragmentId");
1327
+ if (!Array.isArray(record.views) || !record.views.every((item) => typeof item === "string") || !Array.isArray(record.intents) || !record.intents.every((item) => typeof item === "string")) {
1328
+ throw new Error("SDUI Release v3 effective grant routes must be string arrays.");
1329
+ }
1330
+ return `${record.treeId}\0${record.fragmentId}`;
1331
+ });
1332
+ }
1333
+ function assertRecord(value, label) {
1334
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`SDUI Release v3 ${label} must be an object.`);
1335
+ return value;
1336
+ }
1337
+ function assertNonEmptyString(value, label) {
1338
+ if (typeof value !== "string" || !value) throw new Error(`SDUI Release v3 ${label} is required.`);
1339
+ }
1340
+ function assertHexDigest(value, label) {
1341
+ if (typeof value !== "string" || !HEX_DIGEST.test(value)) throw new Error(`SDUI Release v3 ${label} must be a lowercase SHA-256 value.`);
1342
+ }
1343
+ function assertCapabilityReference(value, label) {
1344
+ if (typeof value !== "string" || !value.startsWith("capability://")) throw new Error(`SDUI Release v3 ${label} reference is invalid.`);
1345
+ }
1346
+ function assertUniqueRecords(values, label, identity) {
1347
+ const seen = /* @__PURE__ */ new Set();
1348
+ for (const value of values) {
1349
+ const key = identity(value);
1350
+ if (seen.has(key)) throw new Error(`SDUI Release v3 has a duplicate ${label}.`);
1351
+ seen.add(key);
1352
+ }
1353
+ }
1354
+ async function activateReleaseChannelPointer(input) {
1355
+ const payload = await verifySignedExperienceArtifact({
1356
+ compactJws: input.compactJws,
1357
+ expectedType: "fabric-release-channel-pointer+jws",
1358
+ crypto: input.crypto,
1359
+ trust: input.trust
1360
+ });
1361
+ assertReleaseChannelPointer(payload);
1362
+ if (payload.issuer !== input.expected.issuer || payload.application !== input.expected.application || payload.channel !== input.expected.channel) {
1363
+ throw new Error("Release channel pointer does not match the expected issuer, application, and channel.");
1364
+ }
1365
+ const now = (input.now ?? /* @__PURE__ */ new Date()).getTime();
1366
+ if (payload.notBefore !== void 0 && now < parseTimestamp(payload.notBefore, "notBefore")) {
1367
+ throw new Error("Release channel pointer is not active yet.");
1368
+ }
1369
+ if (payload.expiresAt !== void 0 && now >= parseTimestamp(payload.expiresAt, "expiresAt")) {
1370
+ throw new Error("Release channel pointer has expired.");
1371
+ }
1372
+ const generationKey = `${payload.issuer}:${payload.application}:${payload.channel}`;
1373
+ const pointerDigestBytes = await input.crypto.digestSha256(encoder.encode(canonicalizeJcs(payload)));
1374
+ const pointerDigest = [...pointerDigestBytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1375
+ const current = await input.generations.get(generationKey);
1376
+ if (current !== void 0 && payload.generation < current.generation) {
1377
+ throw new Error("Release channel pointer is older than the accepted generation.");
1378
+ }
1379
+ if (current?.generation === payload.generation && current.pointerDigest !== pointerDigest) {
1380
+ throw new Error("Release channel generation is already bound to a different signed pointer.");
1381
+ }
1382
+ if (current?.generation !== payload.generation) {
1383
+ const accepted = await input.generations.acceptIfHigher(generationKey, payload.generation, pointerDigest);
1384
+ if (!accepted) {
1385
+ const raced = await input.generations.get(generationKey);
1386
+ if (raced?.generation !== payload.generation || raced.pointerDigest !== pointerDigest) {
1387
+ throw new Error("Release channel pointer lost an activation race to a different generation.");
1388
+ }
1389
+ }
1390
+ }
1391
+ return payload;
1392
+ }
1393
+ function assertReleaseChannelPointer(value) {
1394
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Release channel pointer must be an object.");
1395
+ const pointer = value;
1396
+ if (pointer.formatVersion !== RELEASE_CHANNEL_POINTER_FORMAT_VERSION || pointer.kind !== "release-channel-pointer") {
1397
+ throw new Error("Release channel pointer has an unsupported format.");
1398
+ }
1399
+ for (const field of ["issuer", "application", "channel", "issuedAt"]) {
1400
+ if (typeof pointer[field] !== "string" || !pointer[field]) throw new Error(`Release channel pointer ${field} is required.`);
1401
+ }
1402
+ if (!Number.isSafeInteger(pointer.generation) || pointer.generation < 0) throw new Error("Release channel pointer generation must be a non-negative safe integer.");
1403
+ if (!HEX_DIGEST.test(String(pointer.activeReleaseDigest)) || !HEX_DIGEST.test(String(pointer.assemblyDigest))) {
1404
+ throw new Error("Release channel pointer digests must be lowercase SHA-256 values.");
1405
+ }
1406
+ 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)) {
1407
+ throw new Error("Release channel pointer fallbackReleaseDigests must contain unique lowercase SHA-256 values.");
1408
+ }
1409
+ parseTimestamp(pointer.issuedAt, "issuedAt");
1410
+ }
1411
+ function parseSegment(segment, label) {
1412
+ return parseJsonBytes(decodeBase64Url(segment), label);
1413
+ }
1414
+ function parseJsonBytes(bytes, label) {
1415
+ let value;
1416
+ try {
1417
+ value = JSON.parse(decoder.decode(bytes));
1418
+ } catch {
1419
+ throw new Error(`Signed experience artifact ${label} is not valid UTF-8 JSON.`);
1420
+ }
1421
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Signed experience artifact ${label} must be an object.`);
1422
+ return value;
1423
+ }
1424
+ function decodeBase64Url(value) {
1425
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("Signed experience artifact contains invalid base64url.");
1426
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
1427
+ let binary;
1428
+ try {
1429
+ binary = atob(padded);
1430
+ } catch {
1431
+ throw new Error("Signed experience artifact contains invalid base64url.");
1432
+ }
1433
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
1434
+ }
1435
+ function parseTimestamp(value, field) {
1436
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value)) throw new Error(`Release channel pointer ${field} must be RFC 3339 with an explicit offset.`);
1437
+ const parsed = Date.parse(value);
1438
+ if (Number.isNaN(parsed)) throw new Error(`Release channel pointer ${field} is invalid.`);
1439
+ return parsed;
1440
+ }
873
1441
  // Annotate the CommonJS export names for ESM import in node:
874
1442
  0 && (module.exports = {
1443
+ RELEASE_CHANNEL_POINTER_FORMAT_VERSION,
875
1444
  SDUI_COMPONENT_PACK_FORMAT_VERSION,
876
1445
  SDUI_DOCUMENT_FORMAT_VERSION,
877
1446
  SDUI_DOCUMENT_JSON_SCHEMA,
878
1447
  SDUI_RELEASE_FORMAT_VERSION,
879
1448
  SDUI_RELEASE_JSON_SCHEMA,
1449
+ SDUI_RELEASE_V3_FORMAT_VERSION,
880
1450
  SDUI_TOKEN_SET_FORMAT_VERSION,
1451
+ activateReleaseChannelPointer,
1452
+ assertReleaseChannelPointer,
881
1453
  assertSduiAuthorizationGrants,
882
1454
  assertSduiComponentPack,
883
1455
  assertSduiDocument,
884
1456
  assertSduiFragment,
885
1457
  assertSduiRelease,
886
1458
  assertSduiReleaseIntegrity,
1459
+ assertSduiReleaseV3,
887
1460
  assertSduiReleaseValid,
888
1461
  assertSduiTokenSet,
1462
+ canonicalizeJcs,
889
1463
  computeReleaseDigest,
890
1464
  createSduiChannel,
891
- validateSduiRelease
1465
+ deriveAuthorizationGrants,
1466
+ parseCapabilityRef,
1467
+ runSduiChannelChecks,
1468
+ sduiChannelChecks,
1469
+ validateSduiRelease,
1470
+ verifySignedExperienceArtifact,
1471
+ verifySignedExperienceRelease
892
1472
  });
893
1473
  //# sourceMappingURL=index.cjs.map