Compare commits

...
19 changed files with 1391 additions and 232 deletions

No files matched your search

+74 -46
View File
@@ -1,56 +1,84 @@
# Official registry client
# Official static distribution client
The Community page refreshes the fixed endpoint
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
in the main process. The renderer cannot choose a registry URL. This endpoint
must be published separately; client implementation and synthetic tests do not
prove the repository or an online artifact is available.
Community starts at the fixed main-process endpoint
`https://plugins.netcatty.app/v1/snapshot.json`. The renderer cannot choose an
endpoint. All automatic catalog, README and potential package reads allow only
that exact HTTPS origin: no credentials, query, fragment, noncanonical URL or
redirect. No GitHub API/raw/avatar/release fallback exists. Explicit user-opened
source links may still open GitHub in an external browser.
## Catalog contract
## Snapshot contract
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
including manifest defaults, uses the existing 128-level / 100,000-value budget
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
and license checks apply. Source-only entries remain build-and-import examples.
No download counts, ratings, or timestamps are invented. Brand previews remain
bundled and reviewed; arbitrary author image URLs are never loaded.
This client implements the version-1 contract from
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
when the last refresh succeeded; it is disabled while a request is pending.
The last good catalog is retained **in memory for this application session**;
a fresh offline launch uses bundled examples. There is no new disk cache,
database migration, cloud sync record, or credential requirement.
The pointer contains `schemaVersion: 1`, `registryCommit`, `snapshotId`, and
`catalog`, `index`, `delivery` references, each `{path, sha256, size}`. Paths must
be `/snapshots/<snapshotId>/<name>.json`; `snapshotId` equals the raw delivery
SHA-256. The host checks the pointer before requesting its three documents,
then checks raw byte counts/hashes, JSON budgets, schemas and cross-document
identity. The three documents must all validate before replacing any cache.
## Selection and installation
Delivery contains `schemaVersion`, `registryCommit`, `catalogSha256`,
`indexSha256`, `sourceLockSha256`, `builderSha256` and `plugins`. Each item matches
one exact catalog `id/version/repository/commit` tuple and supplies `readme`,
`iconLight`, `iconDark`, `avatar`, `artifact`. Asset references are
`{path, sha256, size}`, with `/assets/<sha256>.md` for README and `.png`/`.webp`
for brand icons. This v1 publishing schema requires **avatar and artifact to
be null**. The client rejects unsupported delivery metadata rather than
inventing installable packages or deriving a GitHub avatar URL.
Each returned entry has a host-generated `selectionToken` covering its complete
JSON contents. This is a concurrency check, **not a signature or trust proof**.
README requests must still match that selection and fetch its pinned source
commit. A changed entry requires refresh and renewed review.
Index is validated with its pinned schema. Plugin/version identities must be
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
source-only entry. Withdrawal does not disable, uninstall or delete any existing
plugin: this is catalog admission, not a remote installed-code kill switch.
A manual install must first refresh the official catalog successfully and match
the selected token again. Failed refresh is browse-only, even if the cached
entry has an artifact. Withdrawal, version changes, permission changes, README
changes, or artifact hash changes cannot silently replace the selected package.
Archive download then uses the existing approved-host, size, exact SHA-256,
manifest and package validation pipeline. Installation leaves the plugin disabled;
existing authorization gates still apply on activation/use.
Schemas are vendored under `electron/plugins/distribution/`, with provenance and
fixtures described there. All schema references resolve locally; schema validation
does not fetch GitHub or netcatty.com.
`index.json` release history and withdrawal records are not consumed by this
first client. Removing an entry does **not** disable, uninstall, update, or delete
data from an already installed plugin. There are no background code updates.
## Bounds, cache and selection
## Verification boundary
Pointer reads are bounded to 16 KiB, each JSON document to 2 MiB, and each README
to 256 KiB. Reads time out after 30 seconds. Whole JSON values retain the existing
128-level / 100,000-value / finite-number boundary. Catalogs contain at most 1,000
entries. Existing manifest identity, license and browser-only policies remain.
Service regressions use synthetic network responses and package bytes; the
existing package manager tests cover archive admission. No third-party plugin,
real credential, real sync account, or live registry write is used. Native QA
must separately verify the visible offline/retry state and eventual real catalog
availability once the repository is published.
The host prepares private entries, private delivery resources and all public
selection tokens before one synchronous cache publication. Parse, integrity,
schema, clone, tuple or network failures preserve the last complete good snapshot.
The cache remains **in memory for this app session**; a fresh offline launch lists
bundled examples. No new disk database, sync record or credential is introduced.
A host-generated selection token binds the complete entry and its delivery item.
This prevents silent selection changes; it is **not a signature**. README requests
capture the matched static reference and verify its exact byte count and digest.
Missing/offline snapshots show an error/Retry; they never synthesize a raw GitHub URL.
README HTML and remote images remain inert in the existing safe renderer.
The current v1 distribution cannot install packages. A later publishing/client
schema revision must jointly introduce reviewed package mirroring, keep source
manifest/hash admission, and recheck the current pointer and fresh root withdrawal
ledger before any install. A historical snapshot alone cannot establish availability.
The existing lower-level package downloader accepts only official static URLs and
exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
## Network inventory
| Path | Automatic source | Failure behavior |
| --- | --- | --- |
| Pointer / manual refresh | Fixed `/v1/snapshot.json` | Complete session cache, or bundled source-only entries |
| Catalog/index/delivery | Pointer-bound same-origin immutable paths | Reject partial or mixed snapshots |
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
The real PR3 bootstrap is retained as a test fixture with its original bytes and
hashes. Tests also verify its brand-icon asset bytes; this does not claim a new
remote icon renderer. The static publisher is not a proxy. Client tests do not
prove deployment, mainland reachability or successful native CDN access. Native
QA must independently verify the published endpoint and visible failure state.
+45 -49
View File
@@ -12,23 +12,26 @@ const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
const DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
function assertDownloadUrl(value) {
if (typeof value !== "string" || /[\u0000-\u0020\u007f\\%?#]/u.test(value)) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
const url = new URL(value);
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
if (url.origin !== DISTRIBUTION_ORIGIN || url.username || url.password || url.port
|| url.href !== value || url.pathname.split("/").some((part) => part === "." || part === "..")) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
return url.href;
}
function pinnedReadmeUrl(entry) {
function assertReadmeSource(entry) {
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Invalid or unpinned README path");
}
const prefix = `/${entry.repository}/${entry.commit}/`;
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
return url.href;
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
throw new Error("README source escapes the pinned commit");
}
}
function validateCatalog(entries) {
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
@@ -43,51 +46,49 @@ function validateCatalog(entries) {
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
throw new Error("Invalid or unpinned plugin catalog entry");
}
pinnedReadmeUrl(entry);
assertReadmeSource(entry);
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
assertCatalogManifest(entry.manifest, entry);
ids.add(entry.id);
if (Object.hasOwn(entry, "artifact")) {
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
throw new Error("Invalid catalog artifact metadata");
for (const field of ["artifact"]) {
if (!Object.hasOwn(entry, field)) continue;
const asset = entry[field];
if (!asset || typeof asset !== "object" || Array.isArray(asset)
|| Object.keys(asset).some((key) => key !== "url" && key !== "sha256")
|| typeof asset.url !== "string" || typeof asset.sha256 !== "string") {
throw new Error(`Invalid catalog ${field} metadata`);
}
assertDownloadUrl(entry.artifact.url);
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
assertDownloadUrl(asset.url);
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
}
}
return entries;
}
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
async function fetchBounded(url, limit, fetchImpl = fetch) {
const signal = AbortSignal.timeout(30_000);
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
url = new URL(response.headers.get("location"), url).href;
continue;
}
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
throw new Error("Catalog redirects are not allowed");
}
throw new Error("Too many catalog redirects");
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
}
class PluginCatalogService {
constructor(options) {
@@ -103,11 +104,6 @@ class PluginCatalogService {
if (!entry) throw new Error("Plugin is not in the curated catalog");
return entry;
}
async readme(id) {
const entry = this.get(id);
const url = pinnedReadmeUrl(entry);
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
}
async install(id) {
const entry = this.get(id);
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
@@ -127,4 +123,4 @@ class PluginCatalogService {
}
}
}
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
+4 -12
View File
@@ -24,25 +24,17 @@ test("curated catalog uses pinned public first-party source and rejects advanced
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
});
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
const requested = [];
const service = new PluginCatalogService({ entries, fetch: async (url) => {
requested.push(url); return new Response("# First-party example");
} });
assert.equal(await service.readme(entries[0].id), "# First-party example");
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
await assert.rejects(service.readme(entries[0].id), /redirects/);
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
test("static downloader is bounded and never follows redirects", async () => {
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 3, async () => new Response("1234")), /size limit/);
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 10, async () => new Response(null, { status: 302, headers: { location: "https://github.com/a" } })), /redirects/);
});
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
let installs = 0;
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
assert.equal(name, "plugin-catalog-");
+16
View File
@@ -0,0 +1,16 @@
# Pinned static distribution schemas
`delivery.schema.json` and `index.schema.json` are unmodified UTF-8 files from
binaricat/netcatty-plugins commit 7bfe23e5627e16ba63806de6dded5b2667c02984
(PR 3). The host compiles them with the existing local plugin-contract schema;
no schema reference causes network access.
`../fixtures/static-distribution/` contains that commit's actual
`distribution/bootstrap/v1/snapshot.json`, snapshot catalog/index/delivery and
four referenced README/PNG assets. Raw-byte SHA-256 checks in
`staticDistribution.test.cjs` verify the publisher's supplied pointer and delivery
hashes; every document and asset is checked against the pointer/sidecar.
The fixture includes no executable plugin package, avatar or credentials.
Schema revisions must be coordinated with the registry publisher. In particular,
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
@@ -0,0 +1,166 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Netcatty static delivery v1 (source-only bootstrap)",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"registryCommit",
"plugins",
"catalogSha256",
"indexSha256",
"sourceLockSha256",
"builderSha256"
],
"properties": {
"schemaVersion": {
"const": 1
},
"registryCommit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"repository",
"commit",
"readme",
"iconLight",
"iconDark",
"avatar",
"artifact"
],
"properties": {
"id": {
"type": "string"
},
"version": {
"type": "string"
},
"repository": {
"type": "string"
},
"commit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"readme": {
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
},
"iconLight": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"iconDark": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"avatar": {
"type": "null"
},
"artifact": {
"type": "null"
}
}
}
},
"catalogSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"indexSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceLockSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"builderSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
@@ -0,0 +1,194 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"plugins"
],
"properties": {
"schemaVersion": {
"const": 1
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"current",
"versions"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"current": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
{
"type": "null"
}
]
},
"versions": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"entry",
"distribution",
"withdrawal"
],
"properties": {
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"name",
"description",
"author",
"repository",
"commit",
"license",
"readme",
"manifest"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"author": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"repository": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
},
"commit": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-f0-9]{40}$"
},
"license": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"readme": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"manifest": {
"$ref": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json"
},
"artifact": {
"type": "object",
"additionalProperties": false,
"required": [
"url",
"sha256"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
}
},
"distribution": {
"enum": [
"source-only",
"release"
]
},
"tag": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"withdrawal": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"at",
"reason",
"advisory"
],
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"advisory": {
"type": "string",
"pattern": "^https://github.com/"
}
}
}
]
}
}
}
}
}
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
"use strict";
const { createHash } = require("node:crypto");
const { isDeepStrictEqual } = require("node:util");
const Ajv2020 = require("ajv/dist/2020").default;
const addFormats = require("ajv-formats").default;
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const { fetchBounded, validateCatalog, DISTRIBUTION_ORIGIN, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { formatValidationErrors } = require("./contractValidator.cjs");
const REGISTRY_URL = `${DISTRIBUTION_ORIGIN}/v1/snapshot.json`;
const SHA256 = /^[a-f0-9]{64}$/u;
const COMMIT = /^[a-f0-9]{40}$/u;
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: true });
addFormats(ajv);
ajv.addSchema(require("./generated/plugin-contract.schema.json"));
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
const validateDelivery = ajv.compile(require("./distribution/delivery.schema.json"));
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
function exactKeys(value, keys) {
return value && typeof value === "object" && !Array.isArray(value)
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function assertReference(ref, path, limit) {
if (!exactKeys(ref, ["path", "sha256", "size"]) || typeof ref.sha256 !== "string" || !SHA256.test(ref.sha256)
|| ref.path !== path || !Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > limit) {
throw new Error("Invalid static snapshot resource reference");
}
}
function parseJson(bytes, label, limit = MAX_CATALOG_BYTES) {
const value = JSON.parse(bytes.toString("utf8"));
assertPluginJsonValue(value, { maxBytes: limit });
if (!value || typeof value !== "object") throw new Error(`Invalid ${label}`);
return value;
}
function assertPointer(pointer) {
if (!exactKeys(pointer, ["schemaVersion", "registryCommit", "snapshotId", "catalog", "index", "delivery"])
|| pointer.schemaVersion !== 1 || typeof pointer.registryCommit !== "string" || !COMMIT.test(pointer.registryCommit)
|| typeof pointer.snapshotId !== "string" || !SHA256.test(pointer.snapshotId)) {
throw new Error("Invalid static snapshot pointer");
}
for (const name of ["catalog", "index", "delivery"]) {
assertReference(pointer[name], `/snapshots/${pointer.snapshotId}/${name}.json`, MAX_CATALOG_BYTES);
}
if (pointer.snapshotId !== pointer.delivery.sha256) throw new Error("Snapshot identity does not match delivery hash");
}
function assertAsset(ref, extensions) {
if (!ref || typeof ref.path !== "string" || !extensions.some((extension) => ref.path === `/assets/${ref.sha256}.${extension}`)) {
throw new Error("Invalid static asset path or format");
}
assertReference(ref, ref.path, 256 * 1024);
}
async function fetchVerified(ref, fetchImpl) {
const bytes = await fetchBounded(`${DISTRIBUTION_ORIGIN}${ref.path}`, ref.size, fetchImpl);
if (bytes.byteLength !== ref.size || digest(bytes) !== ref.sha256) throw new Error("Static resource size or SHA-256 mismatch");
return bytes;
}
function validateSet(pointer, entries, index, delivery) {
validateCatalog(entries);
if (!validateIndex(index)) throw new Error(`Invalid distribution index: ${formatValidationErrors(validateIndex.errors)}`);
if (!validateDelivery(delivery)) throw new Error(`Invalid static delivery: ${formatValidationErrors(validateDelivery.errors)}`);
if (delivery.registryCommit !== pointer.registryCommit || delivery.catalogSha256 !== pointer.catalog.sha256
|| delivery.indexSha256 !== pointer.index.sha256) throw new Error("Static snapshot documents do not match");
// v1 publishing deliberately refuses packages and avatars. Do not extend this
// consumer ahead of the reviewed publishing schema or invent installable code.
if (entries.some((entry) => entry.artifact)) throw new Error("Static delivery v1 is source-only");
const current = new Map();
const indexIds = new Set();
for (const plugin of index.plugins) {
if (indexIds.has(plugin.id)) throw new Error("Duplicate distribution index plugin");
indexIds.add(plugin.id);
const versions = new Map();
for (const version of plugin.versions) {
if (version.entry.id !== plugin.id || versions.has(version.entry.version)) throw new Error("Invalid distribution version identity");
versions.set(version.entry.version, version);
}
if (plugin.current !== null) {
const version = versions.get(plugin.current);
if (!version || version.withdrawal !== null || version.distribution !== "source-only") throw new Error("Current distribution version is missing or withdrawn");
current.set(plugin.id, version.entry);
}
}
if (current.size !== entries.length || entries.some((entry) => !isDeepStrictEqual(current.get(entry.id), entry))) {
throw new Error("Catalog does not match the current non-withdrawn index");
}
const resources = new Map();
const catalogTuples = new Set(entries.map(tuple));
for (const plugin of delivery.plugins) {
const key = tuple(plugin);
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
assertAsset(plugin.readme, ["md"]);
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
resources.set(key, plugin);
}
if (resources.size !== entries.length) throw new Error("Missing delivery entry for catalog plugin");
return resources;
}
async function fetchDistribution(fetchImpl) {
const pointer = parseJson(await fetchBounded(REGISTRY_URL, 16 * 1024, fetchImpl), "snapshot pointer", 16 * 1024);
assertPointer(pointer);
const documents = await Promise.all(["catalog", "index", "delivery"].map(async (name) => parseJson(await fetchVerified(pointer[name], fetchImpl), name)));
const [entries, index, delivery] = documents;
const resources = validateSet(pointer, entries, index, delivery);
return { entries, resources, pointer };
}
module.exports = { REGISTRY_URL, fetchDistribution, fetchVerified, tuple, validateSet, assertPointer, assertAsset };
@@ -0,0 +1,53 @@
# Hello Netcatty
This package is the runnable internal example for the plugin API. Package and install it to exercise native settings,
command-palette contributions, lazy command and Provider activation,
localization, terminal completion/decoration Providers, and the runtime SDK.
From the repository root:
```bash
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
```
After installation, change **Greeting** under **Settings → Plugins**, then run
**Examples: Say Hello** from the command palette. The setting is read through
the host settings broker and the command handler is registered inside the
sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
completion Provider, and print `Hello from Netcatty` to exercise the declarative
decoration Provider. Neither Provider receives raw xterm objects or sensitive
terminal input/output streams.
## Import and inspect
Launch the normal browser-only plugin build using a fresh Electron
user-data directory for demonstrations. In **Settings > Plugins**, choose
**Import .ncpkg**, select the generated archive, and open its details. Review the
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
permission prompts still mediate activation and sensitive operations.
Importing an update is a manual action and leaves it disabled, including when
the previous version was enabled. Review the new package before enabling it.
Changing unsigned package contents changes its security principal; previous
persistent grants are not transferred to different bytes.
An incompatible application/API upgrade disables installed plugins before
publishing their contributions. The reason remains in the installed list, and
package files and local data remain available. Returning to a compatible host
requires explicit re-enable. Uninstall is different: package code and local KV
are deleted; user-owned settings, secret storage and sync sidecars are retained.
For a synthetic demonstration, use only this first-party example, change the
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
inspect the package again. Do not connect real hosts or a cloud account. The
existing greeting setting is local; declaring `sync: true` on a non-secret
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
storage does not sync.
For a demonstration without terminal permissions, use `examples/plugins/starter-netcatty`.
Enabling this lazy example does not grant permissions: required permissions are
requested before its first command/provider activation, and optional permissions
when used. Never treat Enabled as a security certification or grant.
@@ -0,0 +1,37 @@
# Plugin Starter
A minimal first-party browser plugin for learning import, settings and lifecycle.
It requests `commands`, `settings.read`, `menus` and `views`: no terminal text, host credentials,
network, files, secrets or advanced Node access. It works without OS key storage.
Permission declarations are not grants; follow the app's normal mediation.
From the repository root:
```sh
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/starter-netcatty
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
```
Start the actual app normally with a fresh test profile; no development flag is
needed. Open **Settings > Plugins > Open plugin manager > Installed**, import the package, inspect its
permissions and enable it. Change **Greeting**, then find **Starter: Greeting Panel** (入门:问候面板)
in the command palette. Review the normal permission prompts, then click
**Show greeting** to see the exact preference in the sandboxed panel.
**Starter: Read Greeting** is also listed and refreshes an already-open panel.
The panel inserts text using `textContent`, never HTML. It does not write to
a terminal or connect to a host. Denied permissions remain denied.
Disable, restart and re-enable to verify the preference persists. Re-importing
the same package leaves it disabled for review. Uninstall removes code and local
KV; user-owned settings remain. The greeting is local only, not cloud-synced.
The separate Hello Netcatty example demonstrates terminal providers and requests
terminal permissions. Use this Starter for the default no-sensitive-access demo.
Version 0.1.2 adds light/dark PNG brand icons in `assets/`. Displaying these
images does not activate the plugin or request permissions. This example now
requires API `>=0.1.0-internal.1 <0.2.0` and `metadata.brand-icon`.
Older Starter 0.1.1 packages remain compatible with the new host.
See [brand icon author instructions](../../../docs/plugin-platform/plugin-icons.md).
@@ -0,0 +1,195 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
{
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
}
]
@@ -0,0 +1,48 @@
{
"builderSha256": "57199cc6a5d30f5f356adb072d51de9e2283dac6e26c647a4404e4c2261c4a70",
"catalogSha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"indexSha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"plugins": [
{
"artifact": null,
"avatar": null,
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"iconDark": {
"path": "/assets/7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe.png",
"sha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe",
"size": 873
},
"iconLight": {
"path": "/assets/ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947.png",
"sha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"size": 873
},
"id": "com.netcatty.starter",
"readme": {
"path": "/assets/d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e.md",
"sha256": "d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e",
"size": 2050
},
"repository": "binaricat/Netcatty",
"version": "0.1.2"
},
{
"artifact": null,
"avatar": null,
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"iconDark": null,
"iconLight": null,
"id": "com.netcatty.hello",
"readme": {
"path": "/assets/1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209.md",
"sha256": "1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209",
"size": 2951
},
"repository": "binaricat/Netcatty",
"version": "0.1.0"
}
],
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"sourceLockSha256": "753711c1860ff0fcc10a558182f8c868d9d06b40e5c8de5522249d0d27b139d6"
}
@@ -0,0 +1,218 @@
{
"schemaVersion": 1,
"plugins": [
{
"id": "com.netcatty.starter",
"current": "0.1.2",
"versions": [
{
"entry": {
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
},
{
"id": "com.netcatty.hello",
"current": "0.1.0",
"versions": [
{
"entry": {
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
}
]
}
@@ -0,0 +1,20 @@
{
"catalog": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/catalog.json",
"sha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"size": 5739
},
"delivery": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/delivery.json",
"sha256": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55",
"size": 1869
},
"index": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/index.json",
"sha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"size": 7726
},
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"snapshotId": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55"
}
@@ -0,0 +1,40 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { createHash } = require("node:crypto");
const root = path.join(__dirname, "static-distribution");
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const read = (name) => fs.readFileSync(path.join(root, name));
const catalog = JSON.parse(read("catalog.json"));
const delivery = JSON.parse(read("delivery.json"));
function realFiles() {
const pointer = JSON.parse(read("snapshot.json"));
const files = new Map([["/v1/snapshot.json", read("snapshot.json")]]);
for (const name of ["catalog", "index", "delivery"]) files.set(pointer[name].path, read(`${name}.json`));
for (const name of fs.readdirSync(path.join(root, "assets"))) files.set(`/assets/${name}`, read(`assets/${name}`));
return files;
}
function makeFiles(entries = catalog, mutate = () => {}) {
const index = { schemaVersion: 1, plugins: entries.map((entry) => ({ id: entry.id, current: entry.version, versions: [{ entry: structuredClone(entry), distribution: "source-only", withdrawal: null }] })) };
const sidecar = { ...structuredClone(delivery), plugins: entries.map((entry) => ({ ...structuredClone(delivery.plugins.find((item) => item.id === entry.id) ?? delivery.plugins[0]), id: entry.id, version: entry.version, repository: entry.repository, commit: entry.commit })) };
mutate(index, sidecar);
const catalogBytes = Buffer.from(JSON.stringify(entries));
const indexBytes = Buffer.from(JSON.stringify(index));
sidecar.catalogSha256 = digest(catalogBytes); sidecar.indexSha256 = digest(indexBytes);
const deliveryBytes = Buffer.from(JSON.stringify(sidecar));
const snapshotId = digest(deliveryBytes);
const pointer = { schemaVersion: 1, registryCommit: sidecar.registryCommit, snapshotId };
const files = realFiles();
for (const [name, bytes] of [["catalog", catalogBytes], ["index", indexBytes], ["delivery", deliveryBytes]]) {
const ref = { path: `/snapshots/${snapshotId}/${name}.json`, size: bytes.length, sha256: digest(bytes) };
pointer[name] = ref; files.set(ref.path, bytes);
}
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
return files;
}
function responseFor(files, url) {
if (new URL(url).origin !== "https://plugins.netcatty.app") throw new Error("Unexpected upstream origin");
const bytes = files.get(new URL(url).pathname);
return bytes ? new Response(bytes) : new Response("Missing fixture", { status: 404 });
}
module.exports = { root, read, digest, catalog, delivery, realFiles, makeFiles, responseFor };
+14 -11
View File
@@ -1,20 +1,21 @@
"use strict";
const { createHash } = require("node:crypto");
const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json";
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
function prepareSnapshot(value) {
function prepareSnapshot(value, resources = new Map()) {
const entries = structuredClone(validateCatalog(value));
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) }));
return { entries, publicEntries };
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor([entry, resources.get(tuple(entry)) ?? null]) }));
return { entries, publicEntries, resources: structuredClone(resources) };
}
class RegistryCatalogService extends PluginCatalogService {
constructor(options) {
super(options);
this.publicEntries = prepareSnapshot(this.entries).publicEntries;
this.resources = new Map();
this.source = "builtin";
this.error = null;
this.refreshing = null;
@@ -24,11 +25,12 @@ class RegistryCatalogService extends PluginCatalogService {
this.refreshing = (async () => {
try {
// No redirects: only this exact registry endpoint can replace the catalog.
const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0);
const next = prepareSnapshot(JSON.parse(bytes.toString("utf8")));
const distribution = await fetchDistribution(this.fetch);
const next = prepareSnapshot(distribution.entries, distribution.resources);
// Publish only after full validation, clones and every token succeeded.
this.entries = next.entries;
this.publicEntries = next.publicEntries;
this.resources = next.resources;
this.source = "registry";
this.error = null;
return true;
@@ -50,15 +52,16 @@ class RegistryCatalogService extends PluginCatalogService {
}
selected(id, token) {
const entry = this.get(id);
if (typeof token !== "string" || token !== tokenFor(entry)) {
if (typeof token !== "string" || token !== this.publicEntries.find((item) => item.id === id)?.selectionToken) {
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
}
return entry;
}
async readme(id, token) {
this.selected(id, token);
// Base method captures the pinned entry synchronously before its first await.
return super.readme(id);
const entry = this.selected(id, token);
const resource = this.resources.get(tuple(entry))?.readme;
if (!resource) throw new Error("No published README snapshot is available. Refresh the catalog or open its source link.");
return (await fetchVerified(resource, this.fetch)).toString("utf8");
}
async install(id, token) {
this.selected(id, token);
+54 -114
View File
@@ -1,153 +1,93 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES } = require("./registryCatalogService.cjs");
const builtin = require("./catalog.json");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { catalog, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("registry uses a fixed bounded anonymous endpoint and retains only validated snapshots", async () => {
let response = () => new Response(JSON.stringify([builtin[0]]));
test("registry starts at a fixed anonymous pointer and retains only complete validated snapshots", async () => {
let files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url, options) => {
assert.equal(url, REGISTRY_URL);
assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
return response();
return responseFor(files, url);
} });
const online = await service.list();
assert.equal(online.source, "registry"); assert.equal(online.entries.length, 1);
assert.match(online.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", '{}', JSON.stringify([{ ...builtin[0], author: {} }]), JSON.stringify([{ ...builtin[0], commit: "main" }])]) {
response = () => new Response(invalid);
const good = await service.list();
assert.equal(good.source, "registry"); assert.equal(good.entries.length, 2);
assert.match(good.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", "{}", JSON.stringify({ schemaVersion: 2 }), '{"schemaVersion":1e999}', " ".repeat(16 * 1024 + 1)]) {
files = realFiles(); files.set("/v1/snapshot.json", Buffer.from(invalid));
const cached = await service.list();
assert.equal(cached.source, "cached"); assert.ok(cached.error);
assert.deepEqual(cached.entries, online.entries);
assert.deepEqual(cached.entries, good.entries);
}
response = () => new Response("[]", { headers: { "content-length": String(MAX_CATALOG_BYTES + 1) } });
assert.match((await service.list()).error, /size limit/);
response = () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/other/repo/main/catalog.json" } });
assert.match((await service.list()).error, /redirects/);
response = () => new Response("[]");
const empty = await service.list();
assert.equal(empty.error, null); assert.deepEqual(empty.entries, []);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
});
test("offline first launch returns bundled source examples and never mutates installed plugins", async () => {
const service = new RegistryCatalogService({ fetch: async () => { throw new Error("offline"); }, manager: {
test("offline first launch returns bundled source examples with no GitHub README fallback", async () => {
let calls = 0;
const service = new RegistryCatalogService({ fetch: async (url) => { calls++; assert.equal(url, REGISTRY_URL); throw new Error("offline"); }, manager: {
install() { assert.fail("offline must not install"); },
} });
const snapshot = await service.list();
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
assert.match(snapshot.error, /offline/);
await assert.rejects(service.readme(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /No published README/);
assert.equal(calls, 1);
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
});
test("selection binds manifest, README, version and artifact; refresh changes or withdrawal cannot silently install", async () => {
let entries = structuredClone(builtin);
let requests = 0;
const service = new RegistryCatalogService({ fetch: async (url) => {
requests++;
if (url === REGISTRY_URL) return new Response(JSON.stringify(entries));
return new Response("# Pinned README");
}, manager: { install() { assert.fail("changed selection must not install"); } } });
test("selection binds source and delivery metadata; changes and withdrawal cannot silently install", async () => {
let files = realFiles(); const requests = [];
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); return responseFor(files, url); }, manager: { install() { assert.fail("source-only must not install"); } } });
const selected = (await service.list()).entries[0];
assert.equal(await service.readme(selected.id, selected.selectionToken), "# Pinned README");
assert.match(await service.readme(selected.id, selected.selectionToken), /Starter/);
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
const before = requests;
await assert.rejects(service.install(selected.id), /changed/);
assert.equal(requests, before);
entries[0].description += " updated";
const before = requests.length;
await assert.rejects(service.install(selected.id), /changed/); assert.equal(requests.length, before);
await assert.rejects(service.install(selected.id, selected.selectionToken), /no published package/);
const changed = structuredClone(catalog); changed[0].description += " changed";
files = makeFiles(changed);
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
entries = [];
const next = (await service.list()).entries;
assert.deepEqual(next, []);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
});
test("concurrent list refreshes share one request", async () => {
let finish; let calls = 0;
const service = new RegistryCatalogService({ fetch: async () => { calls++; return new Promise((resolve) => { finish = resolve; }); } });
test("concurrent list refreshes share one pointer request and complete document set", async () => {
let finish; const requests = []; const files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); if (url === REGISTRY_URL) return new Promise((resolve) => { finish = resolve; }); return responseFor(files, url); } });
const first = service.list(); const second = service.list();
finish(new Response(JSON.stringify(builtin)));
assert.deepEqual(await first, await second); assert.equal(calls, 1);
finish(responseFor(files, REGISTRY_URL));
assert.deepEqual(await first, await second); assert.equal(requests.length, 4);
});
test("fresh unchanged selection installs only its pinned bytes and stays disabled", async (context) => {
const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path");
const { createHash } = require("node:crypto");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "registry-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic validated-by-manager archive");
const entry = { ...structuredClone(builtin[0]), artifact: { url: "https://github.com/example/plugin/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const requests = []; let installs = 0;
const service = new RegistryCatalogService({
getTempFilePath: (name) => path.join(root, name),
fetch: async (url) => { requests.push(url); return new Response(url === REGISTRY_URL ? JSON.stringify([entry]) : bytes); },
manager: { async install(archive, options) {
installs++; assert.deepEqual(await fs.readFile(archive), bytes); assert.equal(options.enable, false);
assert.deepEqual(options.catalog, entry); return { id: entry.id };
} },
});
const selected = (await service.list()).entries[0];
await service.install(selected.id, selected.selectionToken);
assert.deepEqual(requests, [REGISTRY_URL, REGISTRY_URL, entry.artifact.url]);
assert.equal(installs, 1); assert.deepEqual(await fs.readdir(root), []);
});
test("URL-stripped README controls cannot escape a selected commit or replace the good snapshot", async () => {
let entries = structuredClone(builtin);
const requested = [];
const service = new RegistryCatalogService({ fetch: async (url) => {
requested.push(url); return new Response(JSON.stringify(entries));
} });
test("invalid sources and unknown JSON fields cannot poison good offline snapshots", async () => {
let files = realFiles(); let offline = false;
const service = new RegistryCatalogService({ fetch: async (url) => { if (offline) throw new Error("offline"); return responseFor(files, url); } });
const good = await service.list();
for (const readme of ["\t../main/README.md", ".\n./main/README.md", "\r../main/README.md", "a/\t../../main/README.md", "README\u007f.md"]) {
entries = [{ ...builtin[0], readme }];
const snapshot = await service.list();
assert.equal(snapshot.source, "cached"); assert.match(snapshot.error, /unpinned/);
assert.deepEqual(snapshot.entries, good.entries);
}
entries = [{ ...builtin[0], repository: "example/.." }];
assert.match((await service.list()).error, /escapes the pinned commit/);
assert.ok(requested.every((url) => url === REGISTRY_URL));
});
test("whole-catalog JSON budgets and unknown fields cannot poison a good offline snapshot", async () => {
let response = JSON.stringify(builtin);
const service = new RegistryCatalogService({ fetch: async () => {
if (response === null) throw new Error("offline");
return new Response(response);
} });
const good = await service.list();
let nested = null;
for (let depth = 0; depth < 130; depth++) nested = { child: nested };
const nonfinite = structuredClone(builtin[0]);
nonfinite.manifest.contributes.settings[0].default = "finite-marker";
for (const invalid of [
JSON.stringify([{ ...builtin[0], unknownNestedField: nested }]),
JSON.stringify([{ ...builtin[0], unknownField: true }]),
JSON.stringify([nonfinite]).replace('"finite-marker"', '1e999'),
JSON.stringify([{ ...builtin[0], artifact: { url: "https://github.com/a/b", sha256: "a".repeat(64), unexpected: true } }]),
]) {
response = invalid;
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
let nested = null; for (let i = 0; i < 130; i++) nested = { child: nested };
const bad = [
{ ...catalog[0], readme: "\t../main/README.md" }, { ...catalog[0], repository: "example/.." },
{ ...catalog[0], unknownNestedField: nested }, { ...catalog[0], unknownField: true },
{ ...catalog[0], readmeAsset: { url: "https://plugins.netcatty.app/a", sha256: "a".repeat(64) } },
];
for (const entry of bad) {
files = makeFiles([entry]); offline = false;
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries);
response = null;
const offline = await service.list();
assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/);
offline = true; assert.deepEqual((await service.list()).entries, good.entries);
}
});
test("snapshot preparation failure leaves both private entries and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async () => new Response(JSON.stringify(builtin)) });
const good = await service.list();
test("snapshot preparation failure leaves private resources and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async (url) => responseFor(realFiles(), url) });
const good = await service.list(); const resources = service.resources;
const originalClone = global.structuredClone;
try {
global.structuredClone = () => { throw new Error("synthetic clone failure"); };
assert.equal(await service.refresh(), false);
} finally { global.structuredClone = originalClone; }
try { global.structuredClone = () => { throw new Error("synthetic clone failure"); }; assert.equal(await service.refresh(), false); }
finally { global.structuredClone = originalClone; }
assert.equal(service.resources, resources);
service.fetch = async () => { throw new Error("offline"); };
assert.deepEqual((await service.list()).entries, good.entries);
assert.equal(service.selected(good.entries[0].id, good.entries[0].selectionToken).id, good.entries[0].id);
});
@@ -0,0 +1,106 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { assertDownloadUrl, fetchBounded, validateCatalog } = require("./catalogService.cjs");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { fetchVerified, assertAsset } = require("./distributionSnapshot.cjs");
const { read, digest, catalog, delivery, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("exact registry PR3 bootstrap hashes and all README/icon assets verify without GitHub requests", async () => {
assert.equal(digest(read("snapshot.json")), "2878885057a01585e34897c226bcfd81615345d843ea94297980de7e397b4617");
assert.equal(digest(read("delivery.json")), "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55");
const requests = []; const files = realFiles();
const fetch = async (url, options) => {
requests.push(url); assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
return responseFor(files, url);
};
const service = new RegistryCatalogService({ fetch });
const snapshot = await service.list();
assert.equal(snapshot.error, null); assert.equal(snapshot.source, "registry");
assert.equal(requests.length, 4); assert.equal(requests[0], REGISTRY_URL);
for (const entry of snapshot.entries) {
const resource = delivery.plugins.find((item) => item.id === entry.id);
assert.equal(await service.readme(entry.id, entry.selectionToken), read(resource.readme.path.slice(1)).toString());
for (const icon of [resource.iconLight, resource.iconDark]) if (icon) {
assertAsset(icon, ["png", "webp"]);
const bytes = await fetchVerified(icon, fetch);
assert.equal(bytes.length, icon.size); assert.equal(digest(bytes), icon.sha256);
}
assert.equal(entry.artifact, undefined);
assert.equal(resource.avatar, null); assert.equal(resource.artifact, null);
}
assert.ok(requests.every((url) => !/github/u.test(url)));
});
test("GitHub, tracking and noncanonical URLs are rejected before any upstream request", async () => {
for (const url of [
"https://api.github.com/repos/a/b", "https://raw.githubusercontent.com/a/b/main/README.md",
"https://avatars.githubusercontent.com/u/1", "https://github.com/a/b/releases/download/v1/a.ncpkg",
"https://release-assets.githubusercontent.com/a", "https://objects.githubusercontent.com/a",
"https://plugins.netcatty.app.attacker.invalid/a", "https://plugins.netcatty.app@attacker.invalid/a",
"http://plugins.netcatty.app/a", "https://plugins.netcatty.app/a?track=1", "https://plugins.netcatty.app/a#fragment",
"https://plugins.netcatty.app/a/../b", "https://plugins.netcatty.app/%2e%2e/b", "https://plugins.netcatty.app/\ta",
]) {
assert.throws(() => assertDownloadUrl(url));
assert.throws(() => validateCatalog([{ ...catalog[0], artifact: { url, sha256: "a".repeat(64) } }]));
await assert.rejects(fetchBounded(url, 1024, () => assert.fail("invalid source must not be requested")));
}
});
test("official responses never redirect to GitHub or another official path", async () => {
for (const location of ["https://github.com/a", "https://raw.githubusercontent.com/a/b/main/catalog.json", "https://plugins.netcatty.app/another.json"]) {
const requests = [];
await assert.rejects(fetchBounded(REGISTRY_URL, 1024, async (url) => {
requests.push(url); return new Response(null, { status: 302, headers: { location } });
}), /redirects/);
assert.deepEqual(requests, [REGISTRY_URL]);
}
});
test("cross-document mismatches, withdrawn versions and unsupported assets preserve the complete good cache", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list(); const goodResources = service.resources;
const mutations = [
(index, sidecar) => { sidecar.registryCommit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins[0].commit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins.push(sidecar.plugins[0]); },
(index, sidecar) => { sidecar.plugins.pop(); },
(index, sidecar) => { sidecar.plugins[0].readme.path = "https://raw.githubusercontent.com/a/b/main/README.md"; },
(index, sidecar) => { sidecar.plugins[0].readme.path = `/assets/${sidecar.plugins[0].readme.sha256}.png`; },
(index, sidecar) => { sidecar.plugins[0].iconLight.path = `/assets/${sidecar.plugins[0].iconLight.sha256}.md`; },
(index, sidecar) => { sidecar.plugins[0].avatar = { path: "/assets/avatar.png" }; },
(index, sidecar) => { sidecar.plugins[0].artifact = { path: "/assets/package.ncpkg" }; },
(index) => { index.plugins[0].versions[0].withdrawal = { at: "2026-10-26T00:00:00Z", reason: "synthetic withdrawal", advisory: "https://github.com/example/advisory" }; },
(index) => { index.plugins[0].versions[0].entry.description += " mismatch"; },
(index) => { index.plugins[0].current = "9.0.0"; },
(index) => { index.plugins.push(index.plugins[0]); },
(index, sidecar) => { sidecar.unknown = true; },
];
for (const mutate of mutations) {
files = makeFiles(catalog, mutate);
// Preserve the pointer's trusted registry identity when testing delivery mismatch.
const pointer = JSON.parse(files.get("/v1/snapshot.json")); pointer.registryCommit = delivery.registryCommit;
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries); assert.equal(service.resources, goodResources);
}
});
test("pointer/document/resource byte mismatches and partial availability never publish partial state", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list();
const pointer = JSON.parse(read("snapshot.json"));
for (const name of ["catalog", "index", "delivery"]) {
files = realFiles(); files.set(pointer[name].path, Buffer.from("{}"));
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.deepEqual(rejected.entries, good.entries);
files = realFiles(); files.delete(pointer[name].path);
assert.deepEqual((await service.list()).entries, good.entries);
}
files = realFiles(); const altered = structuredClone(pointer); altered.catalog.path = "/catalog.json";
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(altered)));
assert.match((await service.list()).error, /resource reference/);
files = realFiles(); files.set(delivery.plugins[0].readme.path, Buffer.from("x".repeat(delivery.plugins[0].readme.size)));
await assert.rejects(service.readme(good.entries[0].id, good.entries[0].selectionToken), /SHA-256 mismatch/);
});