mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ebc98f0a3f
* fix(plugins): unknown column * fix(mcp): reuse MCP sessions instead of creating one per tool call The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers was absent and browser-context MCP clients (Claude Desktop connectors, Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize response. Unable to echo it back, every request looked like a fresh initialize: one McpServer and one session per tool call, until the per-user cap returned a 429 and the integration died until the container was restarted. The idle sweep was not at fault — it expires on lastActivity with a 1h default, so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'. - expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate - evict a user's least-recently-active session at the cap rather than refusing the request, so a client that cannot persist its session id (or a proxy that strips the header) can never wedge the server - close the McpServer/transport orphaned by every session-less non-initialize POST, which was leaked: never mapped, never swept, never closed - return the cap error as JSON-RPC so clients surface the real reason - warn on session-less POSTs to make a header-stripping proxy diagnosable * chore(deps): declare @modelcontextprotocol/sdk ^1.29.0 Matches the version already resolved in the lockfile; no dependency-tree change. * docs(mcp): add the reverse proxy specs for MCP * fix(plugins): stop the row ⋯ menu being clipped by the sidebar The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is `overflow-hidden` — which clips absolutely-positioned descendants regardless of z-index. With enough plugins installed a row sits low enough that its menu runs past the sidebar's bottom edge and gets chopped, taking Delete with it, so the plugin could no longer be uninstalled from the UI. Portal the menu to <body> and position it `fixed` against the ⋯ button, flipping upward when the bottom is tight and re-anchoring on scroll/resize. A fixed child of <body> has no overflow ancestor, so nothing can clip it. Closes #1523 * feat(plugins): surface author-signature status and add a scoped re-trust override TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on first install, but none of it was ever shown: a successfully-installed UNSIGNED plugin looked identical to a signed one, and a signature-refused update left the plugin quietly pinned at its old version with the reason dying in a toast. Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING / _INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover. Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key; a signature that does not verify means the bytes are not what the author signed, and there is no story where waving that through is right. The override re-pins and updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update would leave the plugin pinned to a key no install had ever been verified against if that second call never came. The artifact must still verify under the new key, so a re-trust moves the pin from one verified key to another. assertRetrustable re-derives the condition server-side, so the UI hiding the button is a convenience, not the control, and it echoes back the full key the admin was shown so a re-key since the dialog rendered is refused. The rotation is written to the admin audit log with both fingerprints — after an incident, "which key did we move from, and to what?" is the question a single key cannot answer. * fix(plugins): let a plugin's frame reach the hosts an admin added for it The frame's connect-src was built from the manifest's http:outbound grants alone, but the child's egress guard is the UNION of those and the hosts an admin added after install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author cannot know in advance). So such a plugin WITH A UI could call the operator's host from its server and was CSP-blocked in its own iframe. Match the frame to the child. The admin consented to these hosts at install and the child already reaches them, so this widens no trust boundary that isn't already crossed. Both sources stay validated on the way in, and the interpolation filter is unchanged. * fix(plugins): report a rejected events.emit on the plugin's own log stream The host can reject an emit (an undeclared event name, a rate limit). The rejection must not escape — a detached rejection crashes the child and terminally disables the plugin over one bad emit — but swallowing it silently left an author with no way to discover that `emits` was missing from their manifest. Surface it as a warning instead. * feat(plugin-sdk): expose the raw request body for webhook signature checks A webhook author must run their HMAC over the exact bytes the sender signed. `body` is the PARSED value, and re-serializing it will not reproduce those bytes — key order, whitespace and unicode escaping all differ — so the signature never matches. Document `rawBodyBase64`, which the host already sets on auth:false routes. * fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes of a release already in the registry breaks the checksum for everyone who installed that version — they can no longer install or update it. The old code blanket-caught every `gh release create` failure (auth, network, a bad repo) and turned it into a --clobber upload. Probe for the release explicitly and refuse unless --force. Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the entry's sha256 pin were computed from; a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT file, not a rebuild. * fix(plugin-sdk): don't fail submit when the fork already has an upstream remote `gh repo clone` of a fork may already have wired `upstream`, in which case a bare `remote add` exits non-zero and took the whole submit down. Set it either way. * fix(plugin-sdk): make the dev server behave like the host Three ways `dev` lied to an author about how their plugin would run in production: - ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so an mkdir/permission failure silently degraded to the in-memory stub — which swallows every write while reporting success. A db:own plugin "worked" in dev and persisted nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and say plainly that the stub discards writes. - notificationChannel: the host fires it with no acting user and hands the recipient's decrypted settings in as a separate `config` argument — send(msg, config, ctx) / test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs, so a channel plugin read its settings off ctx and was broken in production. - /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists. * fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help `parse()` accepts any --x, so a flag a command does not read was silently dropped: `create --template notification-channel` cheerfully scaffolded a blank plugin. Silently ignoring an author's explicit instruction is worse than refusing it, so unknown flags are now an error, and create actually forwards --template/--egress/--required-addons. A bare --permissions used to split the string "true" into a permission literally named `true`; listFlag() now treats a valueless flag as absent. Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the flag check and prints usage on stdout with exit 0. * chore(plugins): drop three unused eslint-disable directives The no-console rule is not enabled for these files, so the directives were dead and eslint reports them as unused. * fix(plugin-sdk): bring preflight back in step with the registry's gates preflight exists to tell an author what TREK-Plugins' CI will say before they open the PR. The registry now verifies author signatures, and preflight didn't — so it drifted into the one failure mode it must never have: a false green. An author trusts a green. - Verify the signature against the artifact bytes. preflight already downloads them for the sha256 check, so this costs one call. Without it, signing with the wrong key (or re-packing after signing) sails through and is caught at review. - Check the signature SHAPE (checkSignatureShape): a key with no signed version, a signature with no key, a malformed key or signature. TREK refuses to install a half-signed entry, so such an entry is dead on arrival. - Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/ manifest.ts and `entry` both default it — so a manifest that legally omits it was failing preflight with "manifest apiVersion undefined != entry 1" while the registry passed it. A false RED, which teaches authors to ignore the tool. - Check requiredAddons/pluginDependencies parity, and operatorEgress without an http:outbound grant. The verifier is a port of the host's install/verify-signature.ts (the registry has its own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK itself emits and cannot judge a minisign key. A test pins all three to the same verdicts. * feat(plugins): enforce compatibility range * chore: bump sdk version * test(e2e): repair the trip-creation specs create-trip and trip-planner have been failing for a while — long enough for three separate UI changes to drift past them, which nothing caught because CI runs vitest only and never invokes Playwright. Each failure was masking the next: - The release-notice modal greets a freshly seeded user and its backdrop swallowed the click on .add-trip-card. Added a shared dismissSystemNotices() helper (the X only shows on the notice's last page, so it has to page through first). - .modal-backdrop no longer exists — the class was namespaced to .trek-modal-backdrop so content blockers stop hiding it. - input[type=text].first() is no longer the Title field: the cover-image search inputs now sit above it, so the specs were typing the trip name into the photo search box and creating an untitled trip that never matched getByText(title). * fix(planner): gate drag & drop on pointer type, not viewport width (#1432) The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever detected touch — "mobile" was inferred from viewport width, at four independent breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was fixed and iPad was not: - useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad rows stayed draggable and a scroll swipe became an HTML5 drag. - TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so its drop handlers and the drop-to-import overlay could never be disabled — that overlay is the reported symptom. - The arrow-button reorder fallback was revealed only below 767px, leaving iPad with no drag *and* no fallback. - touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1 removed. Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout stays width-driven, so the iPad keeps the desktop two-pane planner, while every drag affordance is gated on isMobile || isTouch. The reorder arrows now show on coarse pointers, and the polyfill loads only on hybrid laptops ((pointer: fine) and (any-pointer: coarse)), which also removes the #1440 phantom-dblclick map zoom on tablets. Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context — the engine matters, since every browser on iPadOS is WebKit underneath, which is why the reporter hit this in all three they tried. * test(planner): guard the day-plan reorder arrows against phantom clicks The hover rule that reveals the reorder arrows had been dead since the TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither class exists — the component renders `.reorder-buttons` inside an unclassed row. The buttons sat at opacity:0 on desktop with nothing to reveal them. That was not merely invisible. opacity:0 still hit-tests, so every itinerary row and day note carried an invisible, fully clickable target that silently reordered the trip: opacity: "0", visibility: "visible", pointerEvents: "auto" elementFromPoint(centre) -> BUTTON, inside .reorder-buttons The repair — pointer-events: none while hidden, plus a working .dp-row:hover/:focus-within reveal (focus-within so the buttons are also reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating, so they went with that commit rather than being split mid-file; this commit is the regression guard for them. E2E rather than unit, because jsdom does not evaluate :hover. Against the old CSS it fails on exactly the right assertion: "hidden arrows must not swallow clicks" — expected false, received true. * fix(pdf): keep the header gap on day-header overflow pages (#1531) The repeated <thead> day header (#1471) carried no gap before the first card on overflow pages: the 12px sat in .day-body's block-start padding, which a fragmented box only paints on its first fragment. Move it to the thead cell so it repeats with the header; the day's first page renders pixel-identically. * fix(budget): keep "no one paid yet" when editing an expense (#1533) The ExpenseModal payer initializer fell back to the current user whenever the edited item had no payer, so reopening an expense saved with "no one paid yet" silently reselected "You" — and re-saving then recorded the current user as the payer, corrupting the balances. An absent payer on an existing expense is a deliberate value, so only a brand-new expense defaults to me.
830 lines
44 KiB
TypeScript
830 lines
44 KiB
TypeScript
/**
|
|
* TREK-side registry service (#plugins, M5): browse the aggregated registry and
|
|
* install a pinned version through the full verify -> extract -> validate ->
|
|
* move -> register pipeline (with the network download mocked).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import zlib from 'node:zlib';
|
|
import { createHash, generateKeyPairSync, sign as edSign } from 'node:crypto';
|
|
|
|
const { safeDownload } = vi.hoisted(() => ({ safeDownload: vi.fn() }));
|
|
vi.mock('../../../src/nest/plugins/install/safe-fetch', async (orig) => ({
|
|
...(await orig<Record<string, unknown>>()),
|
|
safeDownload,
|
|
}));
|
|
|
|
const { testDb } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec(`
|
|
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
|
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT,
|
|
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT,
|
|
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
|
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
|
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
|
|
return { testDb: db };
|
|
});
|
|
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
|
|
|
import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service';
|
|
|
|
// ── tiny tar.gz builder (wraps the plugin in a codeload-style top dir) ────────
|
|
function tarHeader(name: string, size: number, typeflag = '0'): Buffer {
|
|
const h = Buffer.alloc(512, 0);
|
|
h.write(name, 0); h.write('0000644', 100); h.write('0000000', 108); h.write('0000000', 116);
|
|
h.write(size.toString(8).padStart(11, '0'), 124); h.write('00000000000', 136);
|
|
h.write(' ', 148); h.write(typeflag, 156); h.write('ustar\0', 257); h.write('00', 263);
|
|
let sum = 0; for (let i = 0; i < 512; i++) sum += h[i];
|
|
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
|
return h;
|
|
}
|
|
// Every artifact declares a `trek` range by default — install REQUIRES one, so a fixture
|
|
// without it would be testing the version gate rather than whatever the test is about.
|
|
// Pass an explicit `trek` (or null, spread last) to exercise the gate itself.
|
|
function makeArtifact(manifest: object): Buffer {
|
|
const withTrek = { trek: '>=3.2.0 <4.0.0', ...manifest };
|
|
const files = [
|
|
{ name: 'plug-abc/', type: '5' as const, data: '' },
|
|
{ name: 'plug-abc/trek-plugin.json', type: '0' as const, data: JSON.stringify(withTrek) },
|
|
{ name: 'plug-abc/server/', type: '5' as const, data: '' },
|
|
{ name: 'plug-abc/server/index.js', type: '0' as const, data: 'module.exports={}' },
|
|
];
|
|
const parts: Buffer[] = [];
|
|
for (const f of files) {
|
|
const body = Buffer.from(f.data);
|
|
parts.push(tarHeader(f.name, f.type === '5' ? 0 : body.length, f.type));
|
|
if (f.type === '0') { parts.push(body); const pad = (512 - (body.length % 512)) % 512; if (pad) parts.push(Buffer.alloc(pad, 0)); }
|
|
}
|
|
parts.push(Buffer.alloc(1024, 0));
|
|
return zlib.gzipSync(Buffer.concat(parts));
|
|
}
|
|
|
|
const REGISTRY = {
|
|
schemaVersion: 1,
|
|
plugins: [
|
|
{
|
|
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
|
|
type: 'widget', reviewedAt: '2026-06-20',
|
|
versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/acme/trek-flight/tar.gz/aaaa', sha256: '', minTrekVersion: '3.2.0' }],
|
|
},
|
|
],
|
|
};
|
|
|
|
let dataRoot: string;
|
|
let codeRoot: string;
|
|
let svc: PluginRegistryService;
|
|
|
|
beforeEach(() => {
|
|
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-data-'));
|
|
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-code-'));
|
|
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
|
process.env.TREK_PLUGINS_DIR = codeRoot;
|
|
testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log');
|
|
__clearRegistryCacheForTests();
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response));
|
|
svc = new PluginRegistryService();
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
safeDownload.mockReset();
|
|
delete (REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey;
|
|
delete (REGISTRY.plugins[0].versions[0] as { signature?: string }).signature;
|
|
delete process.env.TREK_PLUGINS_DATA_DIR;
|
|
delete process.env.TREK_PLUGINS_DIR;
|
|
fs.rmSync(dataRoot, { recursive: true, force: true });
|
|
fs.rmSync(codeRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
/** Ed25519 keypair as (raw 32-byte pubkey base64, raw-signature signer). */
|
|
function signingKey() {
|
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
return {
|
|
pubB64: (publicKey.export({ format: 'der', type: 'spki' }).subarray(-32) as Buffer).toString('base64'),
|
|
sign: (b: Buffer) => (edSign(null, b, privateKey) as Buffer).toString('base64'),
|
|
};
|
|
}
|
|
function stageSignedArtifact(pubB64: string, sig: (b: Buffer) => string): Buffer {
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
|
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
|
(REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey = pubB64;
|
|
(REGISTRY.plugins[0].versions[0] as { signature?: string }).signature = sig(artifact);
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
|
return artifact;
|
|
}
|
|
|
|
describe('PluginRegistryService', () => {
|
|
it('browse maps the aggregated registry to metadata', async () => {
|
|
const list = await svc.browse();
|
|
expect(list).toEqual([
|
|
expect.objectContaining({ id: 'flight-tracker', name: 'Flight', latest: '1.0.0', minTrekVersion: '3.2.0', reviewedAt: '2026-06-20' }),
|
|
]);
|
|
});
|
|
|
|
it('browse exposes a screenshot url pinned at the reviewed commit', async () => {
|
|
const list = await svc.browse();
|
|
expect(list[0].screenshotUrl).toBe(`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`);
|
|
});
|
|
|
|
it('detail merges registry metadata with a live manifest preview', async () => {
|
|
safeDownload.mockResolvedValue({
|
|
bytes: Buffer.from(JSON.stringify({
|
|
id: 'flight-tracker', permissions: ['db:read:trips', 'http:outbound'], egress: ['api.example.com'],
|
|
settings: [{ key: 'api_key', label: 'API Key', input_type: 'password', scope: 'instance', required: true, secret: true }],
|
|
license: 'MIT', icon: 'Plane',
|
|
})),
|
|
sha256: 'unused',
|
|
});
|
|
const d = await svc.detail('flight-tracker');
|
|
expect(safeDownload).toHaveBeenCalledWith(
|
|
`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/trek-plugin.json`,
|
|
expect.any(Number),
|
|
);
|
|
expect(d).toMatchObject({
|
|
id: 'flight-tracker', repo: 'acme/trek-flight', latest: '1.0.0', reviewedAt: '2026-06-20',
|
|
screenshotUrl: `https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`,
|
|
manifest: {
|
|
permissions: ['db:read:trips', 'http:outbound'],
|
|
egress: ['api.example.com'],
|
|
settings: [{ key: 'api_key', label: 'API Key', inputType: 'password', scope: 'instance', required: true }],
|
|
license: 'MIT', icon: 'Plane',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('detail soft-fails the manifest fetch (registry metadata still renders)', async () => {
|
|
safeDownload.mockRejectedValue(new Error('offline'));
|
|
const d = await svc.detail('flight-tracker');
|
|
expect(d).toMatchObject({ id: 'flight-tracker', manifest: null });
|
|
});
|
|
|
|
it('detail tolerates a malformed manifest shape', async () => {
|
|
safeDownload.mockResolvedValue({
|
|
bytes: Buffer.from(JSON.stringify({ permissions: 'not-an-array', settings: [{ label: 'no key' }], egress: [7, 'ok.host'] })),
|
|
sha256: 'unused',
|
|
});
|
|
const d = (await svc.detail('flight-tracker')) as { manifest: { permissions: string[]; egress: string[]; settings: unknown[] } };
|
|
expect(d.manifest).toMatchObject({ permissions: [], egress: ['ok.host'], settings: [] });
|
|
});
|
|
|
|
it('detail surfaces operatorEgress, so the pre-install review can say the host list is not the whole story', async () => {
|
|
safeDownload.mockResolvedValue({
|
|
bytes: Buffer.from(JSON.stringify({ permissions: ['http:outbound:gotify.net'], egress: ['gotify.net'], operatorEgress: true })),
|
|
sha256: 'unused',
|
|
});
|
|
const d = (await svc.detail('flight-tracker')) as { manifest: { operatorEgress: boolean; egress: string[] } };
|
|
expect(d.manifest).toMatchObject({ egress: ['gotify.net'], operatorEgress: true });
|
|
});
|
|
|
|
it('detail defaults operatorEgress to false (an ordinary plugin must not claim it)', async () => {
|
|
safeDownload.mockResolvedValue({
|
|
bytes: Buffer.from(JSON.stringify({ permissions: [], egress: ['api.example.com'] })),
|
|
sha256: 'unused',
|
|
});
|
|
const d = (await svc.detail('flight-tracker')) as { manifest: { operatorEgress: boolean } };
|
|
expect(d.manifest.operatorEgress).toBe(false);
|
|
});
|
|
|
|
it('detail is cached per plugin (one manifest fetch across calls)', async () => {
|
|
safeDownload.mockResolvedValue({ bytes: Buffer.from('{}'), sha256: 'unused' });
|
|
await svc.detail('flight-tracker');
|
|
await svc.detail('flight-tracker');
|
|
expect(safeDownload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('detail does not negative-cache a failed manifest fetch (next open retries)', async () => {
|
|
safeDownload.mockRejectedValueOnce(new Error('github hiccup'));
|
|
const first = (await svc.detail('flight-tracker')) as { manifest: unknown };
|
|
expect(first.manifest).toBeNull();
|
|
safeDownload.mockResolvedValue({ bytes: Buffer.from(JSON.stringify({ permissions: ['db:own'] })), sha256: 'unused' });
|
|
const second = (await svc.detail('flight-tracker')) as { manifest: { permissions: string[] } };
|
|
expect(second.manifest).toMatchObject({ permissions: ['db:own'] });
|
|
expect(safeDownload).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('detail rejects an unknown plugin id', async () => {
|
|
await expect(svc.detail('ghost')).rejects.toThrow(RegistryError);
|
|
});
|
|
|
|
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
|
|
__clearRegistryCacheForTests();
|
|
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
|
expect((await new PluginRegistryService().fetchRegistry()).plugins).toEqual([]);
|
|
});
|
|
|
|
it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => {
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
|
const sha = createHash('sha256').update(artifact).digest('hex');
|
|
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
|
|
|
const out = await svc.install('flight-tracker');
|
|
expect(out).toEqual({ id: 'flight-tracker', version: '1.0.0' });
|
|
|
|
// moved into place + registered inactive with provenance
|
|
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker', 'trek-plugin.json'))).toBe(true);
|
|
const row = testDb.prepare("SELECT status, source_repo, source_commit FROM plugins WHERE id='flight-tracker'").get() as { status: string; source_repo: string; source_commit: string };
|
|
expect(row).toMatchObject({ status: 'inactive', source_repo: 'acme/trek-flight', source_commit: 'a'.repeat(40) });
|
|
// no staging left behind
|
|
const staging = path.join(dataRoot, '.staging');
|
|
expect(!fs.existsSync(staging) || fs.readdirSync(staging).length === 0).toBe(true);
|
|
});
|
|
|
|
it('rejects an sha256 mismatch', async () => {
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget' });
|
|
REGISTRY.plugins[0].versions[0].sha256 = 'b'.repeat(64);
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: 'c'.repeat(64) });
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(/integrity/);
|
|
});
|
|
|
|
it('rejects an unknown plugin id', async () => {
|
|
await expect(svc.install('ghost')).rejects.toThrow(RegistryError);
|
|
});
|
|
|
|
it('caches the registry (one fetch across calls)', async () => {
|
|
const spy = vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response);
|
|
vi.stubGlobal('fetch', spy);
|
|
__clearRegistryCacheForTests();
|
|
await svc.fetchRegistry();
|
|
await svc.fetchRegistry();
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('force refresh bypasses the cache and busts the CDN', async () => {
|
|
const spy = vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response);
|
|
vi.stubGlobal('fetch', spy);
|
|
__clearRegistryCacheForTests();
|
|
await svc.fetchRegistry(); // primes the 30-min cache (fetch #1)
|
|
await svc.fetchRegistry(true); // force → refetches despite the warm cache
|
|
expect(spy).toHaveBeenCalledTimes(2);
|
|
const [url, opts] = spy.mock.calls[1] as unknown as [string | URL, { headers?: Record<string, string> }];
|
|
expect(String(url)).toMatch(/[?&]_=\d+/); // cache-buster query
|
|
expect(opts.headers?.['Cache-Control']).toBe('no-cache');
|
|
});
|
|
|
|
// ── Sideload (upload your own plugin) ────────────────────────────────────────
|
|
it('sideload: stage + commit installs an uploaded plugin INACTIVE as local:upload', () => {
|
|
const zip = makeArtifact({ id: 'my-upload', name: 'Uploaded', version: '2.0.0', type: 'widget', permissions: ['db:own'] });
|
|
const staged = svc.stageUpload(zip);
|
|
expect(staged.id).toBe('my-upload');
|
|
expect(staged.version).toBe('2.0.0');
|
|
|
|
svc.commitUpload(staged);
|
|
|
|
const row = testDb.prepare('SELECT status, source_repo, reviewed_at, version FROM plugins WHERE id = ?').get('my-upload') as
|
|
{ status: string; source_repo: string | null; reviewed_at: string | null; version: string } | undefined;
|
|
expect(row?.status).toBe('inactive'); // never auto-activates
|
|
expect(row?.source_repo).toBe('local:upload');
|
|
expect(row?.reviewed_at).toBeNull(); // unsigned + unreviewed → flagged in the UI
|
|
expect(row?.version).toBe('2.0.0');
|
|
expect(fs.existsSync(path.join(codeRoot, 'my-upload', 'trek-plugin.json'))).toBe(true);
|
|
expect(fs.existsSync(staged.stagingDir)).toBe(false); // staging cleaned up
|
|
});
|
|
|
|
it('sideload: rejects an oversized archive', () => {
|
|
expect(() => svc.stageUpload(Buffer.alloc(50 * 1024 * 1024 + 8192))).toThrow(/50MB|exceed/i);
|
|
});
|
|
|
|
it('sideload: rejects an archive without a manifest', () => {
|
|
const empty = zlib.gzipSync(Buffer.alloc(1024, 0)); // valid gzip, empty tar
|
|
expect(() => svc.stageUpload(empty)).toThrow(/trek-plugin\.json/);
|
|
});
|
|
|
|
it('sideload: forces INACTIVE even when replacing a plugin that was active', () => {
|
|
const zip = () => makeArtifact({ id: 'my-upload', name: 'Uploaded', version: '2.0.0', type: 'widget', permissions: ['db:own'] });
|
|
svc.commitUpload(svc.stageUpload(zip())); // first install
|
|
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'my-upload'").run(); // admin activated it
|
|
svc.commitUpload(svc.stageUpload(zip())); // re-upload replaces the code
|
|
const row = testDb.prepare('SELECT status, enabled FROM plugins WHERE id = ?').get('my-upload') as { status: string; enabled: number };
|
|
expect(row.status).toBe('inactive'); // discoverPlugins keeps the old status; commitUpload floors it back to inactive
|
|
expect(row.enabled).toBe(0);
|
|
});
|
|
|
|
it('soft-fails on a non-ok registry response', async () => {
|
|
__clearRegistryCacheForTests();
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response));
|
|
expect((await svc.fetchRegistry()).plugins).toEqual([]);
|
|
});
|
|
|
|
it('rejects a version that is not listed', async () => {
|
|
await expect(svc.install('flight-tracker', { version: '9.9.9' })).rejects.toThrow(/not found/);
|
|
});
|
|
|
|
it('rejects an archive without a manifest', async () => {
|
|
const parts = [Buffer.alloc(1024, 0)]; // empty tar
|
|
const empty = zlib.gzipSync(Buffer.concat(parts));
|
|
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(empty).digest('hex');
|
|
safeDownload.mockResolvedValue({ bytes: empty, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(/no trek-plugin.json/);
|
|
});
|
|
|
|
it('rejects a manifest id that does not match the registry id', async () => {
|
|
const artifact = makeArtifact({ id: 'other-id', name: 'X', version: '1.0.0', type: 'widget' });
|
|
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(/!=/);
|
|
});
|
|
|
|
it('installs a signed plugin and pins the author key (TOFU)', async () => {
|
|
const k = signingKey();
|
|
stageSignedArtifact(k.pubB64, k.sign);
|
|
await expect(svc.install('flight-tracker')).resolves.toEqual({ id: 'flight-tracker', version: '1.0.0' });
|
|
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string };
|
|
expect(row.author_pubkey).toBe(k.pubB64);
|
|
});
|
|
|
|
it('rejects a signed plugin whose signature does not verify', async () => {
|
|
const k = signingKey();
|
|
stageSignedArtifact(k.pubB64, () => k.sign(Buffer.from('different bytes')));
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(/signature verification failed/);
|
|
});
|
|
|
|
it('rejects an update whose author key differs from the pinned one (TOFU mismatch)', async () => {
|
|
const a = signingKey();
|
|
stageSignedArtifact(a.pubB64, a.sign);
|
|
await svc.install('flight-tracker');
|
|
const b = signingKey();
|
|
stageSignedArtifact(b.pubB64, b.sign); // valid signature, but a different author key
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(/author signing key changed/);
|
|
});
|
|
|
|
it('rejects a half-signed entry (key without a signature)', async () => {
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
|
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
|
(REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey = signingKey().pubB64; // no version signature
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(/incomplete signature/);
|
|
});
|
|
|
|
it('refuses to downgrade a previously-signed plugin to an unsigned update', async () => {
|
|
const k = signingKey();
|
|
stageSignedArtifact(k.pubB64, k.sign);
|
|
await svc.install('flight-tracker');
|
|
// now offer an unsigned update
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
|
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
|
delete (REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey;
|
|
delete (REGISTRY.plugins[0].versions[0] as { signature?: string }).signature;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(/unsigned/);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Machine-readable codes on the four signature refusals.
|
|
*
|
|
* These exist because exactly ONE of the four (a rotated key) may ever be overridden by
|
|
* an admin, and the UI has to tell them apart without reading prose. A client that
|
|
* string-matches the message will eventually offer "re-trust" on a signature that simply
|
|
* does not verify — the worst outcome this whole surface could produce.
|
|
*/
|
|
describe('signature failure codes', () => {
|
|
const codeOf = async (): Promise<string | undefined> => {
|
|
try {
|
|
await svc.install('flight-tracker');
|
|
return undefined; // installed — no refusal
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RegistryError);
|
|
return (e as RegistryError).code;
|
|
}
|
|
};
|
|
|
|
it('SIGNATURE_MISSING — was signed before, the update is unsigned', async () => {
|
|
const k = signingKey();
|
|
stageSignedArtifact(k.pubB64, k.sign);
|
|
await svc.install('flight-tracker');
|
|
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
|
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
|
delete (REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey;
|
|
delete (REGISTRY.plugins[0].versions[0] as { signature?: string }).signature;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
|
|
|
expect(await codeOf()).toBe('SIGNATURE_MISSING');
|
|
});
|
|
|
|
it('SIGNATURE_INCOMPLETE — an author key with no version signature', async () => {
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
|
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
|
(REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey = signingKey().pubB64;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
|
|
|
expect(await codeOf()).toBe('SIGNATURE_INCOMPLETE');
|
|
});
|
|
|
|
it('SIGNATURE_KEY_CHANGED — the pinned key no longer matches the registry entry', async () => {
|
|
const a = signingKey();
|
|
stageSignedArtifact(a.pubB64, a.sign);
|
|
await svc.install('flight-tracker');
|
|
const b = signingKey();
|
|
stageSignedArtifact(b.pubB64, b.sign); // a VALID signature — under a different key
|
|
|
|
expect(await codeOf()).toBe('SIGNATURE_KEY_CHANGED');
|
|
});
|
|
|
|
it('SIGNATURE_INVALID — a well-formed signature that does not verify', async () => {
|
|
const k = signingKey();
|
|
stageSignedArtifact(k.pubB64, () => k.sign(Buffer.from('different bytes')));
|
|
|
|
expect(await codeOf()).toBe('SIGNATURE_INVALID');
|
|
});
|
|
|
|
// verifyAuthorSignature THROWS SignatureError (not RegistryError) on a malformed key or
|
|
// signature, and that used to escape verifySignatureAndTofu untyped. To an admin a
|
|
// malformed signature and an invalid one are the same thing, and both are non-overridable.
|
|
it('SIGNATURE_INVALID — a MALFORMED signature (SignatureError is mapped, not leaked)', async () => {
|
|
const k = signingKey();
|
|
stageSignedArtifact(k.pubB64, k.sign);
|
|
(REGISTRY.plugins[0].versions[0] as { signature?: string }).signature = Buffer.from('nonsense').toString('base64');
|
|
|
|
expect(await codeOf()).toBe('SIGNATURE_INVALID');
|
|
});
|
|
|
|
it('SIGNATURE_INVALID — a MALFORMED author key', async () => {
|
|
const k = signingKey();
|
|
stageSignedArtifact(k.pubB64, k.sign);
|
|
(REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey = Buffer.from('not-a-key').toString('base64');
|
|
|
|
expect(await codeOf()).toBe('SIGNATURE_INVALID');
|
|
});
|
|
|
|
it('records the refusal so the admin list can keep showing it, and clears it on a later success', async () => {
|
|
const a = signingKey();
|
|
stageSignedArtifact(a.pubB64, a.sign);
|
|
await svc.install('flight-tracker');
|
|
|
|
const b = signingKey();
|
|
stageSignedArtifact(b.pubB64, b.sign);
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow();
|
|
|
|
const blocked = testDb.prepare("SELECT * FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
|
expect(blocked.update_block_code).toBe('SIGNATURE_KEY_CHANGED');
|
|
expect(blocked.update_block_version).toBe('1.0.0');
|
|
// The plugin still RUNS fine on its old code — a blocked update is not a broken
|
|
// runtime, and marking it 'error' would make the health dot lie.
|
|
expect(blocked.status).not.toBe('error');
|
|
// ...and the old key is still pinned. A refused update changes nothing.
|
|
expect(blocked.author_pubkey).toBe(a.pubB64);
|
|
|
|
// The author reverts to the original key: the install succeeds and the block goes.
|
|
stageSignedArtifact(a.pubB64, a.sign);
|
|
await svc.install('flight-tracker');
|
|
const after = testDb.prepare("SELECT * FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
|
expect(after.update_block_code).toBeNull();
|
|
expect(after.update_block_version).toBeNull();
|
|
});
|
|
|
|
it('browse reports signed + the full author key (a public key — the re-trust round-trip needs it exact)', async () => {
|
|
const k = signingKey();
|
|
stageSignedArtifact(k.pubB64, k.sign);
|
|
__clearRegistryCacheForTests();
|
|
|
|
const [entry] = await svc.browse();
|
|
expect(entry.signed).toBe(true);
|
|
expect(entry.authorPublicKey).toBe(k.pubB64);
|
|
});
|
|
|
|
it('browse reports unsigned when the entry has no key', async () => {
|
|
const [entry] = await svc.browse();
|
|
expect(entry.signed).toBe(false);
|
|
expect(entry.authorPublicKey).toBeNull();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Re-trust. These encode the two decisions that make this surface safe rather than merely
|
|
* visible; if they ever go green by accident, the feature is a downgrade hole.
|
|
*
|
|
* D1 — a re-trust moves the pin from one VERIFIED key to another verified key. The
|
|
* obvious implementation (clear author_pubkey to NULL and let TOFU re-pin) would
|
|
* return the plugin to the "never been signed" state, where an UNSIGNED update is
|
|
* legitimately accepted. That trades a key-rotation block for a silent downgrade.
|
|
* D2 — re-trust is offered ONLY for a rotated key. A signature that doesn't verify means
|
|
* the bytes are not what the author signed; there is no story where waving that
|
|
* through is right. Enforced in the SERVICE, not by hiding a button.
|
|
*/
|
|
describe('re-trust (assertRetrustable)', () => {
|
|
/** Install under key `a`, then have the registry offer key `b` — the rotation case. */
|
|
async function rotateTo(b: { pubB64: string; sign: (x: Buffer) => string }, a = signingKey()) {
|
|
stageSignedArtifact(a.pubB64, a.sign);
|
|
await svc.install('flight-tracker');
|
|
testDb.prepare("UPDATE plugins SET source_repo='acme/trek-flight' WHERE id='flight-tracker'").run();
|
|
stageSignedArtifact(b.pubB64, b.sign);
|
|
return a;
|
|
}
|
|
|
|
it('accepts the rotated key the admin was shown, and hands back the entry', async () => {
|
|
const b = signingKey();
|
|
await rotateTo(b);
|
|
await expect(svc.assertRetrustable('flight-tracker', b.pubB64)).resolves.toMatchObject({ authorPublicKey: b.pubB64 });
|
|
});
|
|
|
|
// D1 (TOCTOU): the client echoes back the key the DIALOG rendered. If the entry has been
|
|
// re-keyed AGAIN since then, the admin would be blessing a key they never laid eyes on.
|
|
it('refuses when the entry was re-keyed again between the dialog and the click', async () => {
|
|
const b = signingKey();
|
|
await rotateTo(b);
|
|
const c = signingKey();
|
|
stageSignedArtifact(c.pubB64, c.sign); // the registry moved on to a THIRD key
|
|
__clearRegistryCacheForTests();
|
|
|
|
await expect(svc.assertRetrustable('flight-tracker', b.pubB64)).rejects.toMatchObject({ code: 'RETRUST_KEY_MISMATCH' });
|
|
});
|
|
|
|
// D2, enforced server-side. The UI hiding the button is a convenience, not the control.
|
|
it('refuses a plugin whose key did NOT change (nothing to re-trust)', async () => {
|
|
const a = signingKey();
|
|
stageSignedArtifact(a.pubB64, a.sign);
|
|
await svc.install('flight-tracker');
|
|
testDb.prepare("UPDATE plugins SET source_repo='acme/trek-flight' WHERE id='flight-tracker'").run();
|
|
|
|
await expect(svc.assertRetrustable('flight-tracker', a.pubB64)).rejects.toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' });
|
|
});
|
|
|
|
it('refuses a sideloaded plugin (it sits outside the registry trust model entirely)', async () => {
|
|
const b = signingKey();
|
|
await rotateTo(b);
|
|
testDb.prepare("UPDATE plugins SET source_repo='local:upload' WHERE id='flight-tracker'").run();
|
|
|
|
await expect(svc.assertRetrustable('flight-tracker', b.pubB64)).rejects.toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' });
|
|
});
|
|
|
|
// D1, the one that matters most: a key an admin blesses must still SIGN THE CODE it
|
|
// ships. Re-trusting is not a way to install something unverified.
|
|
it('refuses to install under a re-trusted key that does not actually validate the artifact', async () => {
|
|
const b = signingKey();
|
|
const a = await rotateTo(b);
|
|
// The registry now offers b's key — but the signature is garbage under it.
|
|
(REGISTRY.plugins[0].versions[0] as { signature?: string }).signature = b.sign(Buffer.from('other bytes'));
|
|
__clearRegistryCacheForTests();
|
|
|
|
await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: b.pubB64 })).rejects.toMatchObject({
|
|
code: 'SIGNATURE_INVALID',
|
|
});
|
|
// The old key is STILL pinned — a refused re-trust changes nothing.
|
|
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string };
|
|
expect(row.author_pubkey).toBe(a.pubB64);
|
|
});
|
|
|
|
it('re-pins to the new key once the artifact verifies under it — and NEVER to NULL', async () => {
|
|
const b = signingKey();
|
|
await rotateTo(b);
|
|
__clearRegistryCacheForTests();
|
|
|
|
await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: b.pubB64 })).resolves.toMatchObject({ version: '1.0.0' });
|
|
const row = testDb.prepare("SELECT author_pubkey, update_block_code FROM plugins WHERE id='flight-tracker'").get() as {
|
|
author_pubkey: string | null;
|
|
update_block_code: string | null;
|
|
};
|
|
expect(row.author_pubkey).toBe(b.pubB64);
|
|
expect(row.author_pubkey).not.toBeNull();
|
|
expect(row.update_block_code).toBeNull();
|
|
});
|
|
|
|
// The retrustKey override lifts ONLY the key-change stop. Handing it an unsigned entry
|
|
// must not open the "never been signed" path — that is the downgrade hole D1 exists for.
|
|
it('a retrustKey cannot wave through an unsigned update of a previously-signed plugin', async () => {
|
|
const a = signingKey();
|
|
stageSignedArtifact(a.pubB64, a.sign);
|
|
await svc.install('flight-tracker');
|
|
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
|
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
|
delete (REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey;
|
|
delete (REGISTRY.plugins[0].versions[0] as { signature?: string }).signature;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
|
__clearRegistryCacheForTests();
|
|
|
|
await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: 'anything' })).rejects.toMatchObject({
|
|
code: 'SIGNATURE_MISSING',
|
|
});
|
|
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string | null };
|
|
expect(row.author_pubkey).toBe(a.pubB64); // still pinned, never NULL
|
|
});
|
|
});
|
|
|
|
describe('PluginRegistryService.resolveVersion (latest compatible)', () => {
|
|
const MULTI = {
|
|
schemaVersion: 1,
|
|
plugins: [{
|
|
id: 'multi', name: 'Multi', author: 'a', description: 'many versions', repo: 'a/b', type: 'integration',
|
|
versions: [
|
|
{ version: '2.1.0', gitTag: 'v2.1.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/x', sha256: '', minTrekVersion: '3.4.0' },
|
|
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/y', sha256: '', minTrekVersion: '3.2.0' },
|
|
{ version: '1.5.0', gitTag: 'v1.5.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/z', sha256: '', minTrekVersion: '3.0.0' },
|
|
],
|
|
}],
|
|
};
|
|
const stub = () => { __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => MULTI }) as unknown as Response)); };
|
|
|
|
it('picks the highest version compatible with the running TREK', async () => {
|
|
process.env.APP_VERSION = '3.3.0'; stub();
|
|
expect((await svc.resolveVersion('multi')).version).toBe('2.0.0'); // 2.1.0 needs 3.4.0 > host
|
|
delete process.env.APP_VERSION;
|
|
});
|
|
it('picks the highest version satisfying a semver range', async () => {
|
|
process.env.APP_VERSION = '3.3.0'; stub();
|
|
expect((await svc.resolveVersion('multi', '>=1.0.0 <2.0.0')).version).toBe('1.5.0');
|
|
delete process.env.APP_VERSION;
|
|
});
|
|
it('throws when nothing satisfies the constraint', async () => {
|
|
process.env.APP_VERSION = '3.3.0'; stub();
|
|
await expect(svc.resolveVersion('multi', '>=9.0.0')).rejects.toThrow(RegistryError);
|
|
delete process.env.APP_VERSION;
|
|
});
|
|
it('throws for a plugin not in the registry', async () => {
|
|
stub();
|
|
await expect(svc.resolveVersion('nope')).rejects.toThrow(/not in registry/);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The install must be satisfied by the running TREK — on EVERY path.
|
|
*
|
|
* hostCompatible() used to be reachable only from resolveVersion(), i.e. only when the
|
|
* caller passed a semver constraint. The admin UI's Install button passes neither a
|
|
* version nor a constraint, so the one path an admin actually clicks was the one that
|
|
* skipped the check entirely.
|
|
*/
|
|
describe('TREK-version gating on install', () => {
|
|
const MULTI = {
|
|
schemaVersion: 1,
|
|
plugins: [{
|
|
id: 'multi', name: 'Multi', author: 'a', description: 'many versions', repo: 'a/b', type: 'integration',
|
|
versions: [
|
|
{ version: '2.1.0', gitTag: 'v2.1.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/x', sha256: '', minTrekVersion: '3.4.0', trek: '>=3.4.0 <4.0.0' },
|
|
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/y', sha256: '', minTrekVersion: '3.2.0', trek: '>=3.2.0 <4.0.0' },
|
|
],
|
|
}],
|
|
};
|
|
const stubMulti = () => { __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => MULTI }) as unknown as Response)); };
|
|
|
|
afterEach(() => { delete process.env.APP_VERSION; });
|
|
|
|
/** Stage `version`'s artifact so a successful install has real bytes to fetch. */
|
|
const stageMulti = (version: string, idx: number) => {
|
|
const artifact = makeArtifact({ id: 'multi', name: 'Multi', version, type: 'integration', trek: MULTI.plugins[0].versions[idx].trek });
|
|
const sha = createHash('sha256').update(artifact).digest('hex');
|
|
MULTI.plugins[0].versions[idx].sha256 = sha;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
|
};
|
|
|
|
it('install-latest takes the newest version this TREK can RUN, not the newest published', async () => {
|
|
// 2.1.0 is versions[0] and needs >=3.4.0. The old code fetched it without asking; now
|
|
// the same "install latest" the admin's button sends resolves to 2.0.0 instead.
|
|
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
|
stageMulti('2.0.0', 1);
|
|
await expect(svc.install('multi')).resolves.toMatchObject({ id: 'multi', version: '2.0.0' });
|
|
expect(safeDownload).toHaveBeenCalledWith(MULTI.plugins[0].versions[1].downloadUrl, expect.any(Number));
|
|
});
|
|
|
|
it('refuses install-latest outright when NO published version fits', async () => {
|
|
process.env.APP_VERSION = '3.0.0'; stubMulti(); // both versions need >=3.2.0
|
|
await expect(svc.install('multi')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
|
expect(safeDownload).not.toHaveBeenCalled(); // refused BEFORE a byte is fetched
|
|
});
|
|
|
|
it('refuses an explicitly-pinned version this TREK cannot run', async () => {
|
|
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
|
await expect(svc.install('multi', { version: '2.1.0' })).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
|
expect(safeDownload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('honours an EXCLUSIVE upper bound, which minTrekVersion alone cannot express', async () => {
|
|
// Both versions declare "<4.0.0" and neither carries a maxTrekVersion — under the old
|
|
// min-only check TREK 4 looked compatible with everything.
|
|
process.env.APP_VERSION = '4.0.0'; stubMulti();
|
|
await expect(svc.install('multi')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
|
});
|
|
|
|
it('reports the newest version this TREK CAN run, so the UI can offer it', async () => {
|
|
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
|
const [item] = await svc.browse(true);
|
|
expect(item).toMatchObject({ id: 'multi', latest: '2.1.0', compatible: false, latestCompatible: '2.0.0', hostVersion: '3.3.0' });
|
|
});
|
|
|
|
it('reports plain compatibility when the latest version fits', async () => {
|
|
process.env.APP_VERSION = '3.5.0'; stubMulti();
|
|
const [item] = await svc.browse(true);
|
|
expect(item).toMatchObject({ compatible: true, latestCompatible: '2.1.0' });
|
|
});
|
|
|
|
it('tolerates a legacy entry with NO lower bound at all (minTrekVersion: null)', async () => {
|
|
// The schema allows a null floor, so nothing may assume a string here: the legacy check
|
|
// must skip the bound rather than compare against it, and the "requires TREK …" message
|
|
// must not interpolate a missing bound as the word "null".
|
|
process.env.APP_VERSION = '3.3.0';
|
|
__clearRegistryCacheForTests();
|
|
const noFloor = {
|
|
schemaVersion: 1,
|
|
plugins: [{
|
|
id: 'nofloor', name: 'NoFloor', author: 'a', description: 'no lower bound', repo: 'a/b', type: 'integration',
|
|
versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/x', sha256: '', minTrekVersion: null, maxTrekVersion: '3.0.0' }],
|
|
}],
|
|
};
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => noFloor }) as unknown as Response));
|
|
|
|
// Only the ceiling applies, and TREK 3.3 is past it — refused, with a readable reason.
|
|
const err = await svc.install('nofloor').catch((e) => e);
|
|
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
|
expect(err.message).toContain('<=3.0.0');
|
|
expect(err.message).not.toContain('null');
|
|
});
|
|
|
|
it('falls back to the legacy min/max bounds for an entry published without a range', async () => {
|
|
process.env.APP_VERSION = '3.1.0'; // the fixture entry declares minTrekVersion 3.2.0, no trek
|
|
__clearRegistryCacheForTests();
|
|
await expect(svc.install('flight-tracker')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
|
});
|
|
|
|
it("re-gates on the ARTIFACT's own range — the index metadata is only a pre-download filter", async () => {
|
|
// The registry index says 3.2.0+, but the manifest inside the tarball says it stops at
|
|
// 3.3.0. The bytes are what will run, so the bytes get the last word.
|
|
process.env.APP_VERSION = '3.9.0';
|
|
__clearRegistryCacheForTests();
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', trek: '>=3.2.0 <3.4.0' });
|
|
const sha = createHash('sha256').update(artifact).digest('hex');
|
|
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
|
await expect(svc.install('flight-tracker')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
|
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker'))).toBe(false); // nothing moved into place
|
|
});
|
|
|
|
it('refuses an artifact that declares no range at all', async () => {
|
|
__clearRegistryCacheForTests();
|
|
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', trek: undefined });
|
|
const sha = createHash('sha256').update(artifact).digest('hex');
|
|
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(/missing "trek"/);
|
|
});
|
|
|
|
describe('sideload', () => {
|
|
it('refuses an uploaded archive this TREK cannot run, and cleans up staging', async () => {
|
|
process.env.APP_VERSION = '3.9.0';
|
|
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: '>=3.0.0 <3.5.0' });
|
|
expect(() => svc.stageUpload(bytes)).toThrow(expect.objectContaining({ code: 'TREK_VERSION_INCOMPATIBLE' }));
|
|
const staging = path.join(dataRoot, '.staging');
|
|
// The rejected upload leaves nothing extracted behind.
|
|
expect(fs.existsSync(staging) ? fs.readdirSync(staging) : []).toEqual([]);
|
|
});
|
|
|
|
it('refuses an uploaded archive that declares no range', () => {
|
|
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined });
|
|
expect(() => svc.stageUpload(bytes)).toThrow(/missing "trek"/);
|
|
});
|
|
});
|
|
|
|
it('installWithDependencies installs the version it RESOLVED, not versions[0]', async () => {
|
|
// It resolved a compatible version and then called install() with the range (or with
|
|
// nothing, for the root), which made install() re-pick — landing on versions[0] and
|
|
// discarding the compatible choice entirely.
|
|
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
|
const artifact = makeArtifact({ id: 'multi', name: 'Multi', version: '2.0.0', type: 'integration', trek: '>=3.2.0 <4.0.0' });
|
|
const sha = createHash('sha256').update(artifact).digest('hex');
|
|
MULTI.plugins[0].versions[1].sha256 = sha;
|
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
|
|
|
const res = await svc.installWithDependencies('multi');
|
|
expect(res.installed).toEqual(['multi']);
|
|
expect(safeDownload).toHaveBeenCalledWith(MULTI.plugins[0].versions[1].downloadUrl, expect.any(Number)); // 2.0.0, not 2.1.0
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Leaving the registry trust model (sideload / dev-link) must take the update-block with it.
|
|
*
|
|
* A block describes a refused REGISTRY update. Once an admin hands TREK the bytes directly,
|
|
* the running code is whatever they uploaded — a warning about an author's signing key says
|
|
* nothing about it, and the row would insist an update was blocked over a key that no longer
|
|
* applies to the code in front of them.
|
|
*/
|
|
describe('an update block does not outlive the registry relationship', () => {
|
|
it('sideloading clears a recorded block (and the pinned key)', async () => {
|
|
const a = signingKey();
|
|
stageSignedArtifact(a.pubB64, a.sign);
|
|
await svc.install('flight-tracker');
|
|
|
|
const b = signingKey();
|
|
stageSignedArtifact(b.pubB64, b.sign);
|
|
await expect(svc.install('flight-tracker')).rejects.toThrow(); // rotated key → blocked
|
|
expect(
|
|
(testDb.prepare("SELECT update_block_code FROM plugins WHERE id='flight-tracker'").get() as { update_block_code: string })
|
|
.update_block_code,
|
|
).toBe('SIGNATURE_KEY_CHANGED');
|
|
|
|
// The admin now uploads the plugin by hand.
|
|
const upload = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '9.9.9', type: 'widget', permissions: ['db:own'] });
|
|
svc.commitUpload(svc.stageUpload(upload));
|
|
|
|
const row = testDb.prepare("SELECT source_repo, author_pubkey, update_block_code, update_block_version FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
|
expect(row.source_repo).toBe('local:upload');
|
|
expect(row.author_pubkey).toBeNull(); // out of the trust model — deliberate, and badged
|
|
expect(row.update_block_code).toBeNull();
|
|
expect(row.update_block_version).toBeNull();
|
|
});
|
|
});
|