capdag 0.149.334 → 0.153.347

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.
Files changed (3) hide show
  1. package/capdag.js +309 -116
  2. package/capdag.test.js +223 -51
  3. package/package.json +2 -2
package/capdag.js CHANGED
@@ -3613,6 +3613,54 @@ class CartridgeCapGroup {
3613
3613
  }
3614
3614
  }
3615
3615
 
3616
+ // =============================================================================
3617
+ // Cartridge registry slug
3618
+ // =============================================================================
3619
+ //
3620
+ // Deterministic mapping from a registry URL to a top-level folder
3621
+ // name under the cartridges install root. Mirrors
3622
+ // capdag::cartridge_slug byte-for-byte: SHA-256 of the URL bytes,
3623
+ // lowercase hex, first 16 chars. The literal string "dev" is
3624
+ // reserved for dev cartridges that have no registry.
3625
+ //
3626
+ // JS uses Web Crypto's SubtleCrypto for SHA-256. The function is
3627
+ // async because `crypto.subtle.digest` returns a Promise; consumers
3628
+ // in synchronous contexts must await.
3629
+
3630
+ const DEV_SLUG = "dev";
3631
+ const SLUG_HEX_LEN = 16;
3632
+
3633
+ /**
3634
+ * Compute the on-disk slug for a registry URL.
3635
+ *
3636
+ * @param {string|null|undefined} registryUrl - The registry URL, or
3637
+ * null/undefined for dev installs.
3638
+ * @returns {Promise<string>} The slug — `DEV_SLUG` for null,
3639
+ * otherwise the first 16 lowercase-hex characters of
3640
+ * sha256(registryUrl).
3641
+ */
3642
+ async function slugForRegistryUrl(registryUrl) {
3643
+ if (registryUrl === null || registryUrl === undefined) {
3644
+ return DEV_SLUG;
3645
+ }
3646
+ const bytes = new TextEncoder().encode(registryUrl);
3647
+ // Web Crypto exposes SHA-256 via crypto.subtle. Node 16+ exposes
3648
+ // it through globalThis.crypto.subtle.
3649
+ const digestBuffer = await crypto.subtle.digest("SHA-256", bytes);
3650
+ const digestBytes = new Uint8Array(digestBuffer);
3651
+ let hex = "";
3652
+ for (const b of digestBytes) {
3653
+ hex += b.toString(16).padStart(2, "0");
3654
+ }
3655
+ return hex.slice(0, SLUG_HEX_LEN);
3656
+ }
3657
+
3658
+ function isRegistrySlug(s) {
3659
+ return typeof s === "string"
3660
+ && s.length === SLUG_HEX_LEN
3661
+ && /^[0-9a-f]+$/.test(s);
3662
+ }
3663
+
3616
3664
  /**
3617
3665
  * Cartridge information from registry
3618
3666
  */
@@ -3634,6 +3682,22 @@ class CartridgeInfo {
3634
3682
  // Versions with platform-specific builds
3635
3683
  this.versions = data.versions || {};
3636
3684
  this.availableVersions = data.availableVersions || [];
3685
+ // Channel: 'release' or 'nightly'. Required — set by the registry
3686
+ // transformer when flattening the channel-partitioned registry.
3687
+ if (data.channel !== 'release' && data.channel !== 'nightly') {
3688
+ throw new Error(`CartridgeInfo ${data.id || '?'}: invalid or missing channel '${data.channel}'`);
3689
+ }
3690
+ this.channel = data.channel;
3691
+ // Registry URL: verbatim string the registry was fetched from.
3692
+ // Required and non-empty — every CartridgeInfo carries the URL of
3693
+ // the registry that served it so downstream consumers can build
3694
+ // the (registryUrl, channel, id) identity tuple without
3695
+ // re-deriving it. The registry transformer stamps this onto every
3696
+ // entry at flatten time.
3697
+ if (typeof data.registryUrl !== 'string' || data.registryUrl.length === 0) {
3698
+ throw new Error(`CartridgeInfo ${data.id || '?'}: registryUrl is required and must be a non-empty string`);
3699
+ }
3700
+ this.registryUrl = data.registryUrl;
3637
3701
  }
3638
3702
 
3639
3703
  /** All caps flattened across all cap_groups, deduplicated by URN */
@@ -3682,7 +3746,13 @@ class CartridgeInfo {
3682
3746
  }
3683
3747
 
3684
3748
  /**
3685
- * Cartridge suggestion for a missing cap
3749
+ * Cartridge suggestion for a missing cap.
3750
+ *
3751
+ * `(registryUrl, channel, cartridgeId)` is the suggesting
3752
+ * cartridge's full identity — installs of the same id from
3753
+ * different registries × channels are independent records and the
3754
+ * client keeps both visible. `registryUrl` is required and
3755
+ * non-empty; suggestions never come from dev installs.
3686
3756
  */
