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.
- package/capdag.js +309 -116
- package/capdag.test.js +223 -51
- 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(); //
|
|
3706
|
-
this.capToCartridges = new Map(); // cap_urn -> [
|
|
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
|
-
|
|
3734
|
-
|
|
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
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
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
|
|
3749
|
-
*
|
|
3750
|
-
*
|
|
3751
|
-
* (
|
|
3752
|
-
*
|
|
3753
|
-
* that fails to parse is a registry corruption:
|
|
3754
|
-
* silently keep the malformed string in the
|
|
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(
|
|
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(
|
|
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
|
|
3816
|
-
*
|
|
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
|
|
3825
|
-
if (!
|
|
3955
|
+
const refs = cache.capToCartridges.get(normalized);
|
|
3956
|
+
if (!refs) continue;
|
|
3826
3957
|
|
|
3827
|
-
for (const
|
|
3828
|
-
const cartridge = cache.cartridges.get(
|
|
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
|
|
3867
|
-
cartridges.push([
|
|
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
|
|
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
|
-
|
|
3891
|
-
|
|
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 !== '
|
|
3933
|
-
throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required:
|
|
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
|
-
|
|
3936
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
3982
|
-
const
|
|
3983
|
-
const
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3994
|
-
|
|
4167
|
+
if (!Array.isArray(cartridge.cap_groups)) {
|
|
4168
|
+
throw new Error(`Cartridge ${id} (${channel}): missing cap_groups array`);
|
|
4169
|
+
}
|
|
3995
4170
|
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
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
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
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
|
|
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
|
-
|
|
4039
|
-
|
|
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
|
-
*
|
|
4076
|
-
*
|
|
4077
|
-
*
|
|
4078
|
-
*
|
|
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
|
-
|
|
4087
|
-
|
|
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: '
|
|
1647
|
+
schemaVersion: '5.0',
|
|
1643
1648
|
lastUpdated: '2026-02-07T16:48:28Z',
|
|
1644
|
-
|
|
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
|
|
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 === '
|
|
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: '
|
|
1873
|
+
new CartridgeRepoServer({schemaVersion: '4.0', channels: {release: {cartridges: {}}, nightly: {cartridges: {}}}});
|
|
1821
1874
|
} catch (e) {
|
|
1822
1875
|
threw = true;
|
|
1823
|
-
assert(e.message.includes('
|
|
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
|
|
1878
|
+
assert(threw, 'Should throw for v4.0 schema');
|
|
1826
1879
|
|
|
1827
|
-
// Missing
|
|
1880
|
+
// Missing channels object
|
|
1828
1881
|
threw = false;
|
|
1829
1882
|
try {
|
|
1830
|
-
new CartridgeRepoServer({schemaVersion: '
|
|
1883
|
+
new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest'});
|
|
1831
1884
|
} catch (e) {
|
|
1832
1885
|
threw = true;
|
|
1833
|
-
assert(e.message.includes('
|
|
1886
|
+
assert(e.message.includes('channels'), 'Should reject missing channels');
|
|
1834
1887
|
}
|
|
1835
|
-
assert(threw, 'Should throw for missing
|
|
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
|
|
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 ===
|
|
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.
|
|
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 ===
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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(
|
|
2058
|
+
const cache = client.caches.get(REGISTRY_URL);
|
|
1938
2059
|
assert(cache !== undefined, 'Cache should exist');
|
|
1939
|
-
assert(cache.cartridges.size ===
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
43
|
+
"version": "0.153.347"
|
|
44
44
|
}
|