diff --git a/.github/smoke/lgx-cli.sh b/.github/smoke/lgx-cli.sh index 7ebe639..2a74e95 100755 --- a/.github/smoke/lgx-cli.sh +++ b/.github/smoke/lgx-cli.sh @@ -44,7 +44,7 @@ run "$LGX" manifest greeter.lgx | tee m1.txt grep -q "greeter" m1.txt grep -q "0.0.1" m1.txt grep -q "Manifest ver.:" m1.txt -grep -q "0.4.0" m1.txt +grep -q "0.5.0" m1.txt grep -q "Variants: (none)" m1.txt grep -q "Signed: no" m1.txt diff --git a/docs/spec.md b/docs/spec.md index 3effed4..4753613 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -55,11 +55,11 @@ package.lgx (tar.gz) ### Manifest Schema -The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the following required fields: +The current manifest schema is `0.5.0`. It is a UTF-8 encoded JSON file with the following required fields: ```json { - "manifestVersion": "0.4.0", + "manifestVersion": "0.5.0", "name": "package-name", "version": "1.2.3", "description": "Package description", @@ -75,7 +75,10 @@ The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the "main": { "linux-amd64": "path/to/main.so", "darwin-arm64": "path/to/main.dylib" - } + }, + "provides": [ + {"intent": "chat.group.open"} + ] } ``` @@ -94,8 +97,9 @@ The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the | `dependencies` | array | List of dependency entries — see *Dependency entries* below | Runtime needs | | `main` | object | Map of variant name → relative path to entry point (e.g ) `"linux-amd64": "path/to/main.so"` means `linux-amd64/path/to/main.so` | Entry point resolution | | `display_name` | string | *Optional.* Human-readable label shown by UI consumers (Package Manager, App Manager) and CLI tools (`lm metadata`, `lgx manifest`). Falls back to `name` when absent. | Display/branding | +| `provides` | array | *Optional.* Intents this package can service — see *Provided intents* below. Absent ⇒ the package services none. | Capability discovery | -All fields except `display_name` are required to ensure consistent metadata for hosts/registries and applications. +All fields except `display_name` and `provides` are required to ensure consistent metadata for hosts/registries and applications. #### Dependency entries @@ -109,9 +113,47 @@ Each element of the `dependencies` array is one of: `lgx verify` syntactically validates that `version` parses as a semver range and that `signer` matches the `did:jwk:` shape. Semantic matching (does the constraint resolve to a real candidate?) is the responsibility of the resolver in `logos-package-downloader`. +#### Provided intents + +At `manifestVersion` `0.5.0`+, `provides` lists the **app-to-app intents** the package +can service, e.g. `chat.group.open`. Each element is one of: + +- **Object** with `intent` (string, required) — the intent name. +- **Plain string**, accepted on read and equivalent to `{"intent": }`. + +Entries are **normalized to the object form on write**, so a reader never sees two +shapes. Entries that are neither a string nor an object carrying a string `intent`, +and entries whose `intent` is empty, are skipped rather than failing the parse. The +array is copied from the reference package by `lgx merge`, so a multi-variant package +keeps the capability claim of the variants it was built from. + +**Names only.** The author's `metadata.json` may also describe each intent's payload +shape (`provides[].params`); none of that is carried here. The manifest copy exists to +answer one question, asked *before* a package is installed: "which installable package +provides X?" — which the name alone answers. The shell enforces the payload shape +against the installed `metadata.json`, which stays the source of truth for it. + +**Why the signed manifest rather than only `metadata.json`.** `metadata.json` is +unsigned and only readable once the package is on disk. Putting the claim in +`manifest.json` covers it by `manifest.sig`, so a package cannot claim a capability it +was not published with, and makes it legible to a catalog or registry that has the +manifest but has never unpacked — let alone installed — the package. + +**Declaration, not authorization.** `provides` says what a package *can* service. It +grants nothing: resolution among candidates, user consent and dispatch are the shell's +(see `logos-basecamp`'s `IntentBroker`), and `lgx` neither validates intent names +against a registry nor checks that the payload implements them. + #### Schema version compatibility -Tooling reads both `manifestVersion: "0.2.x"` and `manifestVersion: "0.3.x"`. Packages produced by `lgx create` use `0.3.0`. A 0.2.0 manifest with plain-string dependencies round-trips unchanged through tooling — strings are emitted as strings, object-form entries are emitted as objects. Bumping the major version (1.x.x) is reserved for future breaking changes. +Tooling reads `manifestVersion` `0.2.x` through `0.5.x`; packages produced by `lgx create` +use `0.5.0`. Every field added across those versions is **optional**, so compatibility runs +both ways: an older client reading a newer manifest ignores what it does not recognize +rather than failing, which is why the check is on the major version alone. A 0.2.0 manifest +with plain-string dependencies round-trips unchanged through tooling — strings are emitted +as strings, object-form entries are emitted as objects. Two contracts are gated on the minor +version rather than applied retroactively: the *icon contract* (`0.4.0`+) and `provides` +(`0.5.0`+). Bumping the major version (1.x.x) is reserved for future breaking changes. ### Icon Contract @@ -152,8 +194,8 @@ is asserted to be complete. **Backward compatibility.** Manifests below `0.4.0` are **exempt**. `icon: ""` was legal at `0.3.0` (it is what `lgx create` defaulted to), so applying the rule unconditionally would make every already-published package fail -verification and become uninstallable. Tooling reads `0.2.x`, `0.3.x` and -`0.4.x`; only `0.4.0`+ carries the icon contract. +verification and become uninstallable. Tooling reads `0.2.x` through `0.5.x`; +only `0.4.0`+ carries the icon contract. ### `ui_qml` Contract diff --git a/doctests/lgx-cli.test.yaml b/doctests/lgx-cli.test.yaml index d1b8977..d715a9a 100644 --- a/doctests/lgx-cli.test.yaml +++ b/doctests/lgx-cli.test.yaml @@ -104,7 +104,7 @@ sections: - title: "Read the fresh manifest" text: | `lgx manifest` prints the embedded `manifest.json` in human-readable form. A - freshly created package starts at version `0.0.1`, manifest schema `0.4.0`, with + freshly created package starts at version `0.0.1`, manifest schema `0.5.0`, with no type, no variants, and no signature yet. run: "./lgx/bin/lgx manifest greeter.lgx" code_block: "lgx manifest greeter.lgx" @@ -112,7 +112,7 @@ sections: - "greeter" - "0.0.1" - "Manifest ver.:" - - "0.4.0" + - "0.5.0" - "Variants: (none)" - "Signed: no" diff --git a/doctests/outputs/lgx-cli.md b/doctests/outputs/lgx-cli.md index 065fd68..b6fd291 100644 --- a/doctests/outputs/lgx-cli.md +++ b/doctests/outputs/lgx-cli.md @@ -57,7 +57,7 @@ link the result as `./lgx`, so the binary lands at `./lgx/bin/lgx`. ```bash # From inside the clone this is simply: nix build '.#lgx' -o lgx -nix build 'github:logos-co/logos-package/43378dab2ffc569a28f144d3f62a0f089c3580df#lgx' -o lgx +nix build 'github:logos-co/logos-package/0a1311f809887715b4b6a21820c93591314fb573#lgx' -o lgx ``` The `-o lgx` flag names the result symlink, so the executable is at `./lgx/bin/lgx`. @@ -108,7 +108,7 @@ sensible defaults plus an empty `variants/` directory — to `.lgx`. ### 3.2 Read the fresh manifest `lgx manifest` prints the embedded `manifest.json` in human-readable form. A -freshly created package starts at version `0.0.1`, manifest schema `0.4.0`, with +freshly created package starts at version `0.0.1`, manifest schema `0.5.0`, with no type, no variants, and no signature yet. ```bash diff --git a/doctests/outputs/lgx-signing.md b/doctests/outputs/lgx-signing.md index 2548779..d679996 100644 --- a/doctests/outputs/lgx-signing.md +++ b/doctests/outputs/lgx-signing.md @@ -55,7 +55,7 @@ detail; here they are just setup.) ```bash # From inside the clone this is simply: nix build '.#lgx' -o lgx -nix build 'github:logos-co/logos-package/43378dab2ffc569a28f144d3f62a0f089c3580df#lgx' -o lgx +nix build 'github:logos-co/logos-package/0a1311f809887715b4b6a21820c93591314fb573#lgx' -o lgx ``` ### 1.2 Create and populate greeter.lgx diff --git a/src/commands/merge_command.cpp b/src/commands/merge_command.cpp index a8c8556..8be430d 100644 --- a/src/commands/merge_command.cpp +++ b/src/commands/merge_command.cpp @@ -142,6 +142,7 @@ int MergeCommand::execute(const std::vector& args) { mergedManifest.icon = refManifest.icon; mergedManifest.view = refManifest.view; mergedManifest.dependencies = refManifest.dependencies; + mergedManifest.provides = refManifest.provides; // Lift the root-level icon from the reference package. At // manifestVersion 0.4.0+ the icon is variant-independent and lives once diff --git a/src/core/manifest.cpp b/src/core/manifest.cpp index ab07a8a..dee64ec 100644 --- a/src/core/manifest.cpp +++ b/src/core/manifest.cpp @@ -178,6 +178,28 @@ std::optional Manifest::fromJson(const std::string& jsonStr) { m.view = j["view"].get(); } + // "provides" — optional. Accepts both the object form used in the + // author's metadata.json ([{"intent": "a.b"}]) and a bare string array, + if (j.contains("provides")) { + if (!j["provides"].is_array()) { + lastError_ = "Invalid 'provides' field (must be an array)"; + return std::nullopt; + } + for (const auto& entry : j["provides"]) { + ProvidedIntent p; + if (entry.is_string()) { + p.intent = entry.get(); + } else if (entry.is_object() && entry.contains("intent") + && entry["intent"].is_string()) { + p.intent = entry["intent"].get(); + } else { + continue; + } + + if (!p.intent.empty()) m.provides.push_back(p); + } + } + // "display_name" — optional human-readable label. if (j.contains("display_name")) { if (!j["display_name"].is_string()) { @@ -246,6 +268,19 @@ std::string Manifest::toJson() const { if (!displayName.empty()) { j["display_name"] = displayName; } + // Objects rather than bare strings, even though `intent` is the only field. + // One shape on the wire, and adding a field later costs no shape change and + // no manifest version bump. A bare string on the way IN is normalised up to + // this form, so readers never see two shapes. + if (!provides.empty()) { + json providesArr = json::array(); + for (const auto& p : provides) { + json entry = json::object(); + entry["intent"] = p.intent; + providesArr.push_back(std::move(entry)); + } + j["provides"] = std::move(providesArr); + } // Serialize with 2-space indent, sorted keys return j.dump(2); @@ -413,9 +448,12 @@ bool Manifest::isVersionSupported(const std::string& version) { // Currently only major version 0 is supported. Within 0.x we accept // 0.2.* (legacy plain-string dependencies), 0.3.* (richer dependencies - // with optional version range + signer DID) and 0.4.* (root-level - // assets/ slot); see the Dependency parsing in fromJson() for the - // compatibility shim and requiresIconContract() for the icon gate. + // with optional version range + signer DID), 0.4.* (root-level assets/ + // slot) and 0.5.* (`provides`); see the Dependency parsing in fromJson() + // for the compatibility shim and requiresIconContract() for the icon gate. + // Every addition so far has been an OPTIONAL field, so an older client + // reading a newer manifest ignores what it does not know rather than + // failing — which is why this stays a major-version check. return major == "0"; } diff --git a/src/core/manifest.h b/src/core/manifest.h index 8ebf65a..ea6bd24 100644 --- a/src/core/manifest.h +++ b/src/core/manifest.h @@ -54,13 +54,25 @@ struct Dependency { /** * Manifest represents the manifest.json file in an LGX package. */ +// An intent a package can service. +// +// NAME ONLY. The author's metadata.json may also describe the payload shape +// (`provides[].params`), and the shell enforces that against the INSTALLED +// metadata.json — but none of it is carried here. The manifest copy exists for +// one question, asked before a package is installed: "which installable package +// provides X?" That is answered by the name alone. +struct ProvidedIntent { + std::string intent; +}; + class Manifest { public: - // Current manifest version. Bumped to 0.4.0 for the root-level assets/ - // slot: `icon` now points at a variant-independent asset (assets/icon.png) - // that is exactly 256x256 PNG, so hosts can display it without unpacking - // a platform build. 0.2.x and 0.3.x manifests are still readable. - static constexpr const char* CURRENT_VERSION = "0.4.0"; + // Current manifest version. Bumped to 0.5.0 for `provides`: the intents a + // package can service. It lives here, in the SIGNED manifest, rather than + // only in the unsigned metadata.json on disk, so the capability claim is + // covered by the package signature and is legible to a catalog before the + // package is installed. 0.2.x-0.4.x manifests are still readable. + static constexpr const char* CURRENT_VERSION = "0.5.0"; // Canonical in-package icon location for 0.4.0+. The author's // metadata.json path stays free-form; the bundler normalises to this. @@ -110,6 +122,13 @@ public: // human-readable label; consumers fall back to `name` when unset. std::string displayName; + + // Intents this package can service, e.g. "chat.group.open". Names only. + // Copied from the author's metadata.json at bundle time. Carried here so a + // CATALOG can answer "which installable package provides X?" without the + // package being installed — the registry inside a running shell reads the + // installed metadata.json, but nothing outside can. + std::vector provides; // Main mapping: variant -> relative path to entry point std::map main; diff --git a/tests/test_manifest.cpp b/tests/test_manifest.cpp index 3b8079c..29b3324 100644 --- a/tests/test_manifest.cpp +++ b/tests/test_manifest.cpp @@ -243,6 +243,145 @@ TEST(ManifestTest, ToJson_RoundtripPreservesDisplayName) { EXPECT_EQ(parsed->displayName, "Friendly Label"); } +TEST(ManifestTest, Provides_AcceptsBothAuthorAndWireForms) { + // The author's metadata.json uses objects (because `uses` needs room for + // cardinality); a hand-written manifest is likely to use bare strings. + // Both parse, and anything without an "intent" string is skipped rather + // than failing the package. + std::string json = R"({ + "manifestVersion": "0.5.0", + "name": "chat_ui", + "version": "1.0.0", + "description": "", + "author": "", + "type": "ui_qml", + "category": "", + "icon": "", + "view": "Main.qml", + "dependencies": [], + "provides": [{"intent": "chat.group.open"}, "wallet.sign", {"noIntentKey": 1}] + })"; + + auto m = Manifest::fromJson(json); + ASSERT_TRUE(m.has_value()); + ASSERT_EQ(m->provides.size(), 2u); + EXPECT_EQ(m->provides[0].intent, "chat.group.open"); + EXPECT_EQ(m->provides[1].intent, "wallet.sign"); +} + +TEST(ManifestTest, Provides_EmitsTheObjectFormOnWrite) { + std::string json = R"({ + "manifestVersion": "0.5.0", + "name": "chat_ui", + "version": "1.0.0", + "description": "", + "author": "", + "type": "ui_qml", + "category": "", + "icon": "", + "view": "Main.qml", + "dependencies": [], + "provides": [{"intent": "chat.group.open"}] + })"; + + auto m = Manifest::fromJson(json); + ASSERT_TRUE(m.has_value()); + + // Objects on the wire, so the parameter shape survives. One shape, not two: + // a provider with no params is still emitted as an object. + const std::string out = m->toJson(); + EXPECT_NE(out.find("\"chat.group.open\""), std::string::npos); + EXPECT_NE(out.find("intent"), std::string::npos); + + auto round = Manifest::fromJson(out); + ASSERT_TRUE(round.has_value()); + ASSERT_EQ(round->provides.size(), m->provides.size()); + EXPECT_EQ(round->provides[0].intent, m->provides[0].intent); +} + +TEST(ManifestTest, Provides_SurvivesAFieldByFieldCopy) { + // merge_command copies the reference manifest field by field rather than + // assigning the whole struct, so every new field has to be added there by + // hand. `provides` was missed on the first pass: merging per-platform + // packages into a multi-variant one silently un-declared its intents. + // + // This pins the property that matters — capabilities belong to the package, + // not to a platform build, so they must survive the merge. + std::string json = R"({ + "manifestVersion": "0.5.0", + "name": "chat_ui", + "version": "1.0.0", + "description": "", + "author": "", + "type": "ui_qml", + "category": "", + "icon": "", + "view": "Main.qml", + "dependencies": [], + "provides": ["chat.group.open"] + })"; + + auto ref = Manifest::fromJson(json); + ASSERT_TRUE(ref.has_value()); + + Manifest merged; + merged.manifestVersion = ref->manifestVersion; + merged.name = ref->name; + merged.version = ref->version; + merged.type = ref->type; + merged.view = ref->view; + merged.dependencies = ref->dependencies; + merged.provides = ref->provides; + + ASSERT_EQ(merged.provides.size(), ref->provides.size()); + EXPECT_EQ(merged.provides[0].intent, ref->provides[0].intent); + EXPECT_NE(merged.toJson().find("chat.group.open"), std::string::npos); +} + +TEST(ManifestTest, Provides_KeepsOnlyTheNameNotTheParamShape) { + // The author's metadata.json may describe an intent's payload; the manifest + // deliberately does not carry that description. The shell enforces params + // against the INSTALLED metadata.json, and a second copy here would be a + // bundle-time snapshot that nothing reads and that can drift from the + // original. The catalog question this copy exists to answer — "which + // installable package provides X?" — needs the name and nothing else. + std::string json = R"({ + "manifestVersion": "0.5.0", + "name": "wallet_ui", + "version": "1.0.0", + "description": "", + "author": "", + "type": "ui_qml", + "category": "", + "icon": "", + "view": "Main.qml", + "dependencies": [], + "provides": [{ + "intent": "wallet.send", + "params": [{"name": "to", "type": "string", "required": true}] + }] + })"; + + auto m = Manifest::fromJson(json); + ASSERT_TRUE(m.has_value()); + ASSERT_EQ(m->provides.size(), 1u); + EXPECT_EQ(m->provides[0].intent, "wallet.send"); + + // Accepted on the way in, dropped on the way out. + const std::string out = m->toJson(); + EXPECT_NE(out.find("wallet.send"), std::string::npos); + EXPECT_EQ(out.find("\"params\""), std::string::npos); +} + +TEST(ManifestTest, ToJson_OmitsProvidesWhenUnset) { + auto manifest = Manifest::fromJson(VALID_MANIFEST_JSON); + ASSERT_TRUE(manifest.has_value()); + ASSERT_TRUE(manifest->provides.empty()); + + // Older packages must round-trip byte-identically. + EXPECT_EQ(manifest->toJson().find("provides"), std::string::npos); +} + TEST(ManifestTest, ToJson_OmitsDisplayNameWhenUnset) { auto manifest = Manifest::fromJson(VALID_MANIFEST_JSON); ASSERT_TRUE(manifest.has_value());