3687
3757
  class CartridgeSuggestion {
3688
3758
  constructor(data) {
@@ -3694,21 +3764,35 @@ class CartridgeSuggestion {
3694
3764
  this.latestVersion = data.latestVersion;
3695
3765
  this.repoUrl = data.repoUrl;
3696
3766
  this.pageUrl = data.pageUrl;
3767
+ if (data.channel !== 'release' && data.channel !== 'nightly') {
3768
+ throw new Error(`CartridgeSuggestion: invalid or missing channel '${data.channel}'`);
3769
+ }
3770
+ this.channel = data.channel;
3771
+ if (typeof data.registryUrl !== 'string' || data.registryUrl.length === 0) {
3772
+ throw new Error("CartridgeSuggestion: registryUrl is required and must be a non-empty string");
3773
+ }
3774
+ this.registryUrl = data.registryUrl;
3697
3775
  }
3698
3776
  }
3699
3777
 
3700
3778
  /**
3701
- * Cartridge registry cache entry
3779
+ * Cartridge registry cache entry. The cartridges map is keyed by
3780
+ * `<registryUrl>:<channel>:<id>` so the same id can independently
3781
+ * coexist across multiple registries × both channels.
3702
3782
  */
3703
3783
  class CartridgeRepoCache {
3704
3784
  constructor(repoUrl) {
3705
- this.cartridges = new Map(); // cartridge_id -> CartridgeInfo
3706
- this.capToCartridges = new Map(); // cap_urn -> [cartridge_ids]
3785
+ this.cartridges = new Map(); // "<registryUrl>:<channel>:<id>" -> CartridgeInfo
3786
+ this.capToCartridges = new Map(); // cap_urn -> [{registryUrl, channel, id}]
3707
3787
  this.lastUpdated = Date.now();
3708
3788
  this.repoUrl = repoUrl;
3709
3789
  }
3710
3790
  }
3711
3791
 
3792
+ function _cacheKey(registryUrl, channel, id) {
3793
+ return `${registryUrl}:${channel}:${id}`;
3794
+ }
3795
+
3712
3796
  /**
3713
3797
  * Cartridge repository client - fetches and caches cartridge registry
3714
3798
  */
@@ -3719,52 +3803,98 @@ class CartridgeRepoClient {
3719
3803
  }
3720
3804
 
3721
3805
  /**
3722
- * Fetch registry from a URL
3806
+ * Fetch a v5.0 channel-partitioned registry from a URL and flatten
3807
+ * to a list of `CartridgeInfo`, one per `(channel, id)` pair.
3723
3808
  */
3724
3809
  async fetchRegistry(repoUrl) {
3725
3810
  const response = await fetch(repoUrl);
3726
-
3811
+ if (response.status === 404) {
3812
+ // Manifest not published yet — return an empty list so the
3813
+ // caller's cache reflects "no cartridges available" without
3814
+ // poisoning future syncs.
3815
+ return [];
3816
+ }
3727
3817
  if (!response.ok) {
3728
3818
  throw new Error(`Cartridge registry request failed: HTTP ${response.status} from ${repoUrl}`);
3729
3819
  }
3730
3820
 
3731
3821
  const data = await response.json();
3732
-
3733
- if (!data.cartridges || typeof data.cartridges !== 'object') {
3734
- throw new Error(`Invalid cartridge registry response from ${repoUrl}: missing cartridges object`);
3822
+ if (data.schemaVersion !== '5.0') {
3823
+ throw new Error(`Cartridge registry from ${repoUrl} has schemaVersion '${data.schemaVersion}'; required: 5.0`);
3824
+ }
3825
+ // Self-referential check: the manifest declares its own URL via
3826
+ // `registryUrl`. It must match the URL we just fetched from
3827
+ // byte-for-byte — a mismatch is a manifest-corruption signal
3828
+ // (publisher wrote the wrong URL, or manifest is being served
3829
+ // from an unexpected mirror). Identity downstream depends on
3830
+ // this string; refuse to ingest on mismatch.
3831
+ if (typeof data.registryUrl !== 'string' || data.registryUrl.length === 0) {
3832
+ throw new Error(`Cartridge registry from ${repoUrl}: missing required top-level 'registryUrl' field`);
3833
+ }
3834
+ if (data.registryUrl !== repoUrl) {
3835
+ throw new Error(
3836
+ `Cartridge registry from ${repoUrl}: declared registryUrl '${data.registryUrl}' ` +
3837
+ `does not match the URL it was fetched from. These must match byte-for-byte.`
3838
+ );
3839
+ }
3840
+ if (!data.channels || typeof data.channels !== 'object') {
3841
+ throw new Error(`Cartridge registry from ${repoUrl}: missing channels object`);
3735
3842
  }
3736
3843
 
3737
- // Registry stores cartridges as an object keyed by id; normalize to array.
3738
- return Object.entries(data.cartridges).map(([id, c]) => new CartridgeInfo({
3739
- ...c,
3740
- id,
3741
- version: c.latestVersion
3742
- }));
3844
+ const out = [];
3845
+ for (const channel of ['release', 'nightly']) {
3846
+ const entry = data.channels[channel];
3847
+ if (!entry || typeof entry !== 'object' || !entry.cartridges || typeof entry.cartridges !== 'object') {
3848
+ throw new Error(`Cartridge registry from ${repoUrl}: channels.${channel}.cartridges must be an object`);
3849
+ }
3850
+ for (const [id, c] of Object.entries(entry.cartridges)) {
3851
+ out.push(new CartridgeInfo({
3852
+ ...c,
3853
+ id,
3854
+ version: c.latestVersion,
3855
+ channel,
3856
+ // Stamp registryUrl onto every entry — verbatim from the
3857
+ // registry self-reference (which we just verified equals
3858
+ // the fetched URL). Identity comparison downstream is
3859
+ // byte equality.
3860
+ registryUrl: data.registryUrl
3861
+ }));
3862
+ }
3863
+ }
3864
+ return out;
3743
3865
  }
3744
3866
 
3745
3867
  /**
3746
3868
  * Update cache from registry data.
3747
3869
  *
3748
- * The cap-to-cartridges index keys on the *normalized* tagged-URN form
3749
- * of each cap URN (parse via CapUrn.fromString, then take toString()).
3750
- * This collapses textually different but canonically identical URNs
3751
- * (different declared tag order) into the same bucket so that lookups
3752
- * resolve regardless of how the requester phrased the URN. A cap URN
3753
- * that fails to parse is a registry corruption: we throw rather than
3754
- * silently keep the malformed string in the index.
3870
+ * The cartridges map is keyed by `<channel>:<id>` so the same id can
3871
+ * coexist in release and nightly with separate metadata/versions. The
3872
+ * cap-to-cartridges index keys on the *normalized* tagged-URN form
3873
+ * (parse via CapUrn.fromString, then take toString()) and stores
3874
+ * `{channel, id}` references so suggestions preserve channel
3875
+ * provenance. A cap URN that fails to parse is a registry corruption:
3876
+ * we throw rather than silently keep the malformed string in the
3877
+ * index.
3755
3878
  */
3756
3879
  updateCache(repoUrl, cartridges) {
3757
3880
  const cache = new CartridgeRepoCache(repoUrl);
3758
3881
 
3759
3882
  for (const cartridge of cartridges) {
3760
- cache.cartridges.set(cartridge.id, cartridge);
3883
+ cache.cartridges.set(
3884
+ _cacheKey(cartridge.registryUrl, cartridge.channel, cartridge.id),
3885
+ cartridge
3886
+ );
3761
3887
 
3762
3888
  for (const cap of cartridge.allCaps()) {
3763
3889
  const normalized = CapUrn.fromString(cap.urn).toString();
3764
3890
  if (!cache.capToCartridges.has(normalized)) {
3765
3891
  cache.capToCartridges.set(normalized, []);
3766
3892
  }
3767
- cache.capToCartridges.get(normalized).push(cartridge.id);
3893
+ cache.capToCartridges.get(normalized).push({
3894
+ registryUrl: cartridge.registryUrl,
3895
+ channel: cartridge.channel,
3896
+ id: cartridge.id
3897
+ });
3768
3898
  }
3769
3899
  }
3770
3900
 
@@ -3812,8 +3942,9 @@ class CartridgeRepoClient {
3812
3942
  * `capUrn` is parsed via CapUrn.fromString; the parsed-and-
3813
3943
  * re-serialized form is the canonical key into the cap-to-cartridges
3814
3944
  * index. Inside each candidate cartridge we walk its caps via
3815
- * `allCaps()` and match each one with `isEquivalent`, never with
3816
- * string equality.
3945
+ * `allCaps()` and match each one with `isEquivalent`. The `op` tag
3946
+ * has no functional role — only `in` and `out` predicates participate
3947
+ * in dispatch.
3817
3948
  */
3818
3949
  getSuggestionsForCap(capUrn) {
3819
3950
  const requested = CapUrn.fromString(capUrn);
@@ -3821,11 +3952,11 @@ class CartridgeRepoClient {
3821
3952
  const suggestions = [];
3822
3953
 
3823
3954
  for (const cache of this.caches.values()) {
3824
- const cartridgeIds = cache.capToCartridges.get(normalized);
3825
- if (!cartridgeIds) continue;
3955
+ const refs = cache.capToCartridges.get(normalized);
3956
+ if (!refs) continue;
3826
3957
 
3827
- for (const cartridgeId of cartridgeIds) {
3828
- const cartridge = cache.cartridges.get(cartridgeId);
3958
+ for (const ref of refs) {
3959
+ const cartridge = cache.cartridges.get(_cacheKey(ref.registryUrl, ref.channel, ref.id));
3829
3960
  if (!cartridge) continue;
3830
3961
 
3831
3962
  const capInfo = cartridge.allCaps().find(c => {
@@ -3849,7 +3980,9 @@ class CartridgeRepoClient {
3849
3980
  capTitle: capInfo.title,
3850
3981
  latestVersion: cartridge.version,
3851
3982
  repoUrl: cache.repoUrl,
3852
- pageUrl: pageUrl
3983
+ pageUrl: pageUrl,
3984
+ channel: cartridge.channel,
3985
+ registryUrl: cartridge.registryUrl
3853
3986
  }));
3854
3987
  }
3855
3988
  }
@@ -3858,13 +3991,15 @@ class CartridgeRepoClient {
3858
3991
  }
3859
3992
 
3860
3993
  /**
3861
- * Get all available cartridges from all repos
3994
+ * Get all available cartridges from all repos as
3995
+ * `[channel, id, cartridgeInfo]` tuples — the channel is first-class
3996
+ * so consumers don't have to look it up separately.
3862
3997
  */
3863
3998
  getAllCartridges() {
3864
3999
  const cartridges = [];
3865
4000
  for (const cache of this.caches.values()) {
3866
- for (const [cartridgeId, cartridgeInfo] of cache.cartridges) {
3867
- cartridges.push([cartridgeId, cartridgeInfo]);
4001
+ for (const cartridgeInfo of cache.cartridges.values()) {
4002
+ cartridges.push([cartridgeInfo.channel, cartridgeInfo.id, cartridgeInfo]);
3868
4003
  }
3869
4004
  }
3870
4005
  return cartridges;
@@ -3884,11 +4019,24 @@ class CartridgeRepoClient {
3884
4019
  }
3885
4020
 
3886
4021
  /**
3887
- * Get cartridge info by ID
4022
+ * Get cartridge info by `(registryUrl, channel, id)`. All three
4023
+ * are required — the same id can independently exist across
4024
+ * multiple registries × both channels with different metadata.
4025
+ * Returns `null` when not found. `registryUrl` is the verbatim
4026
+ * URL the cache was indexed under.
3888
4027
  */
3889
- getCartridge(cartridgeId) {
3890
- for (const cache of this.caches.values()) {
3891
- const cartridge = cache.cartridges.get(cartridgeId);
4028
+ getCartridge(registryUrl, channel, cartridgeId) {
4029
+ if (typeof registryUrl !== 'string' || registryUrl.length === 0) {
4030
+ throw new Error('getCartridge: registryUrl must be a non-empty string');
4031
+ }
4032
+ if (channel !== 'release' && channel !== 'nightly') {
4033
+ throw new Error(`Invalid channel '${channel}' — must be 'release' or 'nightly'`);
4034
+ }
4035
+ const key = _cacheKey(registryUrl, channel, cartridgeId);
4036
+ // Cache outer key is also the registry URL, so look up directly.
4037
+ const cache = this.caches.get(registryUrl);
4038
+ if (cache) {
4039
+ const cartridge = cache.cartridges.get(key);
3892
4040
  if (cartridge) {
3893
4041
  return cartridge;
3894
4042
  }
@@ -3916,51 +4064,77 @@ class CartridgeRepoClient {
3916
4064
  /**
3917
4065
  * Cartridge repository server - serves registry data with queries
3918
4066
  */
4067
+ /**
4068
+ * Distribution channel for a cartridge entry — mirrors capdag's
4069
+ * `CartridgeChannel` and the registry's `channels.<channel>` keys.
4070
+ *
4071
+ * `release` is the user-facing channel; `nightly` is the in-flight
4072
+ * channel. Always one of these two strings — no other values are valid.
4073
+ */
4074
+ const CartridgeChannel = Object.freeze({
4075
+ Release: 'release',
4076
+ Nightly: 'nightly'
4077
+ });
4078
+
4079
+ function _validChannel(c) {
4080
+ return c === CartridgeChannel.Release || c === CartridgeChannel.Nightly;
4081
+ }
4082
+
4083
+ /**
4084
+ * Reads a v5.0 channel-partitioned cartridge registry. Both `release`
4085
+ * and `nightly` channels are always present (possibly empty); every
4086
+ * `CartridgeInfo` returned carries the channel it came from so consumers
4087
+ * can render the release/nightly distinction without re-deriving.
4088
+ */
3919
4089
  class CartridgeRepoServer {
3920
4090
  constructor(registry) {
3921
4091
  this.registry = registry;
3922
4092
  this.validateRegistry();
3923
4093
  }
3924
4094
 
3925
- /**
3926
- * Validate registry schema
3927
- */
3928
4095
  validateRegistry() {
3929
4096
  if (!this.registry) {
3930
4097
  throw new Error('Registry is required');
3931
4098
  }
3932
- if (this.registry.schemaVersion !== '4.0') {
3933
- throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 4.0`);
4099
+ if (this.registry.schemaVersion !== '5.0') {
4100
+ throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 5.0`);
4101
+ }
4102
+ if (typeof this.registry.registryUrl !== 'string' || this.registry.registryUrl.length === 0) {
4103
+ throw new Error('Registry must have a non-empty top-level `registryUrl` field (self-referential URL)');
3934
4104
  }
3935
- if (!this.registry.cartridges || typeof this.registry.cartridges !== 'object') {
3936
- throw new Error('Registry must have cartridges object');
4105
+ const channels = this.registry.channels;
4106
+ if (!channels || typeof channels !== 'object') {
4107
+ throw new Error('Registry must have a channels object');
4108
+ }
4109
+ for (const ch of [CartridgeChannel.Release, CartridgeChannel.Nightly]) {
4110
+ const entry = channels[ch];
4111
+ if (!entry || typeof entry !== 'object') {
4112
+ throw new Error(`Registry must have channels.${ch}`);
4113
+ }
4114
+ if (!entry.cartridges || typeof entry.cartridges !== 'object') {
4115
+ throw new Error(`Registry: channels.${ch}.cartridges must be an object`);
4116
+ }
3937
4117
  }
3938
4118
  }
3939
4119
 
3940
- /**
3941
- * Validate version data has all required fields
3942
- */
3943
- validateVersionData(id, version, versionData) {
4120
+ validateVersionData(channel, id, version, versionData) {
3944
4121
  if (!Array.isArray(versionData.builds) || versionData.builds.length === 0) {
3945
- throw new Error(`Cartridge ${id} v${version}: no builds`);
4122
+ throw new Error(`Cartridge ${id} (${channel}) v${version}: no builds`);
3946
4123
  }
3947
4124
  for (let i = 0; i < versionData.builds.length; i++) {
3948
4125
  const build = versionData.builds[i];
3949
4126
  if (!build.platform) {
3950
- throw new Error(`Cartridge ${id} v${version}: build[${i}] missing platform`);
4127
+ throw new Error(`Cartridge ${id} (${channel}) v${version}: build[${i}] missing platform`);
3951
4128
  }
3952
4129
  if (!build.package || !build.package.name) {
3953
- throw new Error(`Cartridge ${id} v${version}: build[${i}] (${build.platform}) missing package.name`);
4130
+ throw new Error(`Cartridge ${id} (${channel}) v${version}: build[${i}] (${build.platform}) missing package.name`);
3954
4131
  }
3955
4132
  if (!build.package.url) {
3956
- throw new Error(`Cartridge ${id} v${version}: build[${i}] (${build.platform}) missing package.url`);
4133
+ throw new Error(`Cartridge ${id} (${channel}) v${version}: build[${i}] (${build.platform}) missing package.url`);
3957
4134
  }
3958
4135
  }
3959
4136
  }
3960
4137
 
3961
- /**
3962
- * Compare version strings
3963
- */
3964
4138
  compareVersions(a, b) {
3965
4139
  const partsA = a.split('.').map(x => parseInt(x) || 0);
3966
4140
  const partsB = b.split('.').map(x => parseInt(x) || 0);
@@ -3976,71 +4150,86 @@ class CartridgeRepoServer {
3976
4150
  }
3977
4151
 
3978
4152
  /**
3979
- * Transform registry to flat cartridge array
4153
+ * Convert one channel-entry into a flat CartridgeInfo. Throws if the
4154
+ * entry's `latestVersion` is not present in `versions` or if the
4155
+ * latest version's builds are malformed.
3980
4156
  */
3981
- transformToCartridgeArray() {
3982
- const cartridgesObject = this.registry.cartridges || {};
3983
- const cartridges = [];
3984
-
3985
- for (const [id, cartridge] of Object.entries(cartridgesObject)) {
3986
- const latestVersion = cartridge.latestVersion;
3987
- const versionData = cartridge.versions[latestVersion];
4157
+ _entryToCartridgeInfo(channel, id, cartridge) {
4158
+ const latestVersion = cartridge.latestVersion;
4159
+ const versionData = cartridge.versions[latestVersion];
4160
+ if (!versionData) {
4161
+ throw new Error(`Cartridge ${id} (${channel}): latestVersion ${latestVersion} not found in versions`);
4162
+ }
4163
+ this.validateVersionData(channel, id, latestVersion, versionData);
3988
4164
 
3989
- if (!versionData) {
3990
- throw new Error(`Cartridge ${id}: latest version ${latestVersion} not found in versions`);
3991
- }
4165
+ const availableVersions = Object.keys(cartridge.versions).sort((a, b) => this.compareVersions(b, a));
3992
4166
 
3993
- // Validate required fields - fail hard
3994
- this.validateVersionData(id, latestVersion, versionData);
4167
+ if (!Array.isArray(cartridge.cap_groups)) {
4168
+ throw new Error(`Cartridge ${id} (${channel}): missing cap_groups array`);
4169
+ }
3995
4170
 
3996
- // Get all version numbers sorted descending
3997
- const availableVersions = Object.keys(cartridge.versions).sort((a, b) => {
3998
- return this.compareVersions(b, a);
3999
- });
4171
+ return {
4172
+ id,
4173
+ name: cartridge.name,
4174
+ version: latestVersion,
4175
+ description: cartridge.description,
4176
+ author: cartridge.author,
4177
+ pageUrl: cartridge.pageUrl || '',
4178
+ teamId: cartridge.teamId,
4179
+ signedAt: versionData.releaseDate,
4180
+ minAppVersion: versionData.minAppVersion || cartridge.minAppVersion,
4181
+ cap_groups: cartridge.cap_groups,
4182
+ categories: cartridge.categories,
4183
+ tags: cartridge.tags,
4184
+ versions: cartridge.versions,
4185
+ availableVersions,
4186
+ channel,
4187
+ // Stamp the manifest's self-referential URL onto every entry —
4188
+ // verbatim from the registry. Identity comparison downstream is
4189
+ // byte equality.
4190
+ registryUrl: this.registry.registryUrl
4191
+ };
4192
+ }
4000
4193
 
4001
- cartridges.push({
4002
- id,
4003
- name: cartridge.name,
4004
- version: latestVersion,
4005
- description: cartridge.description,
4006
- author: cartridge.author,
4007
- pageUrl: cartridge.pageUrl || '',
4008
- teamId: cartridge.teamId,
4009
- signedAt: versionData.releaseDate,
4010
- minAppVersion: versionData.minAppVersion || cartridge.minAppVersion,
4011
- cap_groups: (() => {
4012
- if (!Array.isArray(cartridge.cap_groups)) throw new Error(`Cartridge ${id}: missing cap_groups array`);
4013
- return cartridge.cap_groups;
4014
- })(),
4015
- categories: cartridge.categories,
4016
- tags: cartridge.tags,
4017
- versions: cartridge.versions,
4018
- availableVersions
4019
- });
4194
+ /**
4195
+ * Walk both channels and emit a flat array of CartridgeInfo. Release
4196
+ * entries appear before nightly entries — UIs that paint in array
4197
+ * order get the user-facing channel at the top by default.
4198
+ */
4199
+ transformToCartridgeArray() {
4200
+ const out = [];
4201
+ for (const channel of [CartridgeChannel.Release, CartridgeChannel.Nightly]) {
4202
+ const map = (this.registry.channels[channel].cartridges) || {};
4203
+ for (const [id, cartridge] of Object.entries(map)) {
4204
+ out.push(this._entryToCartridgeInfo(channel, id, cartridge));
4205
+ }
4020
4206
  }
4021
-
4022
- return cartridges;
4207
+ return out;
4023
4208
  }
4024
4209
 
4025
4210
  /**
4026
- * Get all cartridges (API response format)
4211
+ * Get all cartridges (API response format) — both channels.
4027
4212
  */
4028
4213
  getCartridges() {
4029
- return {
4030
- cartridges: this.transformToCartridgeArray()
4031
- };
4214
+ return { cartridges: this.transformToCartridgeArray() };
4032
4215
  }
4033
4216
 
4034
4217
  /**
4035
- * Get cartridge by ID
4218
+ * Get cartridge by `(channel, id)`. Channel is required because the
4219
+ * same id can independently exist in both channels. Returns
4220
+ * `undefined` if the cartridge isn't in the requested channel.
4036
4221
  */
4037
- getCartridgeById(id) {
4038
- const cartridges = this.transformToCartridgeArray();
4039
- return cartridges.find(p => p.id === id);
4222
+ getCartridgeById(channel, id) {
4223
+ if (!_validChannel(channel)) {
4224
+ throw new Error(`Invalid channel '${channel}' must be 'release' or 'nightly'`);
4225
+ }
4226
+ const cartridge = this.registry.channels[channel].cartridges[id];
4227
+ if (!cartridge) return undefined;
4228
+ return this._entryToCartridgeInfo(channel, id, cartridge);
4040
4229
  }
4041
4230
 
4042
4231
  /**
4043
- * Search cartridges by free-text query.
4232
+ * Search cartridges by free-text query across both channels.
4044
4233
  *
4045
4234
  * Matches against cartridge name, description, tags, and cap titles.
4046
4235
  * Cap URN strings are not substring-matched: a cap URN is a tagged
@@ -4051,7 +4240,6 @@ class CartridgeRepoServer {
4051
4240
  searchCartridges(query) {
4052
4241
  const cartridges = this.transformToCartridgeArray();
4053
4242
  const lowerQuery = query.toLowerCase();
4054
-
4055
4243
  return cartridges.filter(p => {
4056
4244
  const allCaps = (p.cap_groups || []).flatMap(g => g.caps || []);
4057
4245
  return p.name.toLowerCase().includes(lowerQuery) ||
@@ -4062,7 +4250,7 @@ class CartridgeRepoServer {
4062
4250
  }
4063
4251
 
4064
4252
  /**
4065
- * Get cartridges by category
4253
+ * Get cartridges by category — both channels.
4066
4254
  */
4067
4255
  getCartridgesByCategory(category) {
4068
4256
  const cartridges = this.transformToCartridgeArray();
@@ -4072,10 +4260,14 @@ class CartridgeRepoServer {
4072
4260
  /**
4073
4261
  * Get cartridges that provide a specific cap.
4074
4262
  *
4075
- * Both the request URN and each candidate cap URN are parsed via
4076
- * CapUrn.fromString and matched with `isEquivalent` so caps declared
4077
- * in any tag order resolve. A malformed input URN throws — there is
4078
- * no fallback that compares the raw strings.
4263
+ * The request URN is parsed via CapUrn.fromString. Each declared
4264
+ * cartridge cap is parsed and matched with `conformsTo`: cap dispatch
4265
+ * is the partial-order question "does the declared cap conform to
4266
+ * (i.e. refine, equal, or be more specific than) the requested
4267
+ * pattern?". Only `in` and `out` tags are semantically meaningful —
4268
+ * no string comparison, no special role for the `op` tag. A malformed
4269
+ * input URN throws; a malformed declared URN in the registry also
4270
+ * throws (registry corruption is not a fallback condition).
4079
4271
  */
4080
4272
  getCartridgesByCap(capUrn) {
4081
4273
  const requested = CapUrn.fromString(capUrn);
@@ -4083,13 +4275,8 @@ class CartridgeRepoServer {
4083
4275
  return cartridges.filter(p =>
4084
4276
  (p.cap_groups || []).some(g =>
4085
4277
  (g.caps || []).some(c => {
4086
- let parsed;
4087
- try {
4088
- parsed = CapUrn.fromString(c.urn);
4089
- } catch (_e) {
4090
- return false;
4091
- }
4092
- return parsed.isEquivalent(requested);
4278
+ const declared = CapUrn.fromString(c.urn);
4279
+ return declared.conformsTo(requested);
4093
4280
  })
4094
4281
  )
4095
4282
  );
@@ -5326,6 +5513,12 @@ module.exports = {
5326
5513
  CartridgeRepoCache,
5327
5514
  CartridgeRepoClient,
5328
5515
  CartridgeRepoServer,
5516
+ CartridgeChannel,
5517
+ // Registry slug
5518
+ DEV_SLUG,
5519
+ SLUG_HEX_LEN,
5520
+ slugForRegistryUrl,
5521
+ isRegistrySlug,
5329
5522
  // Machine notation
5330
5523
  MachineSyntaxError,
5331
5524
  MachineSyntaxErrorCodes,
package/capdag.test.js CHANGED
@@ -1637,11 +1637,18 @@ function testJS_mediaSpecConstruction() {
1637
1637
  // Cartridge Repository Tests (TEST320-TEST335)
1638
1638
  // =============================================================================
1639
1639
 
1640
- // Sample registry for testing
1640
+ // Sample registry for testing — v5.0 channel-partitioned schema.
1641
+ //
1642
+ // Both `release` and `nightly` are always present. We populate the
1643
+ // release channel with two cartridges (pdf + txt) for the bulk of the
1644
+ // tests, and add one nightly entry so isolation tests have something
1645
+ // to assert on. Tests that need an empty channel use literals inline.
1641
1646
  const sampleRegistry = {
1642
- schemaVersion: '4.0',
1647
+ schemaVersion: '5.0',
1643
1648
  lastUpdated: '2026-02-07T16:48:28Z',
1644
- cartridges: {
1649
+ registryUrl: 'https://test.example/manifest',
1650
+ channels: {
1651
+ release: { cartridges: {
1645
1652
  pdfcartridge: {
1646
1653
  name: 'pdfcartridge',
1647
1654
  description: 'PDF document processor',
@@ -1727,6 +1734,50 @@ const sampleRegistry = {
1727
1734
  }
1728
1735
  }
1729
1736
  }
1737
+ } },
1738
+ nightly: { cartridges: {
1739
+ jsoncartridge: {
1740
+ name: 'jsoncartridge',
1741
+ description: 'JSON tooling — nightly only',
1742
+ author: 'test-author',
1743
+ pageUrl: 'https://example.com/json',
1744
+ teamId: 'P336JK947M',
1745
+ minAppVersion: '1.0.0',
1746
+ categories: ['data'],
1747
+ tags: ['json'],
1748
+ cap_groups: [
1749
+ {
1750
+ name: 'json-processing',
1751
+ adapter_urns: ['media:json'],
1752
+ caps: [
1753
+ {
1754
+ urn: 'cap:in="media:json";op=pretty;out="media:json;textable"',
1755
+ title: 'Pretty Print JSON',
1756
+ description: 'Format JSON',
1757
+ command: 'pretty'
1758
+ }
1759
+ ]
1760
+ }
1761
+ ],
1762
+ latestVersion: '0.1.0',
1763
+ versions: {
1764
+ '0.1.0': {
1765
+ releaseDate: '2026-04-01T00:00:00Z',
1766
+ changelog: ['Initial nightly'],
1767
+ minAppVersion: '1.0.0',
1768
+ builds: [{
1769
+ platform: 'darwin-arm64',
1770
+ package: {
1771
+ name: 'jsoncartridge-0.1.0.pkg',
1772
+ url: 'https://cartridges.machinefabric.com/nightly/jsoncartridge/0.1.0/jsoncartridge-0.1.0.pkg',
1773
+ sha256: 'deadbeef',
1774
+ size: 100000
1775
+ }
1776
+ }]
1777
+ }
1778
+ }
1779
+ }
1780
+ } }
1730
1781
  }
1731
1782
  };
1732
1783
 
@@ -1735,6 +1786,8 @@ function test320_cartridgeInfoConstruction() {
1735
1786
  const data = {
1736
1787
  id: 'testcartridge',
1737
1788
  name: 'Test Cartridge',
1789
+ registryUrl: 'https://test.example/manifest',
1790
+ channel: 'release',
1738
1791
  version: '1.0.0',
1739
1792
  description: 'A test',
1740
1793
  teamId: 'TEAM123',
@@ -1764,20 +1817,20 @@ function test320_cartridgeInfoConstruction() {
1764
1817
 
1765
1818
  // TEST321: CartridgeInfo.is_signed() returns true when signature is present
1766
1819
  function test321_cartridgeInfoIsSigned() {
1767
- const signed = new CartridgeInfo({id: 'test', teamId: 'TEAM', signedAt: '2026-01-01', cap_groups: []});
1820
+ const signed = new CartridgeInfo({id: 'test', registryUrl: 'https://test.example/manifest', channel: 'release', teamId: 'TEAM', signedAt: '2026-01-01', cap_groups: []});
1768
1821
  assert(signed.isSigned() === true, 'Cartridge with teamId and signedAt should be signed');
1769
1822
 
1770
- const unsigned1 = new CartridgeInfo({id: 'test', teamId: '', signedAt: '2026-01-01', cap_groups: []});
1823
+ const unsigned1 = new CartridgeInfo({id: 'test', registryUrl: 'https://test.example/manifest', channel: 'release', teamId: '', signedAt: '2026-01-01', cap_groups: []});
1771
1824
  assert(unsigned1.isSigned() === false, 'Cartridge without teamId should not be signed');
1772
1825
 
1773
- const unsigned2 = new CartridgeInfo({id: 'test', teamId: 'TEAM', signedAt: '', cap_groups: []});
1826
+ const unsigned2 = new CartridgeInfo({id: 'test', registryUrl: 'https://test.example/manifest', channel: 'release', teamId: 'TEAM', signedAt: '', cap_groups: []});
1774
1827
  assert(unsigned2.isSigned() === false, 'Cartridge without signedAt should not be signed');
1775
1828
  }
1776
1829
 
1777
1830
  // TEST322: CartridgeInfo.build_for_platform() returns the build matching the current platform
1778
1831
  function test322_cartridgeInfoBuildForPlatform() {
1779
1832
  const withBuilds = new CartridgeInfo({
1780
- id: 'test', version: '1.0.0', cap_groups: [],
1833
+ id: 'test', registryUrl: 'https://test.example/manifest', channel: 'release', version: '1.0.0', cap_groups: [],
1781
1834
  versions: {
1782
1835
  '1.0.0': {
1783
1836
  builds: [
@@ -1803,51 +1856,64 @@ function test322_cartridgeInfoBuildForPlatform() {
1803
1856
  assert(platforms.includes('darwin-arm64'), 'Should include darwin-arm64');
1804
1857
  assert(platforms.includes('linux-x86_64'), 'Should include linux-x86_64');
1805
1858
 
1806
- const noBuilds = new CartridgeInfo({id: 'test', version: '1.0.0', cap_groups: [], versions: {}, availableVersions: []});
1859
+ const noBuilds = new CartridgeInfo({id: 'test', registryUrl: 'https://test.example/manifest', channel: 'release', version: '1.0.0', cap_groups: [], versions: {}, availableVersions: []});
1807
1860
  assert(noBuilds.buildForPlatform('darwin-arm64') === null, 'Should return null when no versions');
1808
1861
  assert(noBuilds.availablePlatforms().length === 0, 'Should have no platforms');
1809
1862
  }
1810
1863
 
1811
- // TEST323: CartridgeRepoServer validates registry JSON schema version
1864
+ // TEST323: CartridgeRepoServer requires schema 5.0 and rejects older.
1812
1865
  function test323_cartridgeRepoServerValidateRegistry() {
1813
1866
  // Valid registry
1814
1867
  const server = new CartridgeRepoServer(sampleRegistry);
1815
- assert(server.registry.schemaVersion === '4.0', 'Should accept valid registry');
1868
+ assert(server.registry.schemaVersion === '5.0', 'Should accept v5.0 registry');
1816
1869
 
1817
- // Invalid schema version
1870
+ // Invalid schema version (4.0 is no longer accepted)
1818
1871
  let threw = false;
1819
1872
  try {
1820
- new CartridgeRepoServer({schemaVersion: '3.0', cartridges: {}});
1873
+ new CartridgeRepoServer({schemaVersion: '4.0', channels: {release: {cartridges: {}}, nightly: {cartridges: {}}}});
1821
1874
  } catch (e) {
1822
1875
  threw = true;
1823
- assert(e.message.includes('schema version'), 'Should reject wrong schema version');
1876
+ assert(e.message.includes('5.0'), 'Should reject pre-5.0 schema and mention required version');
1824
1877
  }
1825
- assert(threw, 'Should throw for invalid schema');
1878
+ assert(threw, 'Should throw for v4.0 schema');
1826
1879
 
1827
- // Missing cartridges
1880
+ // Missing channels object
1828
1881
  threw = false;
1829
1882
  try {
1830
- new CartridgeRepoServer({schemaVersion: '4.0'});
1883
+ new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest'});
1831
1884
  } catch (e) {
1832
1885
  threw = true;
1833
- assert(e.message.includes('cartridges'), 'Should reject missing cartridges');
1886
+ assert(e.message.includes('channels'), 'Should reject missing channels');
1834
1887
  }
1835
- assert(threw, 'Should throw for missing cartridges');
1888
+ assert(threw, 'Should throw for missing channels');
1889
+
1890
+ // Missing one of the two required channels
1891
+ threw = false;
1892
+ try {
1893
+ new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest', channels: {release: {cartridges: {}}}});
1894
+ } catch (e) {
1895
+ threw = true;
1896
+ assert(e.message.includes('nightly'), 'Should require nightly channel');
1897
+ }
1898
+ assert(threw, 'Should throw when nightly channel is missing');
1836
1899
  }
1837
1900
 
1838
- // TEST324: CartridgeRepoServer transforms v3 registry JSON into flat cartridge array
1901
+ // TEST324: CartridgeRepoServer walks both channels and emits a flat
1902
+ // CartridgeInfo array preserving channel provenance. Release entries
1903
+ // appear first.
1839
1904
  function test324_cartridgeRepoServerTransformToArray() {
1840
1905
  const server = new CartridgeRepoServer(sampleRegistry);
1841
1906
  const cartridges = server.transformToCartridgeArray();
1842
1907
 
1843
1908
  assert(Array.isArray(cartridges), 'Should return array');
1844
- assert(cartridges.length === 2, 'Should have 2 cartridges');
1909
+ assert(cartridges.length === 3, 'Should have 3 cartridges across both channels');
1845
1910
 
1846
1911
  const pdf = cartridges.find(p => p.id === 'pdfcartridge');
1847
1912
  assert(pdf !== undefined, 'Should include pdfcartridge');
1848
1913
  assert(pdf.version === '0.81.5325', 'Should have latest version');
1849
1914
  assert(pdf.teamId === 'P336JK947M', 'Should have teamId');
1850
1915
  assert(pdf.signedAt === '2026-02-07T16:40:28Z', 'Should have signedAt from releaseDate');
1916
+ assert(pdf.channel === 'release', 'pdfcartridge should be in release channel');
1851
1917
  assert(pdf.versions !== undefined, 'Should have versions');
1852
1918
  assert(pdf.versions['0.81.5325'] !== undefined, 'Should have version data');
1853
1919
  assert(pdf.versions['0.81.5325'].builds.length === 1, 'Should have 1 build');
@@ -1859,31 +1925,66 @@ function test324_cartridgeRepoServerTransformToArray() {
1859
1925
  assert(Array.isArray(pdf.cap_groups), 'Should have cap_groups array');
1860
1926
  assert(pdf.cap_groups.length === 1, 'Should have 1 cap_group');
1861
1927
  assert(pdf.cap_groups[0].caps.length === 2, 'Should have 2 caps in the group');
1928
+
1929
+ const json = cartridges.find(p => p.id === 'jsoncartridge');
1930
+ assert(json !== undefined, 'Should include jsoncartridge from nightly channel');
1931
+ assert(json.channel === 'nightly', 'jsoncartridge should be in nightly channel');
1932
+
1933
+ // Release entries come before nightly so any UI that paints in
1934
+ // iteration order surfaces the user-facing channel first.
1935
+ const firstNightlyIdx = cartridges.findIndex(c => c.channel === 'nightly');
1936
+ const lastReleaseIdx = cartridges.map(c => c.channel).lastIndexOf('release');
1937
+ assert(firstNightlyIdx > lastReleaseIdx, 'Release entries must precede nightly entries');
1862
1938
  }
1863
1939
 
1864
- // TEST325: CartridgeRepoServer.get_cartridges() returns all parsed cartridges
1940
+ // TEST325: CartridgeRepoServer.getCartridges() wraps the transformed
1941
+ // flat array (across both channels) in the response envelope.
1865
1942
  function test325_cartridgeRepoServerGetCartridges() {
1866
1943
  const server = new CartridgeRepoServer(sampleRegistry);
1867
1944
  const response = server.getCartridges();
1868
1945
 
1869
1946
  assert(response.cartridges !== undefined, 'Should have cartridges field');
1870
1947
  assert(Array.isArray(response.cartridges), 'Cartridges should be array');
1871
- assert(response.cartridges.length === 2, 'Should have 2 cartridges');
1948
+ assert(response.cartridges.length === 3, 'Should have 3 cartridges total');
1949
+ assert(response.cartridges.every(c => c.channel === 'release' || c.channel === 'nightly'),
1950
+ 'Every cartridge must carry a channel');
1872
1951
  }
1873
1952
 
1874
- // TEST326: CartridgeRepoServer.get_cartridge() returns cartridge matching the given ID
1953
+ // TEST326: CartridgeRepoServer.getCartridgeById() requires (channel,
1954
+ // id). Same id looked up in the wrong channel must miss — channels are
1955
+ // independent namespaces.
1875
1956
  function test326_cartridgeRepoServerGetCartridgeById() {
1876
1957
  const server = new CartridgeRepoServer(sampleRegistry);
1877
1958
 
1878
- const pdf = server.getCartridgeById('pdfcartridge');
1879
- assert(pdf !== undefined, 'Should find pdfcartridge');
1959
+ const pdf = server.getCartridgeById('release', 'pdfcartridge');
1960
+ assert(pdf !== undefined, 'Should find pdfcartridge in release');
1880
1961
  assert(pdf.id === 'pdfcartridge', 'Should have correct ID');
1962
+ assert(pdf.channel === 'release', 'Should report release channel');
1963
+
1964
+ const json = server.getCartridgeById('nightly', 'jsoncartridge');
1965
+ assert(json !== undefined, 'Should find jsoncartridge in nightly');
1966
+ assert(json.channel === 'nightly', 'Should report nightly channel');
1967
+
1968
+ const wrongChannel = server.getCartridgeById('nightly', 'pdfcartridge');
1969
+ assert(wrongChannel === undefined, 'pdf not in nightly — channels are independent');
1881
1970
 
1882
- const notFound = server.getCartridgeById('nonexistent');
1971
+ const notFound = server.getCartridgeById('release', 'nonexistent');
1883
1972
  assert(notFound === undefined, 'Should return undefined for missing cartridge');
1973
+
1974
+ let threw = false;
1975
+ try {
1976
+ server.getCartridgeById('staging', 'pdfcartridge');
1977
+ } catch (e) {
1978
+ threw = true;
1979
+ assert(e.message.includes('release') && e.message.includes('nightly'),
1980
+ 'Should reject invalid channel value');
1981
+ }
1982
+ assert(threw, 'Should throw for invalid channel');
1884
1983
  }
1885
1984
 
1886
- // TEST327: CartridgeRepoServer.search_cartridges() filters by text query against name and description
1985
+ // TEST327: CartridgeRepoServer.searchCartridges() filters across both
1986
+ // channels by name/description/tags/cap titles. Cap URN strings are
1987
+ // not substring-matched.
1887
1988
  function test327_cartridgeRepoServerSearchCartridges() {
1888
1989
  const server = new CartridgeRepoServer(sampleRegistry);
1889
1990
 
@@ -1894,11 +1995,18 @@ function test327_cartridgeRepoServerSearchCartridges() {
1894
1995
  const metadataResults = server.searchCartridges('metadata');
1895
1996
  assert(metadataResults.length === 1, 'Should find cartridge by cap title');
1896
1997
 
1998
+ // Search must reach into the nightly channel too — channels are not
1999
+ // a search-time filter.
2000
+ const jsonResults = server.searchCartridges('json');
2001
+ assert(jsonResults.length === 1, 'Should reach nightly cartridges');
2002
+ assert(jsonResults[0].channel === 'nightly', 'Should report nightly channel');
2003
+
1897
2004
  const noResults = server.searchCartridges('nonexistent');
1898
2005
  assert(noResults.length === 0, 'Should return empty for no matches');
1899
2006
  }
1900
2007
 
1901
- // TEST328: CartridgeRepoServer.get_by_category() filters cartridges by category tag
2008
+ // TEST328: CartridgeRepoServer.getCartridgesByCategory() filters
2009
+ // cartridges by category across both channels.
1902
2010
  function test328_cartridgeRepoServerGetByCategory() {
1903
2011
  const server = new CartridgeRepoServer(sampleRegistry);
1904
2012
 
@@ -1909,9 +2017,15 @@ function test328_cartridgeRepoServerGetByCategory() {
1909
2017
  const textCartridges = server.getCartridgesByCategory('text');
1910
2018
  assert(textCartridges.length === 1, 'Should find 1 text cartridge');
1911
2019
  assert(textCartridges[0].id === 'txtcartridge', 'Should be txtcartridge');
2020
+
2021
+ const dataCartridges = server.getCartridgesByCategory('data');
2022
+ assert(dataCartridges.length === 1, 'Category lookup should reach the nightly channel too');
2023
+ assert(dataCartridges[0].channel === 'nightly', 'Should be the nightly entry');
1912
2024
  }
1913
2025
 
1914
- // TEST329: CartridgeRepoServer.get_suggestions_for_cap() finds cartridges providing a given cap URN
2026
+ // TEST329: CartridgeRepoServer.getCartridgesByCap() parses the input
2027
+ // URN and matches each declared cap via `conformsTo`. Tag-order
2028
+ // differences resolve because matching is order-theoretic, not string.
1915
2029
  function test329_cartridgeRepoServerGetByCap() {
1916
2030
  const server = new CartridgeRepoServer(sampleRegistry);
1917
2031
 
@@ -1926,21 +2040,37 @@ function test329_cartridgeRepoServerGetByCap() {
1926
2040
  assert(metadataCartridges.length === 1, 'Should find metadata cap');
1927
2041
  }
1928
2042
 
1929
- // TEST330: CartridgeRepoClient updates its local cache from server response
2043
+ // TEST330: CartridgeRepoClient updates its local cache keyed by
2044
+ // "<channel>:<id>". The cache holds release and nightly entries
2045
+ // independently — the same id is allowed in both.
1930
2046
  function test330_cartridgeRepoClientUpdateCache() {
1931
2047
  const client = new CartridgeRepoClient(3600);
1932
2048
  const server = new CartridgeRepoServer(sampleRegistry);
1933
2049
  const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
1934
2050
 
1935
- client.updateCache('https://example.com/api/cartridges', cartridges);
2051
+ // Cache key is the registry URL the cartridges carry — mismatching
2052
+ // it would orphan the entries (the new cache key is
2053
+ // <registryUrl>:<channel>:<id>). The sampleRegistry stamps every
2054
+ // entry with 'https://test.example/manifest'.
2055
+ const REGISTRY_URL = 'https://test.example/manifest';
2056
+ client.updateCache(REGISTRY_URL, cartridges);
1936
2057
 
1937
- const cache = client.caches.get('https://example.com/api/cartridges');
2058
+ const cache = client.caches.get(REGISTRY_URL);
1938
2059
  assert(cache !== undefined, 'Cache should exist');
1939
- assert(cache.cartridges.size === 2, 'Should have 2 cartridges in cache');
2060
+ assert(cache.cartridges.size === 3, 'Should have 3 cartridges in cache (2 release + 1 nightly)');
2061
+ assert(
2062
+ cache.cartridges.has(`${REGISTRY_URL}:release:pdfcartridge`),
2063
+ 'Should key by <registryUrl>:<channel>:<id>'
2064
+ );
2065
+ assert(
2066
+ cache.cartridges.has(`${REGISTRY_URL}:nightly:jsoncartridge`),
2067
+ 'Should hold nightly entry independently'
2068
+ );
1940
2069
  assert(cache.capToCartridges.size > 0, 'Should have cap mappings');
1941
2070
  }
1942
2071
 
1943
- // TEST331: CartridgeRepoClient.get_suggestions_for_cap() returns cartridge suggestions for a cap URN
2072
+ // TEST331: CartridgeRepoClient.getSuggestionsForCap() returns cartridge
2073
+ // suggestions with channel propagated onto each suggestion.
1944
2074
  function test331_cartridgeRepoClientGetSuggestions() {
1945
2075
  const client = new CartridgeRepoClient(3600);
1946
2076
  const server = new CartridgeRepoServer(sampleRegistry);
@@ -1953,6 +2083,7 @@ function test331_cartridgeRepoClientGetSuggestions() {
1953
2083
 
1954
2084
  assert(suggestions.length === 1, 'Should find 1 suggestion');
1955
2085
  assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest pdfcartridge');
2086
+ assert(suggestions[0].channel === 'release', 'Channel must propagate from cache');
1956
2087
  // The returned capUrn is the canonical (normalized) form. Compare via
1957
2088
  // tagged-URN equivalence rather than string equality so a tag-order
1958
2089
  // difference between the request and the canonical form is tolerated.
@@ -1960,25 +2091,61 @@ function test331_cartridgeRepoClientGetSuggestions() {
1960
2091
  const returned = CapUrn.fromString(suggestions[0].capUrn);
1961
2092
  assert(returned.isEquivalent(requested), 'Should have equivalent cap URN');
1962
2093
  assert(suggestions[0].capTitle === 'Disbind PDF', 'Should have cap title');
2094
+
2095
+ // Nightly cap should also surface a suggestion, with channel set to nightly.
2096
+ const prettyCap = 'cap:in="media:json";op=pretty;out="media:json;textable"';
2097
+ const nightlySuggestions = client.getSuggestionsForCap(prettyCap);
2098
+ assert(nightlySuggestions.length === 1, 'Should find nightly cap suggestion');
2099
+ assert(nightlySuggestions[0].channel === 'nightly', 'Should report nightly channel');
1963
2100
  }
1964
2101
 
1965
- // TEST332: CartridgeRepoClient.get_cartridge() retrieves a specific cartridge by ID from cache
2102
+ // TEST332: CartridgeRepoClient.getCartridge() requires (channel, id).
2103
+ // Same id in the wrong channel must miss.
1966
2104
  function test332_cartridgeRepoClientGetCartridge() {
1967
2105
  const client = new CartridgeRepoClient(3600);
1968
2106
  const server = new CartridgeRepoServer(sampleRegistry);
1969
2107
  const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
1970
2108
 
1971
- client.updateCache('https://example.com/api/cartridges', cartridges);
2109
+ const REGISTRY_URL = 'https://test.example/manifest';
2110
+ client.updateCache(REGISTRY_URL, cartridges);
1972
2111
 
1973
- const cartridge = client.getCartridge('pdfcartridge');
1974
- assert(cartridge !== null, 'Should find cartridge');
2112
+ const cartridge = client.getCartridge(REGISTRY_URL, 'release', 'pdfcartridge');
2113
+ assert(cartridge !== null && cartridge !== undefined, 'Should find cartridge in release');
1975
2114
  assert(cartridge.id === 'pdfcartridge', 'Should have correct ID');
2115
+ assert(cartridge.channel === 'release', 'Should report release channel');
2116
+ assert(cartridge.registryUrl === REGISTRY_URL, 'Should report registry URL');
2117
+
2118
+ const json = client.getCartridge(REGISTRY_URL, 'nightly', 'jsoncartridge');
2119
+ assert(json !== null && json !== undefined, 'Should find nightly entry');
2120
+ assert(json.channel === 'nightly', 'Should report nightly channel');
2121
+
2122
+ const wrongChannel = client.getCartridge(REGISTRY_URL, 'nightly', 'pdfcartridge');
2123
+ assert(wrongChannel === undefined || wrongChannel === null,
2124
+ 'Should miss when looking up release id in nightly channel');
1976
2125
 
1977
- const notFound = client.getCartridge('nonexistent');
1978
- assert(notFound === null, 'Should return null for missing cartridge');
2126
+ const notFound = client.getCartridge(REGISTRY_URL, 'release', 'nonexistent');
2127
+ assert(notFound === undefined || notFound === null, 'Should miss for unknown id');
2128
+
2129
+ // Two URLs that look similar but differ byte-wise are distinct
2130
+ // registries — looking up one cartridge under the other's URL
2131
+ // misses, even if id+channel match.
2132
+ const wrongRegistry = client.getCartridge(
2133
+ 'https://other.example/manifest', 'release', 'pdfcartridge'
2134
+ );
2135
+ assert(wrongRegistry === undefined || wrongRegistry === null,
2136
+ 'Should miss when looking up under a different registry URL');
2137
+
2138
+ let threw = false;
2139
+ try {
2140
+ client.getCartridge(REGISTRY_URL, 'staging', 'pdfcartridge');
2141
+ } catch (e) {
2142
+ threw = true;
2143
+ }
2144
+ assert(threw, 'Should throw for invalid channel');
1979
2145
  }
1980
2146
 
1981
- // TEST333: CartridgeRepoClient.get_all_caps() returns aggregate cap URNs from all cached cartridges
2147
+ // TEST333: CartridgeRepoClient.getAllAvailableCaps() returns the set
2148
+ // of normalized URNs across both channels.
1982
2149
  function test333_cartridgeRepoClientGetAllCaps() {
1983
2150
  const client = new CartridgeRepoClient(3600);
1984
2151
  const server = new CartridgeRepoServer(sampleRegistry);
@@ -1988,11 +2155,14 @@ function test333_cartridgeRepoClientGetAllCaps() {
1988
2155
 
1989
2156
  const caps = client.getAllAvailableCaps();
1990
2157
  assert(Array.isArray(caps), 'Should return array');
1991
- assert(caps.length === 3, 'Should have 3 unique caps');
2158
+ // 2 caps from pdfcartridge + 1 from txtcartridge + 1 from
2159
+ // jsoncartridge (nightly) = 4 unique caps.
2160
+ assert(caps.length === 4, `Should have 4 unique caps, got ${caps.length}`);
1992
2161
  assert(caps.every(c => typeof c === 'string'), 'All caps should be strings');
1993
2162
  }
1994
2163
 
1995
- // TEST334: CartridgeRepoClient.needs_sync() returns true when cache TTL has expired
2164
+ // TEST334: CartridgeRepoClient.needsSync() returns true when cache is
2165
+ // empty / stale, false right after a fresh update.
1996
2166
  function test334_cartridgeRepoClientNeedsSync() {
1997
2167
  const client = new CartridgeRepoClient(1); // 1 second TTL
1998
2168
  const server = new CartridgeRepoServer(sampleRegistry);
@@ -2008,12 +2178,10 @@ function test334_cartridgeRepoClientNeedsSync() {
2008
2178
 
2009
2179
  // Should not need sync immediately
2010
2180
  assert(client.needsSync(urls) === false, 'Should not need sync right after update');
2011
-
2012
- // Wait for cache to expire (1 second)
2013
- // Note: Can't test this synchronously, would need async test
2014
2181
  }
2015
2182
 
2016
- // TEST335: Server creates registry response and client consumes it end-to-end
2183
+ // TEST335: Round-trip: server produces a v5.0 response, client consumes
2184
+ // it, channel provenance is preserved end-to-end.
2017
2185
  function test335_cartridgeRepoServerClientIntegration() {
2018
2186
  // Server creates API response
2019
2187
  const server = new CartridgeRepoServer(sampleRegistry);
@@ -2022,12 +2190,15 @@ function test335_cartridgeRepoServerClientIntegration() {
2022
2190
  // Client consumes API response
2023
2191
  const client = new CartridgeRepoClient(3600);
2024
2192
  const cartridges = apiResponse.cartridges.map(p => new CartridgeInfo(p));
2025
- client.updateCache('https://example.com/api/cartridges', cartridges);
2193
+ const REGISTRY_URL = 'https://test.example/manifest';
2194
+ client.updateCache(REGISTRY_URL, cartridges);
2026
2195
 
2027
- // Client can find cartridge
2028
- const cartridge = client.getCartridge('pdfcartridge');
2029
- assert(cartridge !== null, 'Client should find cartridge from server data');
2196
+ // Client can find cartridge by (registryUrl, channel, id)
2197
+ const cartridge = client.getCartridge(REGISTRY_URL, 'release', 'pdfcartridge');
2198
+ assert(cartridge !== null && cartridge !== undefined, 'Client should find cartridge from server data');
2030
2199
  assert(cartridge.isSigned(), 'Cartridge should be signed');
2200
+ assert(cartridge.channel === 'release', 'Should report release channel');
2201
+ assert(cartridge.registryUrl === REGISTRY_URL, 'Should report registry URL');
2031
2202
  assert(cartridge.buildForPlatform('darwin-arm64') !== null, 'Cartridge should have darwin-arm64 build');
2032
2203
 
2033
2204
  // Client can get suggestions
@@ -2035,6 +2206,7 @@ function test335_cartridgeRepoServerClientIntegration() {
2035
2206
  const suggestions = client.getSuggestionsForCap(capUrn);
2036
2207
  assert(suggestions.length === 1, 'Should get suggestions');
2037
2208
  assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest correct cartridge');
2209
+ assert(suggestions[0].channel === 'release', 'Suggestion should preserve channel');
2038
2210
 
2039
2211
  // Server can search
2040
2212
  const searchResults = server.searchCartridges('pdf');
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "author": "Bahram Joharshamshiri",
3
3
  "dependencies": {
4
4
  "peggy": "^5.1.0",
5
- "tagged-urn": "^0.33.81"
5
+ "tagged-urn": "^0.34.83"
6
6
  },
7
7
  "description": "JavaScript implementation of Cap URN (Capability Uniform Resource Names) with strict validation and matching",
8
8
  "engines": {
@@ -40,5 +40,5 @@
40
40
  "pretest": "npm run build:parser",
41
41
  "test": "node capdag.test.js"
42
42
  },
43
- "version": "0.149.334"
43
+ "version": "0.153.347"
44
44
  }