feat(desktop): surface each relay's MIP capabilities from its NIP-11 advertisement
Some checks failed
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Meridian Harness / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Publish rolling release (push) Has been cancelled
Meridian Harness / Publish tagged release (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Server Cross-Compile (push) Has been cancelled
CI / Server Cross-Compile-1 (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Some checks failed
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Meridian Harness / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Publish rolling release (push) Has been cancelled
Meridian Harness / Publish tagged release (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Server Cross-Compile (push) Has been cancelled
CI / Server Cross-Compile-1 (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
The MIP register, the AIS and Compact-Track-Code specs, the wire contract (NIP-11 `supported_extensions`, `mip-` prefixed) and the control plane's reader all existed. The desktop app read none of it, so a member had no way to see which Meridian Implementation Possibilities their relay actually honours. Adds Settings > Capabilities: the register with each MIP's lifecycle state, the capability packs grouped over it (AIS, ADS-B, APP-6E, STANAG-4609), and what this relay advertises right now. Kept honest in three places, because the failure this area already paid for once was NIP-17 advertised while kind:10050 was rejected: - A pack is available only when *every* member MIP is ENFORCED *and* the connected relay advertises it. Today that is 0 of 4, and the card says so rather than offering a toggle that cannot work. - An unreachable relay renders as "not reachable", never as "no capabilities" - "we could not ask" is not an answer. - A relay advertising a MIP the register does not call ENFORCED raises an over-claim warning instead of being trusted. `docs/mips/README.md` stays the one registry: `just check-mips` regenerates the client's copy from it and fails on drift, so the register cannot become two lists. The pack table is new there, and carries the dependency closure plus a Missing column that must name any MIP nobody has written yet. Also corrects the acronym in the three places that expanded it as "Meridian Improvement Proposals". A MIP documents what compatible relay and client software *may* implement - which is why ENFORCED gates advertisement at all. Refs: meridian-hgjj, meridian-4guo Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
This commit is contained in:
parent
0b0242c3c0
commit
f7a0f0cdc8
15 changed files with 1082 additions and 6 deletions
File diff suppressed because one or more lines are too long
9
Justfile
9
Justfile
|
|
@ -135,7 +135,7 @@ build-release:
|
|||
# `desktop-check` / `web-check` are biome plus the guard scripts — neither runs
|
||||
# `tsc`. Without the typecheck recipes here, this whole gate passes on a tree
|
||||
# that does not compile, and the first thing to notice is CI's `desktop-build`.
|
||||
check: fmt-check clippy desktop-check desktop-typecheck desktop-tauri-fmt-check desktop-tauri-clippy web-check web-typecheck mobile-check compose-check backup-restore-check git-pointer-repair-contract nip34-search-rollout-check reply-persistence-benchmark-contract capacity-contract-check check-kinds check-skills check-brand check-feature-specs check-relay-e2e-inventory check-frozen-migrations check-legacy-namespaces check-relay-terminology
|
||||
check: fmt-check clippy desktop-check desktop-typecheck desktop-tauri-fmt-check desktop-tauri-clippy web-check web-typecheck mobile-check compose-check backup-restore-check git-pointer-repair-contract nip34-search-rollout-check reply-persistence-benchmark-contract capacity-contract-check check-kinds check-skills check-brand check-feature-specs check-mips check-relay-e2e-inventory check-frozen-migrations check-legacy-namespaces check-relay-terminology
|
||||
|
||||
# Verify the local backing stack contract (Dragonfly swap, database isolation,
|
||||
# and every caller that names those services). No Docker required — the
|
||||
|
|
@ -189,6 +189,13 @@ check-feature-specs:
|
|||
node scripts/check-feature-specs.mjs
|
||||
node --test scripts/check-feature-specs-core.test.mjs
|
||||
|
||||
# `docs/mips/README.md` is the one MIP register. The desktop capability view
|
||||
# reads a generated copy; this fails when the two drift. Pass `--write` to
|
||||
# regenerate after editing the register.
|
||||
check-mips *ARGS:
|
||||
node scripts/check-mip-registry.mjs {{ARGS}}
|
||||
node --test scripts/mip-registry-core.test.mjs
|
||||
|
||||
# Every ignored relay integration binary must have an executable or reviewed lane.
|
||||
check-relay-e2e-inventory:
|
||||
node scripts/check-relay-e2e-inventory.mjs
|
||||
|
|
|
|||
2
TASKS.md
2
TASKS.md
|
|
@ -364,7 +364,7 @@ Compose service**, because `just _ensure-services` blocks on core health.
|
|||
|
||||
---
|
||||
|
||||
## 5. The MIP system — Meridian Improvement Proposals
|
||||
## 5. The MIP system — Meridian Implementation Possibilities
|
||||
|
||||
### 5.1 The deconfliction rule
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!**/*.generated.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,77 @@ pub async fn fetch_workspace_icon(
|
|||
Ok(doc.icon.filter(|icon| !icon.is_empty()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RelayInfoExtensions {
|
||||
#[serde(default)]
|
||||
supported_extensions: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Which MIPs a relay advertises, split out of NIP-11 `supported_extensions`.
|
||||
#[derive(Serialize, Default, PartialEq, Eq, Debug)]
|
||||
pub struct RelayMips {
|
||||
/// Whether the relay answered at all. `false` means "we do not know",
|
||||
/// which the capability view must render differently from "none".
|
||||
pub reachable: bool,
|
||||
/// Advertised MIP ids, lower-cased (`mip-as`).
|
||||
pub mips: Vec<String>,
|
||||
/// Every other advertised extension (`nip-er`, …).
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Split NIP-11 `supported_extensions` into MIPs and everything else.
|
||||
///
|
||||
/// Mirrors `split_extensions` in `crates/meridian-control-plane/src/http.rs`
|
||||
/// and `splitExtensions` in `scripts/mip-registry-core.mjs`. The prefix is the
|
||||
/// contract: a repo-local proposal advertises as `mip-xx`, and per
|
||||
/// `docs/mips/README.md` it may only be advertised once it reaches `ENFORCED`.
|
||||
fn split_extensions(values: &[String]) -> (Vec<String>, Vec<String>) {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.partition(|value| value.to_ascii_lowercase().starts_with("mip-"))
|
||||
}
|
||||
|
||||
/// Fetch the MIPs a relay advertises in its NIP-11 document.
|
||||
///
|
||||
/// Unauthenticated HTTP GET, same shape as [`fetch_workspace_icon`] — no
|
||||
/// WebSocket session needed, so the capability view renders before (and
|
||||
/// without) a connected relay.
|
||||
///
|
||||
/// An unreachable or malformed relay returns `reachable: false` rather than an
|
||||
/// error. The distinction matters: "this relay advertises no MIPs" is a fact
|
||||
/// about the relay, while "we could not ask" is a fact about the network, and
|
||||
/// collapsing the two would show an empty capability list as if it were
|
||||
/// answered.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_relay_mips(
|
||||
relay_url: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<RelayMips, String> {
|
||||
let http_url = relay::relay_http_base_url(&relay_url);
|
||||
let Ok(response) = state
|
||||
.http_client
|
||||
.get(&http_url)
|
||||
.header("Accept", "application/nostr+json")
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
return Ok(RelayMips::default());
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return Ok(RelayMips::default());
|
||||
}
|
||||
let Ok(doc) = response.json::<RelayInfoExtensions>().await else {
|
||||
return Ok(RelayMips::default());
|
||||
};
|
||||
let (mips, extensions) = split_extensions(&doc.supported_extensions.unwrap_or_default());
|
||||
Ok(RelayMips {
|
||||
reachable: true,
|
||||
mips: mips.iter().map(|mip| mip.to_ascii_lowercase()).collect(),
|
||||
extensions,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActiveWorkspaceInfo {
|
||||
relay_url: String,
|
||||
|
|
@ -288,3 +359,56 @@ pub async fn apply_workspace(
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn owned(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_mips_from_other_extensions() {
|
||||
let (mips, extensions) = split_extensions(&owned(&["nip-er", "mip-as", "mip-ct"]));
|
||||
assert_eq!(mips, owned(&["mip-as", "mip-ct"]));
|
||||
assert_eq!(extensions, owned(&["nip-er"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_match_is_case_insensitive() {
|
||||
let (mips, extensions) = split_extensions(&owned(&["MIP-AS", "NIP-ER"]));
|
||||
assert_eq!(mips, owned(&["MIP-AS"]));
|
||||
assert_eq!(extensions, owned(&["NIP-ER"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_advertisement_yields_no_mips() {
|
||||
let (mips, extensions) = split_extensions(&[]);
|
||||
assert!(mips.is_empty());
|
||||
assert!(extensions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip_prefixed_values_are_never_mistaken_for_mips() {
|
||||
// `nip-` and `mip-` differ by one character; a substring match here
|
||||
// would advertise every NIP as a MIP in the capability view.
|
||||
let (mips, _) = split_extensions(&owned(&["nip-mip-er"]));
|
||||
assert!(mips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_relay_is_distinct_from_no_mips() {
|
||||
// The default is what `fetch_relay_mips` returns when the relay does
|
||||
// not answer. It must not look like an answered "none".
|
||||
let unknown = RelayMips::default();
|
||||
assert!(!unknown.reachable);
|
||||
assert!(unknown.mips.is_empty());
|
||||
|
||||
let answered_none = RelayMips {
|
||||
reachable: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_ne!(unknown, answered_none);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -905,6 +905,7 @@ pub fn run() {
|
|||
validate_repos_dir,
|
||||
get_active_workspace,
|
||||
fetch_workspace_icon,
|
||||
fetch_relay_mips,
|
||||
fetch_join_policy,
|
||||
set_prevent_sleep_active,
|
||||
get_agent_memory,
|
||||
|
|
|
|||
191
desktop/src/features/settings/ui/CapabilitiesSettingsCard.tsx
Normal file
191
desktop/src/features/settings/ui/CapabilitiesSettingsCard.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import {
|
||||
capabilityPacks,
|
||||
mips,
|
||||
packStatus,
|
||||
reconcile,
|
||||
useRelayMips,
|
||||
type CapabilityPack,
|
||||
type Mip,
|
||||
type RelayMips,
|
||||
} from "@/shared/mips";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
|
||||
/** Per-state copy. `ENFORCED` is the only state a relay may advertise. */
|
||||
const STATE_COPY: Record<Mip["state"], { label: string; className: string }> = {
|
||||
DRAFT: { label: "Draft", className: "text-muted-foreground" },
|
||||
REVIEW: { label: "In review", className: "text-muted-foreground" },
|
||||
IMPLEMENTABLE: { label: "Implementable", className: "text-foreground" },
|
||||
ENFORCED: { label: "Enforced", className: "text-emerald-500" },
|
||||
WITHDRAWN: { label: "Withdrawn", className: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
const PACK_STATUS_COPY = {
|
||||
active: { label: "Active", className: "text-emerald-500" },
|
||||
inactive: {
|
||||
label: "Not enabled on this relay",
|
||||
className: "text-muted-foreground",
|
||||
},
|
||||
unavailable: {
|
||||
label: "Not available yet",
|
||||
className: "text-muted-foreground",
|
||||
},
|
||||
unknown: { label: "Relay not reachable", className: "text-muted-foreground" },
|
||||
} as const;
|
||||
|
||||
function PackRow({ pack, relay }: { pack: CapabilityPack; relay: RelayMips }) {
|
||||
const status = packStatus(pack, relay);
|
||||
const copy = PACK_STATUS_COPY[status];
|
||||
|
||||
// Say *why* a pack is dark. "Unavailable" with no reason is the thing that
|
||||
// sends someone hunting for a toggle that does not exist.
|
||||
const reason =
|
||||
pack.missing !== null
|
||||
? pack.missing
|
||||
: status === "unavailable"
|
||||
? `waiting on ${pack.blockedBy.join(", ")}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-border/70 bg-background/70 px-4 py-3"
|
||||
data-testid={`capability-pack-${pack.id}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{pack.id}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pack.requires.length > 0
|
||||
? pack.requires.join(" · ")
|
||||
: "No MIP specified yet"}
|
||||
</p>
|
||||
{reason ? (
|
||||
<p className="text-2xs text-muted-foreground">{reason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={`shrink-0 text-xs ${copy.className}`}>{copy.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MipRow({ mip, advertised }: { mip: Mip; advertised: boolean }) {
|
||||
const copy = STATE_COPY[mip.state];
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-1 py-1.5"
|
||||
data-testid={`mip-row-${mip.id}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium">
|
||||
{mip.id}
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
{mip.title}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shrink-0 text-2xs ${copy.className}`}>
|
||||
{advertised ? "Advertised" : copy.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapabilitiesSettingsCard() {
|
||||
const [relayUrl, setRelayUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<{ relay_url: string }>("get_active_workspace")
|
||||
.then((info) => setRelayUrl(info.relay_url))
|
||||
.catch(() => setRelayUrl(undefined));
|
||||
}, []);
|
||||
|
||||
const relay = useRelayMips(relayUrl);
|
||||
const advertised = new Set(relay.mips.map((id) => id.toLowerCase()));
|
||||
const { advertisedNotEnforced } = reconcile(relay);
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-capabilities">
|
||||
<SettingsSectionHeader
|
||||
title="Capabilities"
|
||||
description={
|
||||
<>
|
||||
Meridian Implementation Possibilities (MIPs) document what a relay
|
||||
and client <em>may</em> implement. A capability pack turns on when
|
||||
every MIP it needs is enforced by the relay you are connected to —
|
||||
it is a property of that relay, not a switch in this app.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!relay.reachable ? (
|
||||
<p
|
||||
className="mb-4 rounded-lg border border-border/70 bg-background/70 px-4 py-3 text-xs text-muted-foreground"
|
||||
data-testid="capabilities-unreachable"
|
||||
>
|
||||
Could not read this relay's capabilities. The list below is the
|
||||
registry, not this relay's answer.
|
||||
</p>
|
||||
) : relay.mips.length === 0 ? (
|
||||
<p
|
||||
className="mb-4 rounded-lg border border-border/70 bg-background/70 px-4 py-3 text-xs text-muted-foreground"
|
||||
data-testid="capabilities-none"
|
||||
>
|
||||
This relay advertises no MIPs yet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{advertisedNotEnforced.length > 0 ? (
|
||||
<p
|
||||
className="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-xs text-amber-600 dark:text-amber-400"
|
||||
data-testid="capabilities-overclaim"
|
||||
>
|
||||
This relay advertises {advertisedNotEnforced.join(", ")}, which the
|
||||
registry does not list as enforced. Treat that support as unverified.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<section aria-labelledby="capability-packs">
|
||||
<p
|
||||
className="text-xs font-semibold text-foreground"
|
||||
id="capability-packs"
|
||||
>
|
||||
Capability packs
|
||||
</p>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
Domain bundles — a pack is a grouping over the MIP register, never a
|
||||
separate list.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{capabilityPacks.map((pack) => (
|
||||
<PackRow key={pack.id} pack={pack} relay={relay} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="mip-register">
|
||||
<p
|
||||
className="text-xs font-semibold text-foreground"
|
||||
id="mip-register"
|
||||
>
|
||||
MIP register
|
||||
</p>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
Every proposal and its lifecycle state. Only an enforced MIP may be
|
||||
advertised by a relay.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col divide-y divide-border/50 rounded-lg border border-border/70 bg-background/70 px-3 py-1">
|
||||
{mips.map((mip) => (
|
||||
<MipRow
|
||||
key={mip.id}
|
||||
mip={mip}
|
||||
advertised={advertised.has(mip.id.toLowerCase())}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Archive,
|
||||
BellRing,
|
||||
Bot,
|
||||
Boxes,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Cpu,
|
||||
|
|
@ -72,6 +73,7 @@ import {
|
|||
} from "@/shared/theme/useThemePreviewVars";
|
||||
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
|
||||
import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel";
|
||||
import { CapabilitiesSettingsCard } from "./CapabilitiesSettingsCard";
|
||||
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
|
||||
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
|
||||
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
|
||||
|
|
@ -95,6 +97,7 @@ export type SettingsSection =
|
|||
| "notifications"
|
||||
| "voice"
|
||||
| "experimental"
|
||||
| "capabilities"
|
||||
| "agents"
|
||||
| "channel-templates"
|
||||
| "compute"
|
||||
|
|
@ -116,6 +119,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
|
|||
"notifications",
|
||||
"voice",
|
||||
"experimental",
|
||||
"capabilities",
|
||||
"agents",
|
||||
"channel-templates",
|
||||
"compute",
|
||||
|
|
@ -224,6 +228,27 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
|||
icon: FlaskConical,
|
||||
keywords: ["preview", "beta", "alpha", "feature flags", "early access"],
|
||||
},
|
||||
{
|
||||
value: "capabilities",
|
||||
label: "Capabilities",
|
||||
icon: Boxes,
|
||||
keywords: [
|
||||
"mip",
|
||||
"mips",
|
||||
"plugin",
|
||||
"plugins",
|
||||
"capability",
|
||||
"pack",
|
||||
"ais",
|
||||
"ads-b",
|
||||
"adsb",
|
||||
"app-6e",
|
||||
"2525",
|
||||
"stanag",
|
||||
"extensions",
|
||||
"nip-11",
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "agents",
|
||||
label: "Agents",
|
||||
|
|
@ -887,6 +912,8 @@ export function renderSettingsSection(
|
|||
return <VoiceSettingsCard currentPubkey={props.currentPubkey} />;
|
||||
case "experimental":
|
||||
return <ExperimentalFeaturesCard />;
|
||||
case "capabilities":
|
||||
return <CapabilitiesSettingsCard />;
|
||||
case "agents":
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
|
|
|
|||
140
desktop/src/shared/mips/index.ts
Normal file
140
desktop/src/shared/mips/index.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* MIP registry and relay capability state.
|
||||
*
|
||||
* `docs/mips/README.md` is the one registry. `registry.generated.json` is
|
||||
* produced from it by `just check-mips`, which fails when the two drift — so
|
||||
* nothing here is hand-maintained, and the client cannot quietly disagree with
|
||||
* the register about what exists or what state it is in.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import generated from "./registry.generated.json";
|
||||
|
||||
/** Lifecycle state of a MIP. Only `ENFORCED` may be advertised over NIP-11. */
|
||||
export type MipState =
|
||||
| "DRAFT"
|
||||
| "REVIEW"
|
||||
| "IMPLEMENTABLE"
|
||||
| "ENFORCED"
|
||||
| "WITHDRAWN";
|
||||
|
||||
export type Mip = {
|
||||
id: string;
|
||||
title: string;
|
||||
layer: string;
|
||||
state: MipState;
|
||||
bead: string | null;
|
||||
};
|
||||
|
||||
export type CapabilityPack = {
|
||||
id: string;
|
||||
/** MIPs that must all be ENFORCED for the pack to be available. */
|
||||
requires: string[];
|
||||
/** Declared unwritten work, or null. A non-null value can never clear. */
|
||||
missing: string | null;
|
||||
/** Required ids with no register row — always a subset of `missing`. */
|
||||
unknown: string[];
|
||||
/** True only when every requirement is ENFORCED and nothing is missing. */
|
||||
available: boolean;
|
||||
/** Requirements not yet ENFORCED, so the UI can say why a pack is dark. */
|
||||
blockedBy: string[];
|
||||
};
|
||||
|
||||
export const mips: Mip[] = generated.mips as Mip[];
|
||||
export const capabilityPacks: CapabilityPack[] =
|
||||
generated.packs as CapabilityPack[];
|
||||
|
||||
/** What a relay told us about its own MIP support. */
|
||||
export type RelayMips = {
|
||||
/** False means "we could not ask", which is not the same as "none". */
|
||||
reachable: boolean;
|
||||
/** Advertised MIP ids, lower-cased (`mip-as`). */
|
||||
mips: string[];
|
||||
/** Every other advertised extension. */
|
||||
extensions: string[];
|
||||
};
|
||||
|
||||
const UNKNOWN: RelayMips = { reachable: false, mips: [], extensions: [] };
|
||||
|
||||
/**
|
||||
* Read the MIPs a relay advertises in NIP-11 `supported_extensions`.
|
||||
*
|
||||
* Returns `reachable: false` until the relay answers. A failed probe is
|
||||
* reported as unknown rather than as an empty advertisement — showing "no
|
||||
* capabilities" for a relay we never reached is the same class of lie as
|
||||
* advertising a MIP with no reader.
|
||||
*/
|
||||
export function useRelayMips(relayUrl: string | undefined): RelayMips {
|
||||
const [state, setState] = useState<RelayMips>(UNKNOWN);
|
||||
|
||||
useEffect(() => {
|
||||
if (!relayUrl) {
|
||||
setState(UNKNOWN);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
invoke<RelayMips>("fetch_relay_mips", { relayUrl })
|
||||
.then((result) => {
|
||||
if (!cancelled) setState(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState(UNKNOWN);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [relayUrl]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** The advertisement id for a MIP: `MIP-AS` -> `mip-as`. */
|
||||
export function advertisementId(mipId: string): string {
|
||||
return mipId.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the register against one relay's advertisement.
|
||||
*
|
||||
* Two disagreements are worth surfacing rather than smoothing over, because
|
||||
* each is a bug in a different place:
|
||||
*
|
||||
* - `advertisedNotEnforced` — the relay claims a MIP the register does not
|
||||
* consider `ENFORCED`. This is the NIP-17 failure shape, and the relay is
|
||||
* wrong.
|
||||
* - `enforcedNotAdvertised` — the register says `ENFORCED` but this relay does
|
||||
* not carry it. Legitimate: state is per-deployment, not global.
|
||||
*/
|
||||
export function reconcile(relay: RelayMips) {
|
||||
const advertised = new Set(relay.mips.map((id) => id.toLowerCase()));
|
||||
const enforced = mips.filter((mip) => mip.state === "ENFORCED");
|
||||
|
||||
return {
|
||||
advertisedNotEnforced: [...advertised].filter(
|
||||
(id) => !enforced.some((mip) => advertisementId(mip.id) === id),
|
||||
),
|
||||
enforcedNotAdvertised: enforced
|
||||
.filter((mip) => !advertised.has(advertisementId(mip.id)))
|
||||
.map((mip) => mip.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pack is usable on this relay right now.
|
||||
*
|
||||
* Both conditions must hold: the register must consider every member MIP
|
||||
* `ENFORCED`, and this relay must actually advertise them. A pack that is
|
||||
* available in the register but dark on the connected relay is the normal
|
||||
* case for a deployment that has not enabled it.
|
||||
*/
|
||||
export function packStatus(pack: CapabilityPack, relay: RelayMips) {
|
||||
if (!relay.reachable) return "unknown" as const;
|
||||
if (!pack.available) return "unavailable" as const;
|
||||
const advertised = new Set(relay.mips.map((id) => id.toLowerCase()));
|
||||
const missing = pack.requires.filter(
|
||||
(id) => !advertised.has(advertisementId(id)),
|
||||
);
|
||||
return missing.length === 0 ? ("active" as const) : ("inactive" as const);
|
||||
}
|
||||
146
desktop/src/shared/mips/registry.generated.json
Normal file
146
desktop/src/shared/mips/registry.generated.json
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
{
|
||||
"$generated": "docs/mips/README.md — edit there, then run `just check-mips --write`",
|
||||
"mips": [
|
||||
{
|
||||
"id": "MIP-RG",
|
||||
"title": "Proposal registry, lifecycle, numbering",
|
||||
"layer": "Process",
|
||||
"state": "DRAFT",
|
||||
"bead": null
|
||||
},
|
||||
{
|
||||
"id": "MIP-OF",
|
||||
"title": "Opaque frame envelope",
|
||||
"layer": "Wire",
|
||||
"state": "DRAFT",
|
||||
"bead": "meridian-tdq"
|
||||
},
|
||||
{
|
||||
"id": "MIP-XP",
|
||||
"title": "Negotiated exchange profiles (P0/P1/P2)",
|
||||
"layer": "Link",
|
||||
"state": "DRAFT",
|
||||
"bead": "meridian-2e2"
|
||||
},
|
||||
{
|
||||
"id": "MIP-SF",
|
||||
"title": "Session frames",
|
||||
"layer": "Wire",
|
||||
"state": "DRAFT",
|
||||
"bead": "meridian-owl"
|
||||
},
|
||||
{
|
||||
"id": "MIP-QC",
|
||||
"title": "QoS classes and congestion contracts",
|
||||
"layer": "Scheduling",
|
||||
"state": "DRAFT",
|
||||
"bead": "meridian-9j8"
|
||||
},
|
||||
{
|
||||
"id": "MIP-LF",
|
||||
"title": "Leaf forwarding",
|
||||
"layer": "Topology",
|
||||
"state": "DRAFT",
|
||||
"bead": null
|
||||
},
|
||||
{
|
||||
"id": "MIP-MQ",
|
||||
"title": "MQTT interoperability",
|
||||
"layer": "Interop",
|
||||
"state": "DRAFT",
|
||||
"bead": null
|
||||
},
|
||||
{
|
||||
"id": "MIP-KN",
|
||||
"title": "Kinematic state payload schema",
|
||||
"layer": "Payload",
|
||||
"state": "DRAFT",
|
||||
"bead": "meridian-ss1"
|
||||
},
|
||||
{
|
||||
"id": "MIP-TL",
|
||||
"title": "Scalar telemetry payload schema",
|
||||
"layer": "Payload",
|
||||
"state": "DRAFT",
|
||||
"bead": null
|
||||
},
|
||||
{
|
||||
"id": "MIP-AS",
|
||||
"title": "AIS feed profile",
|
||||
"layer": "Profile",
|
||||
"state": "DRAFT",
|
||||
"bead": "meridian-hgjj"
|
||||
},
|
||||
{
|
||||
"id": "MIP-CT",
|
||||
"title": "Drone backbone telemetry — symbol identity (SIDC/XSIDC) and Compact Track Code",
|
||||
"layer": "Profile",
|
||||
"state": "DRAFT",
|
||||
"bead": "meridian-2fwq"
|
||||
}
|
||||
],
|
||||
"packs": [
|
||||
{
|
||||
"id": "AIS",
|
||||
"requires": [
|
||||
"MIP-OF",
|
||||
"MIP-XP",
|
||||
"MIP-QC",
|
||||
"MIP-KN",
|
||||
"MIP-AS"
|
||||
],
|
||||
"missing": null,
|
||||
"unknown": [],
|
||||
"available": false,
|
||||
"blockedBy": [
|
||||
"MIP-OF",
|
||||
"MIP-XP",
|
||||
"MIP-QC",
|
||||
"MIP-KN",
|
||||
"MIP-AS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ADS-B",
|
||||
"requires": [
|
||||
"MIP-OF",
|
||||
"MIP-XP",
|
||||
"MIP-QC",
|
||||
"MIP-KN",
|
||||
"MIP-AD"
|
||||
],
|
||||
"missing": "MIP-AD is unwritten",
|
||||
"unknown": [
|
||||
"MIP-AD"
|
||||
],
|
||||
"available": false,
|
||||
"blockedBy": [
|
||||
"MIP-OF",
|
||||
"MIP-XP",
|
||||
"MIP-QC",
|
||||
"MIP-KN",
|
||||
"MIP-AD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "APP-6E",
|
||||
"requires": [
|
||||
"MIP-CT"
|
||||
],
|
||||
"missing": null,
|
||||
"unknown": [],
|
||||
"available": false,
|
||||
"blockedBy": [
|
||||
"MIP-CT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "STANAG-4609",
|
||||
"requires": [],
|
||||
"missing": "no MIP specified yet",
|
||||
"unknown": [],
|
||||
"available": false,
|
||||
"blockedBy": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ troubleshooting playbooks. Not a changelog and not a design diary — top-level
|
|||
| Path | Owns |
|
||||
| --- | --- |
|
||||
| `nips/` | Repo-local NIP extensions: `NIP-AA`, `AB`, `AE`, `AM`, `AO`, `AP`, `CW`, `DV`, `ER`, `GC`, `GS`, `IA`, `OA`, `PL`, `RS`, `WF`, `WP` — the normative wire contracts. `GC` (git collaboration) and `WF` (channel workflows) are **profiles**: they restrict and pin down kinds defined upstream (NIP-34) or already shipped, rather than adding new ones |
|
||||
| `mips/` | Meridian Improvement Proposals — repo-local proposals under the two-letter scheme, with `README.md` as the register and lifecycle. `MIP-AS` (AIS feed profile) and `MIP-CT` (drone backbone telemetry — SIDC/XSIDC and the Compact Track Code) are the landed documents; the rest of the suite is specified in [TASKS.md § 5](../TASKS.md) and migrates here under `MRDN-201`. **`ENFORCED` is the only state that permits NIP-11 advertisement** |
|
||||
| `mips/` | Meridian Implementation Possibilities — repo-local proposals documenting what Meridian-compatible relay and client software **may** implement, under the two-letter scheme, with `README.md` as the register and lifecycle. `MIP-AS` (AIS feed profile) and `MIP-CT` (drone backbone telemetry — SIDC/XSIDC and the Compact Track Code) are the landed documents; the rest of the suite is specified in [TASKS.md § 5](../TASKS.md) and migrates here under `MRDN-201`. **`ENFORCED` is the only state that permits NIP-11 advertisement** |
|
||||
| `spec/` | Machine-checked models: `MultiTenantRelay.tla`/`.cfg`, `GitOnObjectStore.tla`/`.cfg` (TLA+), `MultiTenantAuth.spthy`, `NIP-AB.spthy` (Tamarin) |
|
||||
| `formal/` | `STATEFUL_GATEWAY.md` and `nip-pl/` — executable acceptance + mutation tests in Python (`acceptance.py`, `delivery.py`, `*_mutation.py`, `mutation_test.py`, `NOTE.md`) |
|
||||
| `admin/` | Operator dashboard guide |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
MIP registry
|
||||
============
|
||||
|
||||
Meridian Improvement Proposals — repo-local protocol proposals.
|
||||
Meridian Implementation Possibilities — repo-local proposals documenting what
|
||||
Meridian-compatible relay and client software **may** implement. A MIP is
|
||||
optional by construction, exactly as an upstream NIP is; that optionality is
|
||||
why an implementation has to be able to say which MIPs it actually honours.
|
||||
|
||||
`draft`
|
||||
|
||||
|
|
@ -62,3 +65,23 @@ Onboarding offers domain **capability packs** (AIS, ADS-B, 2525/APP-6E,
|
|||
STANAG 4609). A pack is a named bundle of MIPs a deployment has moved to
|
||||
`ENFORCED` — a UI grouping over this register, never a second registry. See
|
||||
`meridian-4guo`.
|
||||
|
||||
A pack is **available** on a relay only when *every* MIP in its row is
|
||||
`ENFORCED` and advertised by that relay. Partial support is not support: a pack
|
||||
that lights up while one member MIP is still `DRAFT` is the NIP-17 failure
|
||||
above, wearing a friendlier label.
|
||||
|
||||
| Pack | Requires | Missing |
|
||||
| --- | --- | --- |
|
||||
| `AIS` | `MIP-OF`, `MIP-XP`, `MIP-QC`, `MIP-KN`, `MIP-AS` | — |
|
||||
| `ADS-B` | `MIP-OF`, `MIP-XP`, `MIP-QC`, `MIP-KN`, `MIP-AD` | `MIP-AD` is unwritten |
|
||||
| `APP-6E` | `MIP-CT` | — |
|
||||
| `STANAG-4609` | — | no MIP specified yet |
|
||||
|
||||
The `Requires` column is the dependency closure declared by each profile MIP's
|
||||
header — for `AIS`, the `Depends on` line of [MIP-AS](MIP-AS.md). A pack whose
|
||||
`Missing` cell is non-empty can never become available; it names the work.
|
||||
|
||||
**This table is the source of the client's pack view.** `just check-mips`
|
||||
regenerates `desktop/src/shared/mips/registry.generated.json` from this file and
|
||||
fails when the two disagree, so the register stays the one registry.
|
||||
|
|
|
|||
76
scripts/check-mip-registry.mjs
Normal file
76
scripts/check-mip-registry.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate the desktop client's MIP registry from `docs/mips/README.md`, and
|
||||
* fail when the committed copy has drifted.
|
||||
*
|
||||
* `docs/mips/README.md` is the one registry. The client needs the same data to
|
||||
* render its capability view; this script is the only sanctioned way for it to
|
||||
* get there, so the doc stays authoritative and the copy stays honest.
|
||||
*
|
||||
* node scripts/check-mip-registry.mjs # verify (CI)
|
||||
* node scripts/check-mip-registry.mjs --write # regenerate
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { buildRegistry, validateRegistry } from "./mip-registry-core.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SOURCE = resolve(root, "docs/mips/README.md");
|
||||
const GENERATED = resolve(root, "desktop/src/shared/mips/registry.generated.json");
|
||||
|
||||
const write = process.argv.includes("--write");
|
||||
|
||||
const registry = buildRegistry(readFileSync(SOURCE, "utf8"));
|
||||
|
||||
const errors = validateRegistry(registry);
|
||||
if (errors.length > 0) {
|
||||
console.error("docs/mips/README.md is not a valid register:\n");
|
||||
for (const error of errors) console.error(` - ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// The banner rides in the artifact so anyone who opens it is told where to edit.
|
||||
const payload = `${JSON.stringify(
|
||||
{
|
||||
$generated: "docs/mips/README.md — edit there, then run `just check-mips --write`",
|
||||
...registry,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
|
||||
if (write) {
|
||||
writeFileSync(GENERATED, payload);
|
||||
console.log(
|
||||
`check-mips: wrote ${registry.mips.length} MIPs and ${registry.packs.length} packs`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let current = null;
|
||||
try {
|
||||
current = readFileSync(GENERATED, "utf8");
|
||||
} catch {
|
||||
console.error(
|
||||
`check-mips: ${GENERATED} is missing.\nRun: just check-mips --write`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (current !== payload) {
|
||||
console.error(
|
||||
"check-mips: desktop/src/shared/mips/registry.generated.json is stale.\n" +
|
||||
"docs/mips/README.md changed without regenerating the client copy.\n" +
|
||||
"Run: just check-mips --write",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const advertisable = registry.mips.filter((mip) => mip.state === "ENFORCED");
|
||||
console.log(
|
||||
`check-mips: ok — ${registry.mips.length} MIPs (${advertisable.length} ENFORCED), ` +
|
||||
`${registry.packs.filter((pack) => pack.available).length}/${registry.packs.length} packs available`,
|
||||
);
|
||||
197
scripts/mip-registry-core.mjs
Normal file
197
scripts/mip-registry-core.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* Parse `docs/mips/README.md` into the MIP register and the capability packs
|
||||
* derived from it.
|
||||
*
|
||||
* The register is the one registry. The desktop client needs the same data to
|
||||
* render its capability view, and a hand-maintained TypeScript copy is exactly
|
||||
* the "second registry" that `docs/mips/README.md` forbids — so the copy is
|
||||
* generated from this parser and a check fails when the two disagree.
|
||||
*
|
||||
* Pure: the caller supplies the already-read markdown, so the logic is testable
|
||||
* without a fixture tree.
|
||||
*/
|
||||
|
||||
/** Lifecycle states, in order. Only `ENFORCED` permits NIP-11 advertisement. */
|
||||
export const MIP_STATES = ["DRAFT", "REVIEW", "IMPLEMENTABLE", "ENFORCED", "WITHDRAWN"];
|
||||
|
||||
/** The only state a relay may advertise. See `docs/mips/README.md` § Lifecycle. */
|
||||
export const ADVERTISABLE_STATE = "ENFORCED";
|
||||
|
||||
/** Strip markdown link and code wrappers: ``[`MIP-AS`](MIP-AS.md)`` -> `MIP-AS`. */
|
||||
function bare(cell) {
|
||||
return cell
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/`/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Split one markdown table row into trimmed cells. */
|
||||
function cells(line) {
|
||||
return line
|
||||
.replace(/^\s*\|/, "")
|
||||
.replace(/\|\s*$/, "")
|
||||
.split("|")
|
||||
.map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
/** True for a table separator row (`| --- | --- |`). */
|
||||
function isSeparator(line) {
|
||||
return /^\s*\|[\s:|-]+\|\s*$/.test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the rows of the first markdown table appearing after `heading`.
|
||||
*
|
||||
* @param {string} source Markdown.
|
||||
* @param {RegExp} heading Matches the heading line that introduces the table.
|
||||
* @returns {string[][]} Data rows as cell arrays, header and separator dropped.
|
||||
*/
|
||||
function tableAfter(source, heading) {
|
||||
const lines = source.split("\n");
|
||||
const start = lines.findIndex((line) => heading.test(line));
|
||||
if (start === -1) return [];
|
||||
|
||||
const rows = [];
|
||||
let seenHeader = false;
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const isRow = /^\s*\|/.test(line);
|
||||
if (!isRow) {
|
||||
if (rows.length > 0 || seenHeader) break;
|
||||
continue;
|
||||
}
|
||||
if (isSeparator(line)) continue;
|
||||
if (!seenHeader) {
|
||||
seenHeader = true;
|
||||
continue;
|
||||
}
|
||||
rows.push(cells(line));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** An em dash or empty cell both mean "nothing here". */
|
||||
function optional(cell) {
|
||||
const value = bare(cell);
|
||||
return value === "" || value === "—" || value === "-" ? null : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `## Register` table.
|
||||
*
|
||||
* @param {string} source Contents of `docs/mips/README.md`.
|
||||
* @returns {{id: string, title: string, layer: string, state: string, bead: string|null}[]}
|
||||
*/
|
||||
export function parseRegister(source) {
|
||||
return tableAfter(source, /^##\s+Register\s*$/).map((row) => {
|
||||
const [id, title, layer, state, bead] = row;
|
||||
const parsedState = bare(state).toUpperCase();
|
||||
if (!MIP_STATES.includes(parsedState)) {
|
||||
throw new Error(`${bare(id)}: unknown lifecycle state "${bare(state)}"`);
|
||||
}
|
||||
return {
|
||||
id: bare(id),
|
||||
title: bare(title),
|
||||
layer: bare(layer),
|
||||
state: parsedState,
|
||||
bead: optional(bead),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the capability-pack table under "Capability packs are not MIPs".
|
||||
*
|
||||
* @param {string} source Contents of `docs/mips/README.md`.
|
||||
* @returns {{id: string, requires: string[], missing: string|null}[]}
|
||||
*/
|
||||
export function parsePacks(source) {
|
||||
return tableAfter(source, /^##\s+Capability packs are not MIPs\s*$/).map((row) => {
|
||||
const [id, requires, missing] = row;
|
||||
const required = bare(requires)
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry !== "" && entry !== "—" && entry !== "-");
|
||||
return { id: bare(id), requires: required, missing: optional(missing) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the generated registry: the register, plus each pack resolved against it.
|
||||
*
|
||||
* A pack is `available` only when every required MIP is `ENFORCED`. `blockedBy`
|
||||
* names the MIPs that are not, so the client can say *why* a capability is dark
|
||||
* instead of just hiding it.
|
||||
*
|
||||
* @param {string} source Contents of `docs/mips/README.md`.
|
||||
*/
|
||||
export function buildRegistry(source) {
|
||||
const mips = parseRegister(source);
|
||||
const byId = new Map(mips.map((mip) => [mip.id, mip]));
|
||||
|
||||
const packs = parsePacks(source).map((pack) => {
|
||||
const unknown = pack.requires.filter((id) => !byId.has(id));
|
||||
const blockedBy = pack.requires.filter((id) => byId.get(id)?.state !== ADVERTISABLE_STATE);
|
||||
return {
|
||||
id: pack.id,
|
||||
requires: pack.requires,
|
||||
missing: pack.missing,
|
||||
unknown,
|
||||
// A pack with no requirements is not "all requirements met" — it is
|
||||
// unspecified, and must never render as available.
|
||||
available: pack.requires.length > 0 && blockedBy.length === 0 && pack.missing === null,
|
||||
blockedBy,
|
||||
};
|
||||
});
|
||||
|
||||
return { mips, packs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the registry for the conditions a reader cannot recover from.
|
||||
*
|
||||
* @returns {string[]} Human-readable errors; empty means valid.
|
||||
*/
|
||||
export function validateRegistry(registry) {
|
||||
const errors = [];
|
||||
const seen = new Set();
|
||||
for (const mip of registry.mips) {
|
||||
if (!/^MIP-[A-Z]{2}$/.test(mip.id)) {
|
||||
errors.push(`${mip.id}: id must be MIP- plus two uppercase letters`);
|
||||
}
|
||||
if (seen.has(mip.id)) errors.push(`${mip.id}: duplicate register row`);
|
||||
seen.add(mip.id);
|
||||
}
|
||||
for (const pack of registry.packs) {
|
||||
for (const id of pack.unknown) {
|
||||
// A pack may name a MIP nobody has written yet — that is how the register
|
||||
// states planned work. It may not do so *silently*: the Missing cell has
|
||||
// to name the id, or the gap is indistinguishable from a typo.
|
||||
if (pack.missing === null || !pack.missing.includes(id)) {
|
||||
errors.push(
|
||||
`pack ${pack.id}: requires ${id}, which has no register row and is not named in Missing`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pack.available && pack.missing !== null) {
|
||||
errors.push(`pack ${pack.id}: available yet declares missing work`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split NIP-11 `supported_extensions` into MIP ids and everything else.
|
||||
*
|
||||
* Mirrors `split_extensions` in `crates/meridian-control-plane/src/http.rs`.
|
||||
* The prefix is the contract: a repo-local proposal advertises as `mip-xx`.
|
||||
*/
|
||||
export function splitExtensions(values) {
|
||||
const mips = [];
|
||||
const extensions = [];
|
||||
for (const value of values ?? []) {
|
||||
if (String(value).toLowerCase().startsWith("mip-")) mips.push(String(value));
|
||||
else extensions.push(String(value));
|
||||
}
|
||||
return { mips, extensions };
|
||||
}
|
||||
139
scripts/mip-registry-core.test.mjs
Normal file
139
scripts/mip-registry-core.test.mjs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
|
||||
import {
|
||||
buildRegistry,
|
||||
parsePacks,
|
||||
parseRegister,
|
||||
splitExtensions,
|
||||
validateRegistry,
|
||||
} from "./mip-registry-core.mjs";
|
||||
|
||||
const DOC = `
|
||||
MIP registry
|
||||
============
|
||||
|
||||
## Register
|
||||
|
||||
| MIP | Title | Layer | State | Bead |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| \`MIP-OF\` | Opaque frame envelope | Wire | \`DRAFT\` | \`meridian-tdq\` |
|
||||
| \`MIP-XP\` | Negotiated exchange profiles | Link | \`ENFORCED\` | — |
|
||||
| [\`MIP-AS\`](MIP-AS.md) | AIS feed profile | Profile | \`DRAFT\` | \`meridian-hgjj\` |
|
||||
| [\`MIP-CT\`](MIP-CT.md) | Compact track code | Profile | \`ENFORCED\` | — |
|
||||
|
||||
A profile MIP adds no mechanism.
|
||||
|
||||
## Capability packs are not MIPs
|
||||
|
||||
| Pack | Requires | Missing |
|
||||
| --- | --- | --- |
|
||||
| \`AIS\` | \`MIP-OF\`, \`MIP-AS\` | — |
|
||||
| \`APP-6E\` | \`MIP-CT\` | — |
|
||||
| \`ADS-B\` | \`MIP-XP\`, \`MIP-AD\` | \`MIP-AD\` is unwritten |
|
||||
| \`STANAG-4609\` | — | no MIP specified yet |
|
||||
`;
|
||||
|
||||
describe("parseRegister", () => {
|
||||
test("reads every row and unwraps link and code syntax", () => {
|
||||
const rows = parseRegister(DOC);
|
||||
assert.equal(rows.length, 4);
|
||||
assert.deepEqual(rows[2], {
|
||||
id: "MIP-AS",
|
||||
title: "AIS feed profile",
|
||||
layer: "Profile",
|
||||
state: "DRAFT",
|
||||
bead: "meridian-hgjj",
|
||||
});
|
||||
});
|
||||
|
||||
test("an em-dash bead is null, not the dash", () => {
|
||||
assert.equal(parseRegister(DOC)[1].bead, null);
|
||||
});
|
||||
|
||||
test("an unknown lifecycle state throws rather than passing through", () => {
|
||||
const bad = DOC.replace("| Wire | `DRAFT` |", "| Wire | `SHIPPED` |");
|
||||
assert.throws(() => parseRegister(bad), /unknown lifecycle state/);
|
||||
});
|
||||
|
||||
test("stops at the end of the table and does not swallow later tables", () => {
|
||||
assert.equal(parseRegister(DOC).length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePacks", () => {
|
||||
test("splits the requires cell into ids", () => {
|
||||
const packs = parsePacks(DOC);
|
||||
assert.deepEqual(packs[0], { id: "AIS", requires: ["MIP-OF", "MIP-AS"], missing: null });
|
||||
});
|
||||
|
||||
test("an empty requires cell yields no requirements", () => {
|
||||
const stanag = parsePacks(DOC).find((pack) => pack.id === "STANAG-4609");
|
||||
assert.deepEqual(stanag.requires, []);
|
||||
assert.equal(stanag.missing, "no MIP specified yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRegistry", () => {
|
||||
test("a pack is available only when every required MIP is ENFORCED", () => {
|
||||
const { packs } = buildRegistry(DOC);
|
||||
const ais = packs.find((pack) => pack.id === "AIS");
|
||||
// MIP-OF and MIP-AS are both DRAFT, so AIS stays dark and says why.
|
||||
assert.equal(ais.available, false);
|
||||
assert.deepEqual(ais.blockedBy, ["MIP-OF", "MIP-AS"]);
|
||||
});
|
||||
|
||||
test("a pack whose single MIP is ENFORCED is available", () => {
|
||||
const appge = buildRegistry(DOC).packs.find((pack) => pack.id === "APP-6E");
|
||||
assert.equal(appge.available, true);
|
||||
assert.deepEqual(appge.blockedBy, []);
|
||||
});
|
||||
|
||||
test("a pack with no requirements is unspecified, never available", () => {
|
||||
const stanag = buildRegistry(DOC).packs.find((pack) => pack.id === "STANAG-4609");
|
||||
assert.equal(stanag.available, false);
|
||||
});
|
||||
|
||||
test("declared missing work keeps a pack dark even if its listed MIPs pass", () => {
|
||||
const adsb = buildRegistry(DOC).packs.find((pack) => pack.id === "ADS-B");
|
||||
// MIP-XP is ENFORCED, but MIP-AD has no register row at all.
|
||||
assert.equal(adsb.available, false);
|
||||
assert.deepEqual(adsb.unknown, ["MIP-AD"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRegistry", () => {
|
||||
test("an unwritten MIP named in the Missing cell is planned work, not an error", () => {
|
||||
// ADS-B requires MIP-AD, which has no row — but the row says so.
|
||||
assert.deepEqual(validateRegistry(buildRegistry(DOC)), []);
|
||||
});
|
||||
|
||||
test("an unwritten MIP the Missing cell does not name is reported", () => {
|
||||
const silent = DOC.replace("| `MIP-AD` is unwritten |", "| — |");
|
||||
const errors = validateRegistry(buildRegistry(silent));
|
||||
assert.ok(errors.some((error) => /ADS-B: requires MIP-AD/.test(error)));
|
||||
});
|
||||
|
||||
test("a malformed MIP id is reported", () => {
|
||||
const bad = DOC.replace("`MIP-OF`", "`MIP-OFX`");
|
||||
const errors = validateRegistry(buildRegistry(bad));
|
||||
assert.ok(errors.some((error) => /two uppercase letters/.test(error)));
|
||||
});
|
||||
|
||||
test("a well-formed register with no dangling packs is clean", () => {
|
||||
const clean = DOC.replace("| \`ADS-B\` | \`MIP-XP\`, \`MIP-AD\` |", "| \`ADS-B\` | \`MIP-XP\` |");
|
||||
assert.deepEqual(validateRegistry(buildRegistry(clean)), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitExtensions", () => {
|
||||
test("splits on the mip- prefix, case-insensitively", () => {
|
||||
const { mips, extensions } = splitExtensions(["nip-er", "mip-as", "MIP-CT"]);
|
||||
assert.deepEqual(mips, ["mip-as", "MIP-CT"]);
|
||||
assert.deepEqual(extensions, ["nip-er"]);
|
||||
});
|
||||
|
||||
test("a missing list is empty, not a throw", () => {
|
||||
assert.deepEqual(splitExtensions(undefined), { mips: [], extensions: [] });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue