diff --git a/.project-docs/30-worklog/tasks/20260828-plugin-marketplace-mlm02-9b7c4e1a.md b/.project-docs/30-worklog/tasks/20260828-plugin-marketplace-mlm02-9b7c4e1a.md new file mode 100644 index 0000000..9ee8f3a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260828-plugin-marketplace-mlm02-9b7c4e1a.md @@ -0,0 +1,182 @@ +# Task: Marketplace client and Package Store (MLM-02) + +## Identity + +- Task ID: 20260828-plugin-marketplace-mlm02-9b7c4e1a +- Mode: Feature +- Branch: codex/20260828-plugin-marketplace-mlm02-9b7c4e1a-marketplace-mlm02 +- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm02-9b7c4e1a +- Base commit: c73fcf1d2d2e5dccea6f3b403a3b7c00bdc0b25a +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement ticket MLM-02 from the exact client coordinator frontier + `c73fcf1d2d2e5dccea6f3b403a3b7c00bdc0b25a` in this isolated worktree. +- Own only new `electron/coding-plugins/marketplace-client.ts`, + `electron/coding-plugins/package-store.ts`, + `electron/coding-plugins/account-plugin-cache.ts`, and focused Marketplace, + account-cache, download, and Package Store tests plus this task record. +- Consume the integrated MLM-01 parser/signature/trust primitives and frozen + WSM-03/WSM-04 Marketplace DTO contract; do not wire registry, resolver, Pi, + Host routes, Renderer, project state, server, Operations, or Release B. + +## Intent And Constraints + +- Main derives the current account from the existing session boundary; Renderer + never supplies account IDs, Bearer tokens, admissions, or package paths. +- Parse closed catalog/detail/Library/resolve/download DTOs with bounded request + body/deadline handling, composite ETag and stale metadata, and exactly one + authenticated 401 refresh/retry. Preserve a stable resolve request identity + for one logical sync across retry/restart; changed logical requests receive a + new identity. +- Package installation is a controlled temporary-file/extraction transaction: + verify descriptor, code-owned key, Ed25519 signature, size, SHA-256, exact + manifest, plugin/version/client range, then atomically switch the immutable + release directory and index. Every interruption or failure preserves the old + release/index. +- Account Library and Release Admission projections are session/account-scoped + and never enter shared `index.json`; logout/account switch invalidates them. + Cleanup must respect active workers and every account snapshot, with no token, + project, TP, or arbitrary Renderer path in the shared index. +- Stable automatic update and explicit beta selection are separate; no hot swap + for current workers. Production official-key absence remains an activation hold; + no private key, test key, environment trust override, or Release B runtime is + added. + +## Project Context Loaded + +Read: +- `C:\Users\7brot\.agents\skills\implement-spec\SKILL.md` +- `C:\Users\7brot\.codex\skills\maintain-project-docs\SKILL.md` +- `AGENTS.md`, `.project-docs/05-agent-entry/read-before-planning.md`, + `planning-gate.md`, and `read-before-coding.md` +- `.project-docs/05-agent-entry/memory-index.md`, + `.project-docs/00-brief/project-positioning.md`, + `.project-docs/30-worklog/current-state.md`, this task record, + `.project-docs/10-decisions/decision-index.md`, + `.project-docs/20-architecture/system-overview.md`, `module-map.md`, + `data-flow.md`, `.project-docs/40-domain/business-rules.md`, `glossary.md`, + `.project-docs/50-evidence/evidence-index.md`, + `.project-docs/60-reflection/reflection-index.md`, + `.project-docs/80-commitments/commitments.md`, and + `.project-docs/90-maintenance/stale-items.md` +- implementation spec `2026-08-28-makelore-plugin-marketplace-implementation-spec.md` + §§3–6, 7.2–7.3, 8.1, 10.1–10.3, 13.2–13.3 and 16; ticket graph MLM-02; + accepted design sections 6, 8.2–8.3, 9.1, 11.2–11.5, 14–15, 18.3, and + 19.2–20; and the frozen server DTO/API source at `f9c41bd...`. +- Peer records for MLM-01, the client coordinator, WSM-03, and WSM-04 (read-only). + +Relevant understanding: +- The project is an Electron Main/Renderer client; Main owns authentication, + filesystem, network, and package trust while Renderer consumes safe projections. +- Marketplace Release A separates publication, Library acquisition, device + installation, project enablement, Agent assignment, runtime authorization, and + billing. Acquire/download/install/enable/assignment must not write Token Points. +- The exact server contract has anonymous catalog/detail, authenticated Library, + digest-idempotent stable/beta resolve, account-bound expiring admissions, and + admission-authorized download grants/content. `removed_at` tombstones remain in + Library snapshots; `system_included` Data Service bypasses Library/admission. +- MLM-01 is integrated at the dispatch base and provides exact schema-v1/v2 + parsing, descriptor bytes, SemVer checks, Ed25519 verification, and a code-owned + fail-closed trust store. The official production public key is not supplied. +- Current canonical memory predates Marketplace; the committed spec/design, + frozen server DTOs, exact frontier, and peer task outcomes are authoritative. + +Task context: +- Task ID: `20260828-plugin-marketplace-mlm02-9b7c4e1a` +- Mode: Feature +- Branch: `codex/20260828-plugin-marketplace-mlm02-9b7c4e1a-marketplace-mlm02` +- Worktree: `D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm02-9b7c4e1a` +- Base commit: `c73fcf1d2d2e5dccea6f3b403a3b7c00bdc0b25a` +- Other active local tasks: client coordinator and completed MLM-01 peer; + separate server/Operations WSM-03/WSM-04 peers own disjoint files. +- Overlap/semantic assessment: no unresolved conflict. MLM-01 owns parser/trust; + MLM-02 owns only the three Main modules and focused tests; MLM-03 owns resolver, + routes, Pi/lifecycle. WSM-03/04 DTOs are frozen read-only inputs. + +Gate result: +- Concurrent Task Gate: Passed. `check_project_docs.py` passed; `task_context.py + start` created the isolated owner worktree, and `status --json` matches this + task ID, owner, feature mode, absolute worktree, branch, and exact base. +- Planning Gate: Passed. Required memory, spec/ticket/design, frozen server DTO, + and peer scopes were read; no unresolved semantic conflict or ownership overlap + blocks this plan. Coordinator and user root worktrees remain untouched. + +## Implementation Plan + +1. Inspect the existing Main HTTP/session, safe file/archive, package parser, and + test seams without editing; write red focused contract tests for bounded DTO + parsing, auth refresh/retry, stable request identity, account invalidation, and + atomic Package Store failure preservation. +2. Implement the smallest cohesive authenticated MarketplaceClient with closed + response parsers, cache validators/stale state, session-derived account, one + 401 retry, and logical resolve identity management. +3. Implement account cache and Package Store with controlled temp paths, exact + MLM-01 descriptor/signature/manifest verification, atomic immutable index + switching, account/worker-aware cleanup, and stable-vs-beta update policy. +4. Run focused tests and interruption/account/path regression matrices, typecheck, + scoped/full lint, proportionate build, then task-aware diff/doc gates. Complete + the task and make exactly one clean source commit with sole parent the exact + dispatch base. + +## Outcome + +- Implemented MLM-02 from the exact client frontier. Main now owns closed + Marketplace catalog/detail/Library/resolve/download parsing, bounded requests, + composite cache metadata, stale projections, one-refresh authentication retry, + and stable logical resolve identity. +- Added an in-memory account/epoch cache and a device-only immutable Package Store + with controlled temporary extraction, schema-2 Skill-only manifest checks, + descriptor/client-range/size/SHA/Ed25519 verification, atomic release-directory + and index replacement, old-release preservation, stable-versus-explicit-beta + selection, and account/worker-aware cleanup hooks. +- Scope remains limited to the three new Main modules, focused tests, and this + task record. No registry, effective resolver, Pi, Host route, Renderer, + project-file, server, Operations, Token Point, hosted-runtime, or Release B + code was changed. The official production public key is not present, so + production activation remains an explicit hold; tests use ephemeral injected + keys only and contain no production private key. + +## Verification + +- `corepack pnpm install --frozen-lockfile`: passed; no dependency or lockfile + change. +- Initial focused red checkpoint: collection failed because the new owned + `account-plugin-cache` module was absent; no pre-existing implementation was + accepted as a fallback. +- `corepack pnpm exec vitest run tests/unit/coding-plugin-marketplace-client.test.ts + --maxWorkers=1`: 14 passed. +- `corepack pnpm exec vitest run tests/unit/coding-plugin-manifest.test.ts + tests/unit/coding-plugin-marketplace-contract.test.ts + tests/unit/coding-plugin-marketplace-client.test.ts --maxWorkers=1`: 43 passed. +- `corepack pnpm run typecheck`: passed. +- Owned ESLint over the three Main modules and focused test: passed. +- `corepack pnpm run lint:check`: 0 errors, 5 pre-existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `corepack pnpm run build:vite`: passed; only existing dynamic-import and chunk + size warnings. +- Full Vitest: 1,727 passed, 2 skipped; the separate coding-chat pressure test + passed 1/1. +- Focused failure matrix covers 401 refresh/retry, bounded/closed DTO parsing, + detail release projection, stable resolve identity, account A/B cache + isolation, download interruption, bad signature, invalid ZIP extraction, + index replacement failure, incompatible client, account switch, and path + containment; old release/index retention is asserted for the covered install + failure points. +- `git diff --check`: passed; exactly the five owned paths are changed. +- Task-aware `check_doc_drift.py`: passed. `task_context.py complete` returned + `READY_FOR_INTEGRATION` for this task and exact base/worktree/branch. + +## Follow-ups + +- Production activation remains blocked until the official Ed25519 public key is + supplied through the code-owned trust store and the corresponding production + private key is available only from deployment secret management. +- MLM-03 may consume these Main modules for the effective resolver and local + routes; no runtime integration is included here. + +## Promotion Candidates + +- None. No canonical project-memory change is proposed. diff --git a/electron/coding-plugins/account-plugin-cache.ts b/electron/coding-plugins/account-plugin-cache.ts new file mode 100644 index 0000000..e44a4e6 --- /dev/null +++ b/electron/coding-plugins/account-plugin-cache.ts @@ -0,0 +1,198 @@ +/** + * Session/account-scoped Marketplace projections. + * + * The package index is deliberately device-scoped and contains immutable + * release facts only. Library rows and release admissions live here instead, + * keyed by the opaque Main-owned account binding and its session epoch. + */ + +export interface AccountBinding { + readonly accountKey: string; + readonly epoch: number; +} + +export type MarketplaceChannel = 'stable' | 'beta'; +export type MarketplaceResolveAction = 'keep' | 'install' | 'update' | 'unavailable'; + +export interface MarketplaceLibraryEntry { + readonly pluginId: string; + readonly title: string; + readonly summary: string; + readonly category: string; + readonly acquisition: 'free' | 'system_included'; + readonly acquisitionMode: 'system_included' | 'user_acquired'; + readonly catalogStatus: 'active' | 'retired'; + readonly runtimeStatus: 'enabled' | 'suspended'; + readonly acquiredAt: string | null; + readonly removedAt: string | null; + readonly stableVersion: string | null; + readonly betaVersion: string | null; +} + +export interface MarketplaceLibrarySnapshot { + readonly items: readonly MarketplaceLibraryEntry[]; + readonly total: number; + readonly stale: boolean; + readonly fetchedAt: number; +} + +export interface MarketplaceResolveItem { + readonly pluginId: string; + readonly action: MarketplaceResolveAction; + readonly releaseId?: string | null; + readonly version?: string | null; + readonly sha256?: string | null; + readonly sizeBytes?: number | null; + readonly releaseAdmissionId?: string | null; + readonly expiresAt?: string | null; + readonly channel?: MarketplaceChannel | null; + readonly reason?: string | null; +} + +export interface MarketplaceResolveSnapshot { + readonly resolveRequestId: string; + readonly resolveRequestDigest: string; + readonly items: readonly MarketplaceResolveItem[]; + readonly catalogGeneration: number; + readonly etag: string | null; + readonly stale: boolean; +} + +export interface AccountPluginCacheRecord { + readonly binding: AccountBinding; + readonly library: MarketplaceLibrarySnapshot | null; + readonly resolves: ReadonlyMap; +} + +function assertBinding(binding: AccountBinding): void { + if (typeof binding.accountKey !== 'string' || binding.accountKey.length === 0 + || binding.accountKey.length > 512 || !Number.isSafeInteger(binding.epoch) + || binding.epoch < 0) { + throw new TypeError('Marketplace account binding is invalid'); + } +} + +function bindingId(binding: AccountBinding): string { + assertBinding(binding); + return `${binding.accountKey}\u0000${binding.epoch}`; +} + +function clone(value: T): T { + return structuredClone(value); +} + +/** + * In-memory account cache. Keeping this cache in Main memory prevents + * admissions and Library state from entering the shared package index or a + * project file. A session change invalidates every account snapshot. + */ +export class AccountPluginCache { + private readonly records = new Map; + }>(); + + getLibrary(binding: AccountBinding): MarketplaceLibrarySnapshot | null { + const record = this.records.get(bindingId(binding)); + return record?.library ? clone(record.library) : null; + } + + setLibrary(binding: AccountBinding, snapshot: MarketplaceLibrarySnapshot): void { + const key = bindingId(binding); + const record = this.records.get(key) ?? { + binding: { accountKey: binding.accountKey, epoch: binding.epoch }, + library: null, + resolves: new Map(), + }; + record.library = clone(snapshot); + this.records.set(key, record); + } + + markLibraryStale(binding: AccountBinding): MarketplaceLibrarySnapshot | null { + const key = bindingId(binding); + const record = this.records.get(key); + if (!record?.library) return null; + record.library = { ...record.library, stale: true }; + return clone(record.library); + } + + getResolve(binding: AccountBinding, logicalKey: string): MarketplaceResolveSnapshot | null { + if (typeof logicalKey !== 'string' || logicalKey.length === 0) return null; + const record = this.records.get(bindingId(binding)); + const snapshot = record?.resolves.get(logicalKey); + return snapshot ? clone(snapshot) : null; + } + + setResolve( + binding: AccountBinding, + logicalKey: string, + snapshot: MarketplaceResolveSnapshot, + ): void { + if (typeof logicalKey !== 'string' || logicalKey.length === 0 || logicalKey.length > 512) { + throw new TypeError('Marketplace resolve cache key is invalid'); + } + const key = bindingId(binding); + const record = this.records.get(key) ?? { + binding: { accountKey: binding.accountKey, epoch: binding.epoch }, + library: null, + resolves: new Map(), + }; + record.resolves.set(logicalKey, clone(snapshot)); + this.records.set(key, record); + } + + markResolveStale(binding: AccountBinding, logicalKey: string): MarketplaceResolveSnapshot | null { + const key = bindingId(binding); + const record = this.records.get(key); + const snapshot = record?.resolves.get(logicalKey); + if (!record || !snapshot) return null; + const stale = { ...snapshot, stale: true }; + record.resolves.set(logicalKey, stale); + return clone(stale); + } + + getRecord(binding: AccountBinding): AccountPluginCacheRecord | null { + const record = this.records.get(bindingId(binding)); + if (!record) return null; + return { + binding: { ...record.binding }, + library: record.library ? clone(record.library) : null, + resolves: new Map([...record.resolves.entries()].map(([key, value]) => [key, clone(value)])), + }; + } + + /** Release IDs retained by all live account snapshots. */ + referencedReleaseIds(): ReadonlySet { + const result = new Set(); + for (const record of this.records.values()) { + for (const snapshot of record.resolves.values()) { + for (const item of snapshot.items) { + if (item.releaseId) result.add(item.releaseId); + } + } + } + return result; + } + + clearAccount(binding: AccountBinding): void { + assertBinding(binding); + for (const [key, record] of this.records.entries()) { + if (record.binding.accountKey === binding.accountKey) this.records.delete(key); + } + } + + invalidateAll(): void { + this.records.clear(); + } + + clear(): void { + this.invalidateAll(); + } + + get size(): number { + return this.records.size; + } +} + +export const accountBindingKey = bindingId; diff --git a/electron/coding-plugins/marketplace-client.ts b/electron/coding-plugins/marketplace-client.ts new file mode 100644 index 0000000..8bf9e22 --- /dev/null +++ b/electron/coding-plugins/marketplace-client.ts @@ -0,0 +1,1140 @@ +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { WORKS_SQUARE_CONFIG } from '../api/works-config'; +import { + getValidWorksSquareAccessToken, + getWorksSquareAccountBinding, + subscribeWorksSquareSession, +} from '../services/works-square-session'; +import { proxyAwareFetch, fetchWithDeadline } from '../utils/proxy-fetch'; +import { + AccountPluginCache, + type AccountBinding, + type MarketplaceChannel, + type MarketplaceLibraryEntry, + type MarketplaceLibrarySnapshot, + type MarketplaceResolveItem, + type MarketplaceResolveSnapshot, +} from './account-plugin-cache'; +import { isValidSemVer } from './release-descriptor'; + +export type { + AccountBinding, + MarketplaceChannel, + MarketplaceLibraryEntry, + MarketplaceLibrarySnapshot, + MarketplaceResolveAction, + MarketplaceResolveItem, + MarketplaceResolveSnapshot, +} from './account-plugin-cache'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_RESPONSE_BYTES = 2 * 1024 * 1024; +const DEFAULT_REQUEST_BYTES = 512 * 1024; +const DEFAULT_ARTIFACT_BYTES = 16 * 1024 * 1024; +const MAX_PLUGIN_ID = 128; +const MAX_RELEASE_ID = 128; +const MAX_TEXT = 32_000; +const MAX_CURSOR = 512; +const MAX_TAGS = 64; +const MAX_OPERATIONS = 256; +const MAX_PERMISSIONS = 128; +const MAX_RESOLVE_ITEMS = 256; +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const REQUEST_ID_PATTERN = /^[\x21-\x7e]{1,128}$/u; +const ETAG_PATTERN = /^"plugins-(\d+)-tp-([A-Za-z0-9._-]{1,128})"$/u; + +type UnknownRecord = Record; +type FetchImplementation = (input: string | URL, init?: RequestInit) => Promise; + +export interface CatalogQuery { + readonly query?: string; + readonly category?: string; + readonly featured?: boolean; + readonly limit?: number; + readonly cursor?: string; +} + +export interface MarketplaceBilling { + readonly mode: 'included' | 'platform_metered'; + readonly status: 'billing_unavailable' | null; + readonly notice: string; + readonly entitlementScope: string | null; + readonly unitName: string | null; + readonly unitSize: number | null; + readonly ratePoints: string | null; + readonly minimumChargePoints: string | null; + readonly roundingMode: 'ceil' | null; + readonly pricingVersion: string | null; +} + +export interface MarketplaceOperation { + readonly capabilityId: string; + readonly operation: string; + readonly executionMode: 'synchronous' | 'job'; + readonly billing: MarketplaceBilling; + readonly enabled: boolean; +} + +export interface MarketplaceRelease { + readonly releaseId: string; + readonly pluginId: string; + readonly version: string; + readonly packageSchemaVersion: number; + readonly contractVersion: number; + readonly minMakeloreVersion: string; + readonly maxMakeloreVersion: string | null; + readonly deliveryKind: 'bundled' | 'artifact'; + readonly artifactSha256: string | null; + readonly artifactSizeBytes: number | null; + readonly signingKeyId: string | null; + readonly descriptorSignature: string | null; + readonly publishedAt: string | null; +} + +export interface MarketplaceCatalogItem { + readonly pluginId: string; + readonly title: string; + readonly summary: string; + readonly category: string; + readonly tags: readonly string[]; + readonly providerDisplayName: string; + readonly runtimeKind: 'skill_only' | 'bundled_typed' | 'platform_hosted'; + readonly runtimeStatus: 'enabled' | 'suspended'; + readonly acquisition: 'free' | 'system_included'; + readonly usageBilling: 'included' | 'token_point' | 'mixed'; + readonly includedOperationCount: number; + readonly meteredOperationCount: number; + readonly stableVersion: string | null; + readonly betaVersion: string | null; +} + +export interface CatalogPage { + readonly items: readonly MarketplaceCatalogItem[]; + readonly nextCursor: string | null; + readonly total: number; + readonly catalogGeneration: number; + readonly etag: string; + readonly pricingVersionId: string | null; + readonly stale: boolean; + readonly fetchedAt: number; +} + +export interface PluginDetail extends MarketplaceCatalogItem { + readonly descriptionMarkdown: string; + readonly permissions: readonly string[]; + readonly operations: readonly MarketplaceOperation[]; + readonly stableRelease: MarketplaceRelease | null; + readonly betaRelease: MarketplaceRelease | null; + readonly etag: string; + readonly pricingVersionId: string | null; + readonly stale: boolean; + readonly fetchedAt: number; +} + +export interface InstalledReleaseInput { + readonly pluginId: string; + readonly releaseId: string; + readonly sha256: string; +} + +export interface ResolveRequest { + /** Persisted by a caller when a long-lived sync operation is resumed. */ + readonly resolveRequestId?: string; + /** Alias accepted for callers that use the server terminology directly. */ + readonly requestId?: string; + readonly resolveRequestDigest?: string; + readonly makeloreVersion: string; + readonly channel?: MarketplaceChannel; + readonly installed: readonly InstalledReleaseInput[]; +} + +export interface DownloadRequest { + readonly releaseId: string; + readonly releaseAdmissionId: string; +} + +export interface DownloadGrant { + readonly releaseAdmissionId: string; + readonly releaseId: string; + readonly pluginId: string; + readonly version: string; + readonly packageSchemaVersion: number; + readonly contractVersion: number; + readonly minMakeloreVersion: string; + readonly maxMakeloreVersion: string | null; + readonly sizeBytes: number; + readonly sha256: string; + readonly signingKeyId: string; + readonly descriptorSignature: string; + readonly expiresAt: string; + readonly contentUrl: string; +} + +export interface MarketplaceSessionPort { + getAccessToken(options?: { readonly forceRefresh?: boolean }): Promise; + getAccountBinding(): AccountBinding | null; + subscribe?(listener: () => void): () => void; +} + +export interface MarketplaceClientOptions { + readonly fetchImpl?: FetchImplementation; + readonly apiBaseUrl?: string; + readonly requestTimeoutMs?: number; + readonly maxResponseBytes?: number; + readonly maxRequestBytes?: number; + readonly maxArtifactBytes?: number; + readonly now?: () => number; + readonly session?: MarketplaceSessionPort; + readonly getAccessToken?: MarketplaceSessionPort['getAccessToken']; + readonly getAccountBinding?: MarketplaceSessionPort['getAccountBinding']; + readonly subscribeSession?: (listener: () => void) => () => void; + readonly accountCache?: AccountPluginCache; +} + +export type MarketplaceErrorCode = + | 'marketplace_auth_required' + | 'marketplace_account_changed' + | 'marketplace_request_invalid' + | 'marketplace_request_failed' + | 'marketplace_response_invalid' + | 'marketplace_response_too_large' + | 'marketplace_download_invalid' + | 'marketplace_beta_selection_required'; + +export class MarketplaceClientError extends Error { + constructor( + readonly code: MarketplaceErrorCode, + readonly status = 0, + message: string = code, + ) { + super(message); + this.name = 'MarketplaceClientError'; + } +} + +interface CacheMetadata { + readonly etag: string; + readonly generation: number; + readonly pricingVersionId: string | null; +} + +interface RequestResult { + readonly status: number; + readonly headers: Headers; + readonly value: T | null; + readonly notModified: boolean; +} + +function isRecord(value: unknown): value is UnknownRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function fail(code: MarketplaceErrorCode, message: string = code, status = 0): never { + throw new MarketplaceClientError(code, status, message); +} + +function exactKeys( + value: UnknownRecord, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail('marketplace_response_invalid', `unknown field: ${key.slice(0, 128)}`); + } + for (const key of required) { + if (!(key in value)) fail('marketplace_response_invalid', `missing field: ${key}`); + } +} + +function stringValue(value: unknown, field: string, max = MAX_TEXT): string { + if (typeof value !== 'string' || value.length === 0 || value.length > max) { + fail('marketplace_response_invalid', `invalid ${field}`); + } + return value; +} + +function nullableString(value: unknown, field: string, max = MAX_TEXT): string | null { + return value === null ? null : stringValue(value, field, max); +} + +function integerValue(value: unknown, field: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + fail('marketplace_response_invalid', `invalid ${field}`); + } + return value as number; +} + +function booleanValue(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') fail('marketplace_response_invalid', `invalid ${field}`); + return value; +} + +function idValue(value: unknown, field: string): string { + const result = stringValue(value, field, MAX_PLUGIN_ID); + if (!PLUGIN_ID_PATTERN.test(result)) fail('marketplace_response_invalid', `invalid ${field}`); + return result; +} + +function releaseIdValue(value: unknown, field: string, max = MAX_RELEASE_ID): string { + const result = stringValue(value, field, max); + if (!RELEASE_ID_PATTERN.test(result)) fail('marketplace_response_invalid', `invalid ${field}`); + return result; +} + +function shaValue(value: unknown, field: string): string { + const result = stringValue(value, field, 64); + if (!SHA256_PATTERN.test(result)) fail('marketplace_response_invalid', `invalid ${field}`); + return result; +} + +function dateValue(value: unknown, field: string): string | null { + if (value === null) return null; + const result = stringValue(value, field, 80); + if (!Number.isFinite(Date.parse(result))) fail('marketplace_response_invalid', `invalid ${field}`); + return result; +} + +function semverValue(value: unknown, field: string): string { + const result = stringValue(value, field, 64); + if (!isValidSemVer(result)) fail('marketplace_response_invalid', `invalid ${field}`); + return result; +} + +function clone(value: T): T { + return structuredClone(value); +} + +function normalizedBinding(value: AccountBinding | null): AccountBinding | null { + if (!value || typeof value.accountKey !== 'string' || value.accountKey.length === 0 + || value.accountKey.length > 512 || !Number.isSafeInteger(value.epoch) || value.epoch < 0) { + return null; + } + return { accountKey: value.accountKey, epoch: value.epoch }; +} + +function parseBilling(value: unknown): MarketplaceBilling { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid billing'); + exactKeys(record, ['mode', 'status', 'notice', 'entitlement_scope', 'unit_name', 'unit_size', 'rate_points', 'minimum_charge_points', 'rounding_mode', 'pricing_version']); + if (record.mode !== 'included' && record.mode !== 'platform_metered') fail('marketplace_response_invalid', 'invalid billing mode'); + if (record.status !== null && record.status !== 'billing_unavailable') fail('marketplace_response_invalid', 'invalid billing status'); + if (record.rounding_mode !== null && record.rounding_mode !== 'ceil') fail('marketplace_response_invalid', 'invalid billing rounding'); + const unitSize = record.unit_size === null ? null : integerValue(record.unit_size, 'unit_size', 1); + return { + mode: record.mode, + status: record.status, + notice: stringValue(record.notice, 'notice', 160), + entitlementScope: nullableString(record.entitlement_scope, 'entitlement_scope', 64), + unitName: nullableString(record.unit_name, 'unit_name', 80), + unitSize, + ratePoints: nullableString(record.rate_points, 'rate_points', 32), + minimumChargePoints: nullableString(record.minimum_charge_points, 'minimum_charge_points', 32), + roundingMode: record.rounding_mode, + pricingVersion: nullableString(record.pricing_version, 'pricing_version', 36), + }; +} + +function parseOperation(value: unknown): MarketplaceOperation { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid operation'); + exactKeys(record, ['capability_id', 'operation', 'execution_mode', 'billing', 'enabled']); + if (record.execution_mode !== 'synchronous' && record.execution_mode !== 'job') fail('marketplace_response_invalid', 'invalid execution mode'); + return { + capabilityId: stringValue(record.capability_id, 'capability_id', 128), + operation: stringValue(record.operation, 'operation', 128), + executionMode: record.execution_mode, + billing: parseBilling(record.billing), + enabled: booleanValue(record.enabled, 'enabled'), + }; +} + +function parseRelease(value: unknown): MarketplaceRelease { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid release'); + exactKeys(record, [ + 'release_id', 'plugin_id', 'version', 'package_schema_version', 'contract_version', + 'min_makelore_version', 'max_makelore_version', 'delivery_kind', 'artifact_sha256', + 'artifact_size_bytes', 'signing_key_id', 'descriptor_signature', 'published_at', + ]); + if (record.delivery_kind !== 'bundled' && record.delivery_kind !== 'artifact') fail('marketplace_response_invalid', 'invalid delivery kind'); + const artifactSha256 = record.artifact_sha256 === null ? null : shaValue(record.artifact_sha256, 'artifact_sha256'); + const artifactSizeBytes = record.artifact_size_bytes === null ? null : integerValue(record.artifact_size_bytes, 'artifact_size_bytes', 1, DEFAULT_ARTIFACT_BYTES); + if (record.delivery_kind === 'artifact' && (artifactSha256 === null || artifactSizeBytes === null)) fail('marketplace_response_invalid', 'artifact release is missing artifact metadata'); + return { + releaseId: releaseIdValue(record.release_id, 'release_id'), + pluginId: idValue(record.plugin_id, 'plugin_id'), + version: semverValue(record.version, 'version'), + packageSchemaVersion: integerValue(record.package_schema_version, 'package_schema_version', 1), + contractVersion: integerValue(record.contract_version, 'contract_version', 1), + minMakeloreVersion: semverValue(record.min_makelore_version, 'min_makelore_version'), + maxMakeloreVersion: record.max_makelore_version === null ? null : semverValue(record.max_makelore_version, 'max_makelore_version'), + deliveryKind: record.delivery_kind, + artifactSha256, + artifactSizeBytes, + signingKeyId: nullableString(record.signing_key_id, 'signing_key_id', 128), + descriptorSignature: nullableString(record.descriptor_signature, 'descriptor_signature', 256), + publishedAt: dateValue(record.published_at, 'published_at'), + }; +} + +function parseCatalogItem(value: unknown, validateKeys = true): MarketplaceCatalogItem { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid catalog item'); + if (validateKeys) { + exactKeys(record, [ + 'plugin_id', 'title', 'summary', 'category', 'tags', 'provider_display_name', + 'runtime_kind', 'runtime_status', 'acquisition', 'usage_billing', + 'included_operation_count', 'metered_operation_count', 'stable_version', 'beta_version', + ]); + } + if (!Array.isArray(record.tags) || record.tags.length > MAX_TAGS) fail('marketplace_response_invalid', 'invalid tags'); + if (record.runtime_kind !== 'skill_only' && record.runtime_kind !== 'bundled_typed' && record.runtime_kind !== 'platform_hosted') fail('marketplace_response_invalid', 'invalid runtime kind'); + if (record.runtime_status !== 'enabled' && record.runtime_status !== 'suspended') fail('marketplace_response_invalid', 'invalid runtime status'); + if (record.acquisition !== 'free' && record.acquisition !== 'system_included') fail('marketplace_response_invalid', 'invalid acquisition'); + if (record.usage_billing !== 'included' && record.usage_billing !== 'token_point' && record.usage_billing !== 'mixed') fail('marketplace_response_invalid', 'invalid usage billing'); + return { + pluginId: idValue(record.plugin_id, 'plugin_id'), + title: stringValue(record.title, 'title', 255), + summary: stringValue(record.summary, 'summary', 4_000), + category: stringValue(record.category, 'category', 128), + tags: record.tags.map((tag, index) => stringValue(tag, `tags[${index}]`, 128)), + providerDisplayName: stringValue(record.provider_display_name, 'provider_display_name', 128), + runtimeKind: record.runtime_kind, + runtimeStatus: record.runtime_status, + acquisition: record.acquisition, + usageBilling: record.usage_billing, + includedOperationCount: integerValue(record.included_operation_count, 'included_operation_count'), + meteredOperationCount: integerValue(record.metered_operation_count, 'metered_operation_count'), + stableVersion: record.stable_version === null ? null : semverValue(record.stable_version, 'stable_version'), + betaVersion: record.beta_version === null ? null : semverValue(record.beta_version, 'beta_version'), + }; +} + +function parseCatalogPage(value: unknown): Omit { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid catalog page'); + exactKeys(record, ['items', 'next_cursor', 'total', 'catalog_generation']); + if (!Array.isArray(record.items) || record.items.length > 100) fail('marketplace_response_invalid', 'invalid catalog items'); + return { + items: record.items.map(parseCatalogItem), + nextCursor: nullableString(record.next_cursor, 'next_cursor', MAX_CURSOR), + total: integerValue(record.total, 'total'), + catalogGeneration: integerValue(record.catalog_generation, 'catalog_generation', 1), + }; +} + +function parsePluginDetail(value: unknown): Omit { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid plugin detail'); + exactKeys(record, [ + 'plugin_id', 'title', 'summary', 'category', 'tags', 'provider_display_name', + 'runtime_kind', 'runtime_status', 'acquisition', 'usage_billing', + 'included_operation_count', 'metered_operation_count', 'stable_version', 'beta_version', + 'description_markdown', 'permissions', 'operations', 'stable_release', 'beta_release', + ]); + const base = parseCatalogItem(record, false); + if (!Array.isArray(record.permissions) || record.permissions.length > MAX_PERMISSIONS) fail('marketplace_response_invalid', 'invalid permissions'); + if (!Array.isArray(record.operations) || record.operations.length > MAX_OPERATIONS) fail('marketplace_response_invalid', 'invalid operations'); + return { + ...base, + descriptionMarkdown: stringValue(record.description_markdown, 'description_markdown', 32_000), + permissions: record.permissions.map((permission, index) => stringValue(permission, `permissions[${index}]`, 128)), + operations: record.operations.map(parseOperation), + stableRelease: record.stable_release === null ? null : parseRelease(record.stable_release), + betaRelease: record.beta_release === null ? null : parseRelease(record.beta_release), + }; +} + +function parseLibraryEntry(value: unknown): MarketplaceLibraryEntry { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid Library entry'); + exactKeys(record, [ + 'plugin_id', 'title', 'summary', 'category', 'acquisition', 'acquisition_mode', + 'catalog_status', 'runtime_status', 'acquired_at', 'removed_at', 'stable_version', 'beta_version', + ]); + if (record.acquisition !== 'free' && record.acquisition !== 'system_included') fail('marketplace_response_invalid', 'invalid Library acquisition'); + if (record.acquisition_mode !== 'system_included' && record.acquisition_mode !== 'user_acquired') fail('marketplace_response_invalid', 'invalid Library acquisition mode'); + if (record.catalog_status !== 'active' && record.catalog_status !== 'retired') fail('marketplace_response_invalid', 'invalid catalog status'); + if (record.runtime_status !== 'enabled' && record.runtime_status !== 'suspended') fail('marketplace_response_invalid', 'invalid Library runtime status'); + return { + pluginId: idValue(record.plugin_id, 'plugin_id'), + title: stringValue(record.title, 'title', 255), + summary: stringValue(record.summary, 'summary', 4_000), + category: stringValue(record.category, 'category', 128), + acquisition: record.acquisition, + acquisitionMode: record.acquisition_mode, + catalogStatus: record.catalog_status, + runtimeStatus: record.runtime_status, + acquiredAt: dateValue(record.acquired_at, 'acquired_at'), + removedAt: dateValue(record.removed_at, 'removed_at'), + stableVersion: record.stable_version === null ? null : semverValue(record.stable_version, 'stable_version'), + betaVersion: record.beta_version === null ? null : semverValue(record.beta_version, 'beta_version'), + }; +} + +function parseLibrary(value: unknown): Omit { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid Library snapshot'); + exactKeys(record, ['items', 'total']); + if (!Array.isArray(record.items) || record.items.length > 256) fail('marketplace_response_invalid', 'invalid Library items'); + return { items: record.items.map(parseLibraryEntry), total: integerValue(record.total, 'total') }; +} + +function parseResolveItem(value: unknown): MarketplaceResolveItem { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid resolve item'); + exactKeys(record, ['plugin_id', 'action', 'release_id', 'version', 'sha256', 'size_bytes', 'release_admission_id', 'expires_at', 'channel', 'reason']); + if (record.action !== 'keep' && record.action !== 'install' && record.action !== 'update' && record.action !== 'unavailable') fail('marketplace_response_invalid', 'invalid resolve action'); + if (record.channel !== null && record.channel !== 'stable' && record.channel !== 'beta') fail('marketplace_response_invalid', 'invalid resolve channel'); + return { + pluginId: idValue(record.plugin_id, 'plugin_id'), + action: record.action, + releaseId: record.release_id === null ? null : releaseIdValue(record.release_id, 'release_id'), + version: record.version === null ? null : semverValue(record.version, 'version'), + sha256: record.sha256 === null ? null : shaValue(record.sha256, 'sha256'), + sizeBytes: record.size_bytes === null ? null : integerValue(record.size_bytes, 'size_bytes', 1, DEFAULT_ARTIFACT_BYTES), + releaseAdmissionId: record.release_admission_id === null ? null : releaseIdValue(record.release_admission_id, 'release_admission_id'), + expiresAt: dateValue(record.expires_at, 'expires_at'), + channel: record.channel, + reason: nullableString(record.reason, 'reason', 160), + }; +} + +function parseResolve(value: unknown): Omit { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid resolve snapshot'); + exactKeys(record, ['resolve_request_id', 'resolve_request_digest', 'items', 'catalog_generation']); + if (!REQUEST_ID_PATTERN.test(stringValue(record.resolve_request_id, 'resolve_request_id', 128))) fail('marketplace_response_invalid', 'invalid resolve request ID'); + if (!Array.isArray(record.items) || record.items.length > MAX_RESOLVE_ITEMS) fail('marketplace_response_invalid', 'invalid resolve items'); + const requestId = stringValue(record.resolve_request_id, 'resolve_request_id', 128); + if (!REQUEST_ID_PATTERN.test(requestId)) fail('marketplace_response_invalid', 'invalid resolve request ID'); + return { + resolveRequestId: requestId, + resolveRequestDigest: shaValue(record.resolve_request_digest, 'resolve_request_digest'), + items: record.items.map(parseResolveItem), + catalogGeneration: integerValue(record.catalog_generation, 'catalog_generation', 1), + }; +} + +function parseDownloadGrant(value: unknown): DownloadGrant { + const record = isRecord(value) ? value : fail('marketplace_response_invalid', 'invalid download grant'); + exactKeys(record, [ + 'release_admission_id', 'release_id', 'plugin_id', 'version', 'package_schema_version', + 'contract_version', 'min_makelore_version', 'max_makelore_version', 'size_bytes', 'sha256', + 'signing_key_id', 'descriptor_signature', 'expires_at', 'content_url', + ]); + return { + releaseAdmissionId: releaseIdValue(record.release_admission_id, 'release_admission_id'), + releaseId: releaseIdValue(record.release_id, 'release_id'), + pluginId: idValue(record.plugin_id, 'plugin_id'), + version: semverValue(record.version, 'version'), + packageSchemaVersion: integerValue(record.package_schema_version, 'package_schema_version', 1), + contractVersion: integerValue(record.contract_version, 'contract_version', 1), + minMakeloreVersion: semverValue(record.min_makelore_version, 'min_makelore_version'), + maxMakeloreVersion: record.max_makelore_version === null ? null : semverValue(record.max_makelore_version, 'max_makelore_version'), + sizeBytes: integerValue(record.size_bytes, 'size_bytes', 1, DEFAULT_ARTIFACT_BYTES), + sha256: shaValue(record.sha256, 'sha256'), + signingKeyId: stringValue(record.signing_key_id, 'signing_key_id', 128), + descriptorSignature: stringValue(record.descriptor_signature, 'descriptor_signature', 256), + expiresAt: dateValue(record.expires_at, 'expires_at') ?? fail('marketplace_response_invalid', 'grant expiry is required'), + contentUrl: stringValue(record.content_url, 'content_url', 512), + }; +} + +function parseCacheMetadata(headers: Headers, fallbackGeneration: number): CacheMetadata { + const rawEtag = headers.get('etag'); + const generationHeader = headers.get('x-plugin-catalog-generation'); + const pricingHeader = headers.get('x-token-point-pricing-version'); + const generation = generationHeader === null + ? fallbackGeneration + : integerValue(Number(generationHeader), 'X-Plugin-Catalog-Generation', 1); + const pricingVersionId = pricingHeader === null || pricingHeader === 'none' + ? null + : stringValue(pricingHeader, 'X-Token-Point-Pricing-Version', 128); + const etag = rawEtag ?? `"plugins-${generation}-tp-${pricingVersionId ?? 'none'}"`; + const match = ETAG_PATTERN.exec(etag); + if (!match || Number(match[1]) !== generation + || (pricingVersionId === null ? match[2] !== 'none' : match[2] !== pricingVersionId)) { + fail('marketplace_response_invalid', 'invalid composite Marketplace ETag'); + } + return { etag, generation, pricingVersionId }; +} + +async function readBoundedBytes(response: Response, maximum: number): Promise { + const declared = response.headers.get('content-length'); + if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maximum) { + await response.body?.cancel().catch(() => undefined); + fail('marketplace_response_too_large', 'Marketplace response exceeds its bound'); + } + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maximum) fail('marketplace_response_too_large', 'Marketplace response exceeds its bound'); + return bytes; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + size += value.byteLength; + if (size > maximum) { + await reader.cancel().catch(() => undefined); + fail('marketplace_response_too_large', 'Marketplace response exceeds its bound'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); +} + +async function readJson(response: Response, maximum: number): Promise { + const bytes = await readBoundedBytes(response, maximum); + let source: string; + try { + source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + fail('marketplace_response_invalid', 'Marketplace response is not UTF-8'); + } + if (!source.trim()) fail('marketplace_response_invalid', 'Marketplace response is empty'); + try { + return JSON.parse(source) as unknown; + } catch { + fail('marketplace_response_invalid', 'Marketplace response is not JSON'); + } +} + +function normalizedBase(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('unsupported protocol'); + return url.toString().replace(/\/+$/u, ''); + } catch { + throw new TypeError('Marketplace API base URL is invalid'); + } +} + +function queryValue(value: string | undefined, field: string, max: number): string | undefined { + if (value === undefined) return undefined; + if (value.length > max) fail('marketplace_request_invalid', `invalid ${field}`); + return value; +} + +function validateRequestId(value: string, field = 'resolveRequestId'): string { + if (!REQUEST_ID_PATTERN.test(value)) fail('marketplace_request_invalid', `invalid ${field}`); + return value; +} + +function compareStableText(left: string, right: string): number { + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function canonicalResolveInput( + input: Pick, +): string { + const installed = [...input.installed].map((item) => ({ + pluginId: item.pluginId, + releaseId: item.releaseId, + sha256: item.sha256, + })).sort((left, right) => compareStableText(left.pluginId, right.pluginId) + || compareStableText(left.releaseId, right.releaseId) + || compareStableText(left.sha256, right.sha256)); + return JSON.stringify({ + makeloreVersion: input.makeloreVersion, + channel: input.channel ?? 'stable', + installed, + }); +} + +function prepareResolveInput(input: ResolveRequest): { + requestId: string; + digest: string; + channel: MarketplaceChannel; + payload: Record; + cacheKey: string; +} { + if (!isValidSemVer(input.makeloreVersion)) fail('marketplace_request_invalid', 'makeloreVersion must be SemVer'); + const channel = input.channel ?? 'stable'; + if (channel !== 'stable' && channel !== 'beta') fail('marketplace_request_invalid', 'invalid channel'); + if (!Array.isArray(input.installed) || input.installed.length > MAX_RESOLVE_ITEMS) fail('marketplace_request_invalid', 'invalid installed releases'); + const validatedInstalled = input.installed.map((item, index) => { + if (!isRecord(item)) fail('marketplace_request_invalid', `invalid installed[${index}]`); + return { + pluginId: idValue(item.pluginId, `installed[${index}].pluginId`), + releaseId: releaseIdValue(item.releaseId, `installed[${index}].releaseId`), + sha256: shaValue(item.sha256, `installed[${index}].sha256`), + }; + }); + const canonical = canonicalResolveInput({ ...input, installed: validatedInstalled }); + const derivedDigest = createHash('sha256').update(canonical, 'utf8').digest('hex'); + const digest = input.resolveRequestDigest ?? derivedDigest; + if (!SHA256_PATTERN.test(digest)) fail('marketplace_request_invalid', 'invalid resolveRequestDigest'); + const requestedId = input.resolveRequestId ?? input.requestId; + const requestId = validateRequestId(requestedId ?? `makelore-resolve-${derivedDigest}`); + return { + requestId, + digest, + channel, + payload: { + resolve_request_id: requestId, + resolve_request_digest: digest, + makelore_version: input.makeloreVersion, + channel, + installed: validatedInstalled.map((item) => ({ + plugin_id: item.pluginId, + release_id: item.releaseId, + sha256: item.sha256, + })), + }, + cacheKey: `${requestId}\u0000${digest}`, + }; +} + +function relativeContentUrl( + contentUrl: string, + apiBaseUrl: string, + releaseId: string, + admissionId: string, +): URL { + if (!contentUrl.startsWith('/')) fail('marketplace_response_invalid', 'content_url must be relative'); + let parsed: URL; + try { + parsed = new URL(contentUrl, apiBaseUrl); + } catch { + fail('marketplace_response_invalid', 'content_url is invalid'); + } + const base = new URL(apiBaseUrl); + const expectedPath = `/api/plugin-marketplace/v1/releases/${encodeURIComponent(releaseId)}/content`; + if (parsed.origin !== base.origin || parsed.pathname !== expectedPath || parsed.hash) { + fail('marketplace_response_invalid', 'content_url escapes the Marketplace content route'); + } + const entries = [...parsed.searchParams.entries()]; + if (entries.length !== 1 || entries[0]?.[0] !== 'release_admission_id' || entries[0][1] !== admissionId) { + fail('marketplace_response_invalid', 'content_url admission does not match the grant'); + } + return parsed; +} + +export interface MarketplaceClient { + readCatalog(input: CatalogQuery): Promise; + readDetail(pluginId: string): Promise; + readLibrary(): Promise; + acquire(pluginId: string): Promise; + remove(pluginId: string): Promise; + resolve(input: ResolveRequest): Promise; + issueDownload(input: DownloadRequest): Promise; + downloadContent(grant: DownloadGrant): Promise; + getCurrentAccountBinding(): AccountBinding | null; + dispose(): void; +} + +class MarketplaceClientImpl implements MarketplaceClient { + private readonly fetchImpl: FetchImplementation; + private readonly apiBaseUrl: string; + private readonly requestTimeoutMs: number; + private readonly maxResponseBytes: number; + private readonly maxRequestBytes: number; + private readonly maxArtifactBytes: number; + private readonly now: () => number; + private readonly getAccessTokenImpl: MarketplaceSessionPort['getAccessToken']; + private readonly getAccountBindingImpl: MarketplaceSessionPort['getAccountBinding']; + private readonly accountCache: AccountPluginCache; + private readonly catalogCache = new Map(); + private readonly detailCache = new Map(); + private readonly unsubscribeSession: (() => void) | null; + private disposed = false; + + constructor(options: MarketplaceClientOptions = {}) { + this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; + this.apiBaseUrl = normalizedBase(options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl); + this.requestTimeoutMs = positiveOption(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 'requestTimeoutMs'); + this.maxResponseBytes = positiveOption(options.maxResponseBytes, DEFAULT_RESPONSE_BYTES, 'maxResponseBytes'); + this.maxRequestBytes = positiveOption(options.maxRequestBytes, DEFAULT_REQUEST_BYTES, 'maxRequestBytes'); + this.maxArtifactBytes = positiveOption(options.maxArtifactBytes, DEFAULT_ARTIFACT_BYTES, 'maxArtifactBytes'); + this.now = options.now ?? (() => Date.now()); + this.accountCache = options.accountCache ?? new AccountPluginCache(); + const session = options.session; + this.getAccessTokenImpl = options.getAccessToken + ?? (session ? session.getAccessToken.bind(session) : undefined) + ?? (async (refreshOptions) => getValidWorksSquareAccessToken({ forceRefresh: refreshOptions?.forceRefresh })); + this.getAccountBindingImpl = options.getAccountBinding + ?? (session ? session.getAccountBinding.bind(session) : undefined) + ?? (() => { + const binding = getWorksSquareAccountBinding(); + return binding ? { accountKey: binding.accountKey, epoch: binding.epoch } : null; + }); + const subscribe = options.subscribeSession + ?? (session?.subscribe ? session.subscribe.bind(session) : undefined) + ?? ((listener: () => void) => subscribeWorksSquareSession(() => listener())); + this.unsubscribeSession = subscribe(() => { + this.accountCache.invalidateAll(); + }); + } + + getCurrentAccountBinding(): AccountBinding | null { + return normalizedBinding(this.getAccountBindingImpl()); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.unsubscribeSession?.(); + this.accountCache.invalidateAll(); + } + + async readCatalog(input: CatalogQuery = {}): Promise { + const query = { + query: queryValue(input.query, 'query', 120), + category: queryValue(input.category, 'category', 128), + featured: input.featured ?? false, + limit: input.limit ?? 24, + cursor: queryValue(input.cursor, 'cursor', MAX_CURSOR), + }; + if (typeof query.featured !== 'boolean' || !Number.isSafeInteger(query.limit) || query.limit < 1 || query.limit > 100) { + fail('marketplace_request_invalid', 'invalid catalog query'); + } + const key = JSON.stringify(query); + const previous = this.catalogCache.get(key); + const params = new URLSearchParams(); + if (query.query !== undefined) params.set('query', query.query); + if (query.category !== undefined) params.set('category', query.category); + if (query.featured) params.set('featured', 'true'); + params.set('limit', String(query.limit)); + if (query.cursor !== undefined) params.set('cursor', query.cursor); + try { + const result = await this.requestJson( + `/api/plugin-marketplace/v1/catalog?${params.toString()}`, + { auth: 'optional', etag: previous?.etag }, + parseCatalogPage, + ); + if (result.notModified) { + if (!previous) fail('marketplace_response_invalid', '304 received without a catalog snapshot'); + const fresh = { ...previous, stale: false, fetchedAt: this.now() }; + this.catalogCache.set(key, fresh); + return clone(fresh); + } + const parsed = result.value!; + const metadata = parseCacheMetadata(result.headers, parsed.catalogGeneration); + if (metadata.generation !== parsed.catalogGeneration) fail('marketplace_response_invalid', 'catalog generation header does not match body'); + const page: CatalogPage = { ...parsed, ...metadata, stale: false, fetchedAt: this.now() }; + this.catalogCache.set(key, page); + return clone(page); + } catch (error) { + if (previous && canServeStale(error)) { + const stale = { ...previous, stale: true, fetchedAt: previous.fetchedAt }; + this.catalogCache.set(key, stale); + return clone(stale); + } + throw normalizeError(error); + } + } + + async readDetail(pluginId: string): Promise { + const validated = idValue(pluginId, 'pluginId'); + const previous = this.detailCache.get(validated); + try { + const result = await this.requestJson( + `/api/plugin-marketplace/v1/plugins/${encodeURIComponent(validated)}`, + { auth: 'optional', etag: previous?.etag }, + parsePluginDetail, + ); + if (result.notModified) { + if (!previous) fail('marketplace_response_invalid', '304 received without a detail snapshot'); + const fresh = { ...previous, stale: false, fetchedAt: this.now() }; + this.detailCache.set(validated, fresh); + return clone(fresh); + } + const parsed = result.value!; + const metadata = parseCacheMetadata(result.headers, 1); + const detail: PluginDetail = { ...parsed, ...metadata, stale: false, fetchedAt: this.now() }; + this.detailCache.set(validated, detail); + return clone(detail); + } catch (error) { + if (previous && canServeStale(error)) { + const stale = { ...previous, stale: true, fetchedAt: previous.fetchedAt }; + this.detailCache.set(validated, stale); + return clone(stale); + } + throw normalizeError(error); + } + } + + async readLibrary(): Promise { + const binding = this.requireBinding(); + const previous = this.accountCache.getLibrary(binding); + try { + const result = await this.requestJson('/api/plugin-marketplace/v1/library', { auth: 'required' }, parseLibrary); + const parsed = result.value!; + const snapshot: MarketplaceLibrarySnapshot = { + ...parsed, + stale: false, + fetchedAt: this.now(), + }; + this.assertBinding(binding); + this.accountCache.setLibrary(binding, snapshot); + return clone(snapshot); + } catch (error) { + if (!this.bindingMatches(binding)) { + fail('marketplace_account_changed', 'Marketplace account changed while the request was active'); + } + if (previous && canServeStale(error)) { + const stale = this.accountCache.markLibraryStale(binding); + if (stale) return clone(stale); + } + throw normalizeError(error); + } + } + + async acquire(pluginId: string): Promise { + return this.mutateLibrary('PUT', pluginId); + } + + async remove(pluginId: string): Promise { + return this.mutateLibrary('DELETE', pluginId); + } + + async resolve(input: ResolveRequest): Promise { + const binding = this.requireBinding(); + const prepared = prepareResolveInput(input); + const previous = this.accountCache.getResolve(binding, prepared.cacheKey); + try { + const result = await this.requestJson( + '/api/plugin-marketplace/v1/releases/resolve', + { auth: 'required', method: 'POST', body: prepared.payload }, + parseResolve, + ); + const parsed = result.value!; + if (parsed.resolveRequestId !== prepared.requestId || parsed.resolveRequestDigest !== prepared.digest) { + fail('marketplace_response_invalid', 'resolve response identity does not match request'); + } + const metadata = parseCacheMetadata(result.headers, parsed.catalogGeneration); + if (metadata.generation !== parsed.catalogGeneration) fail('marketplace_response_invalid', 'resolve generation header does not match body'); + const snapshot: MarketplaceResolveSnapshot = { + ...parsed, + etag: metadata.etag, + stale: false, + }; + this.assertBinding(binding); + this.accountCache.setResolve(binding, prepared.cacheKey, snapshot); + return clone(snapshot); + } catch (error) { + if (!this.bindingMatches(binding)) { + fail('marketplace_account_changed', 'Marketplace account changed while the request was active'); + } + if (previous && canServeStale(error)) { + const stale = this.accountCache.markResolveStale(binding, prepared.cacheKey); + if (stale) return clone(stale); + } + throw normalizeError(error); + } + } + + async issueDownload(input: DownloadRequest): Promise { + const releaseId = releaseIdValue(input.releaseId, 'releaseId'); + const admissionId = releaseIdValue(input.releaseAdmissionId, 'releaseAdmissionId'); + const result = await this.requestJson( + `/api/plugin-marketplace/v1/releases/${encodeURIComponent(releaseId)}/download`, + { auth: 'required', method: 'POST', body: { release_admission_id: admissionId } }, + parseDownloadGrant, + ); + const grant = result.value!; + if (grant.releaseId !== releaseId || grant.releaseAdmissionId !== admissionId) { + fail('marketplace_response_invalid', 'download grant identity does not match request'); + } + relativeContentUrl(grant.contentUrl, this.apiBaseUrl, releaseId, admissionId); + return clone(grant); + } + + async downloadContent(grant: DownloadGrant): Promise { + const releaseId = releaseIdValue(grant.releaseId, 'releaseId'); + const admissionId = releaseIdValue(grant.releaseAdmissionId, 'releaseAdmissionId'); + const localUrl = relativeContentUrl(grant.contentUrl, this.apiBaseUrl, releaseId, admissionId); + const binding = this.requireBinding(); + let token = await this.getAccessTokenImpl({ forceRefresh: false }); + if (!token) fail('marketplace_auth_required', 'Marketplace session is unavailable', 401); + let refreshed = false; + let url = localUrl.toString(); + let sendAuthorization = true; + for (let redirect = 0; redirect <= 3; redirect += 1) { + const response = await fetchWithDeadline( + this.fetchImpl as typeof fetch, + url, + { + method: 'GET', + headers: { + Accept: 'application/zip', + ...(sendAuthorization ? { Authorization: `Bearer ${token}` } : {}), + }, + redirect: 'manual', + }, + this.requestTimeoutMs, + ); + if (response.status === 401 && sendAuthorization && !refreshed) { + await response.body?.cancel().catch(() => undefined); + refreshed = true; + token = await this.getAccessTokenImpl({ forceRefresh: true }); + if (!token) fail('marketplace_auth_required', 'Marketplace session refresh failed', 401); + this.assertBinding(binding); + continue; + } + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + await response.body?.cancel().catch(() => undefined); + if (!location || location.length > 4096 || redirect === 3) fail('marketplace_download_invalid', 'Marketplace download redirect is invalid'); + let redirected: URL; + try { + redirected = new URL(location, url); + } catch { + fail('marketplace_download_invalid', 'Marketplace download redirect is invalid'); + } + if (redirected.protocol !== 'https:' || redirected.username || redirected.password || redirected.hash) { + fail('marketplace_download_invalid', 'Marketplace download redirect is not trusted'); + } + this.assertBinding(binding); + url = redirected.toString(); + sendAuthorization = false; + continue; + } + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + fail(response.status === 401 ? 'marketplace_auth_required' : 'marketplace_request_failed', 'Marketplace download failed', response.status); + } + const bytes = await readBoundedBytes(response, Math.min(this.maxArtifactBytes, grant.sizeBytes)); + if (bytes.byteLength !== grant.sizeBytes) fail('marketplace_download_invalid', 'Marketplace artifact size does not match its grant'); + this.assertBinding(binding); + return bytes; + } + fail('marketplace_download_invalid', 'Marketplace download redirect loop'); + } + + private async mutateLibrary(method: 'PUT' | 'DELETE', pluginId: string): Promise { + const validated = idValue(pluginId, 'pluginId'); + const binding = this.requireBinding(); + const result = await this.requestJson( + `/api/plugin-marketplace/v1/library/${encodeURIComponent(validated)}`, + { auth: 'required', method }, + parseLibraryEntry, + ); + const entry = result.value!; + this.assertBinding(binding); + try { + return await this.readLibrary(); + } catch (error) { + if (!this.bindingMatches(binding)) { + fail('marketplace_account_changed', 'Marketplace account changed while the request was active'); + } + if (!canServeStale(error)) throw normalizeError(error); + const previous = this.accountCache.getLibrary(binding); + if (previous) { + const items = [entry, ...previous.items.filter((item) => item.pluginId !== entry.pluginId)]; + const snapshot: MarketplaceLibrarySnapshot = { + items, + total: Math.max(previous.total, items.length), + stale: true, + fetchedAt: previous.fetchedAt, + }; + this.accountCache.setLibrary(binding, snapshot); + return clone(snapshot); + } + throw normalizeError(error); + } + } + + private requireBinding(): AccountBinding { + const binding = this.getCurrentAccountBinding(); + if (!binding) fail('marketplace_auth_required', 'Marketplace session is unavailable', 401); + return binding; + } + + private assertBinding(binding: AccountBinding): void { + if (!this.bindingMatches(binding)) { + fail('marketplace_account_changed', 'Marketplace account changed while the request was active'); + } + } + + private bindingMatches(binding: AccountBinding): boolean { + const current = this.getCurrentAccountBinding(); + return current !== null && current.accountKey === binding.accountKey && current.epoch === binding.epoch; + } + + private async requestJson( + route: string, + options: { + readonly auth: 'required' | 'optional'; + readonly method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + readonly body?: Record; + readonly etag?: string; + }, + parser: (value: unknown) => T, + ): Promise> { + const method = options.method ?? 'GET'; + const binding = options.auth === 'required' ? this.requireBinding() : this.getCurrentAccountBinding(); + let token = await this.getAccessTokenImpl({ forceRefresh: false }); + if (options.auth === 'required' && !token) fail('marketplace_auth_required', 'Marketplace session is unavailable', 401); + const body = options.body === undefined ? undefined : JSON.stringify(options.body); + if (body !== undefined && Buffer.byteLength(body, 'utf8') > this.maxRequestBytes) { + fail('marketplace_request_invalid', 'Marketplace request body exceeds its bound'); + } + let refreshed = false; + const url = `${this.apiBaseUrl}${route}`; + for (;;) { + const headers: Record = { + Accept: 'application/json', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.etag ? { 'If-None-Match': options.etag } : {}), + }; + const response = await fetchWithDeadline( + this.fetchImpl as typeof fetch, + url, + { method, headers, body, redirect: 'manual' }, + this.requestTimeoutMs, + ); + if (response.status === 401 && !refreshed && token) { + await response.body?.cancel().catch(() => undefined); + refreshed = true; + token = await this.getAccessTokenImpl({ forceRefresh: true }); + if (!token) fail('marketplace_auth_required', 'Marketplace session refresh failed', 401); + if (binding) this.assertBinding(binding); + continue; + } + if (response.status === 304) { + if (binding) this.assertBinding(binding); + return { status: response.status, headers: response.headers, value: null, notModified: true }; + } + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + fail(response.status === 401 ? 'marketplace_auth_required' : 'marketplace_request_failed', 'Marketplace request failed', response.status); + } + const value = parser(await readJson(response, this.maxResponseBytes)); + if (binding) this.assertBinding(binding); + return { status: response.status, headers: response.headers, value, notModified: false }; + } + } +} + +function positiveOption(value: number | undefined, fallback: number, field: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1) throw new RangeError(`${field} must be a positive safe integer`); + return resolved; +} + +function normalizeError(error: unknown): MarketplaceClientError { + if (error instanceof MarketplaceClientError) return error; + return new MarketplaceClientError('marketplace_request_failed', 0, 'Marketplace request failed'); +} + +function canServeStale(error: unknown): boolean { + if (!(error instanceof MarketplaceClientError)) return true; + if (error.code !== 'marketplace_request_failed') return false; + return error.status === 0 || error.status >= 500; +} + +export function createMarketplaceClient(options: MarketplaceClientOptions = {}): MarketplaceClient { + return new MarketplaceClientImpl(options); +} + +export const parseMarketplaceCatalogPage = parseCatalogPage; +export const parseMarketplacePluginDetail = parsePluginDetail; +export const parseMarketplaceLibrary = parseLibrary; +export const parseMarketplaceResolve = parseResolve; +export const parseMarketplaceDownloadGrant = parseDownloadGrant; diff --git a/electron/coding-plugins/package-store.ts b/electron/coding-plugins/package-store.ts new file mode 100644 index 0000000..6a5ab79 --- /dev/null +++ b/electron/coding-plugins/package-store.ts @@ -0,0 +1,932 @@ +import { Buffer } from 'node:buffer'; +import { createHash, randomUUID } from 'node:crypto'; +import { + lstat, + mkdir, + mkdtemp, + readFile, + rename, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import AdmZip from 'adm-zip'; +import { getDataDir } from '../utils/paths'; +import { subscribeWorksSquareSession } from '../services/works-square-session'; +import { + loadCodingPluginDefinition, +} from './manifest'; +import type { CodingPluginDefinition } from '../../shared/coding-plugins'; +import { + buildPluginReleaseDescriptor, + isMakeLoreVersionCompatible, + isValidSemVer, + type PluginReleaseDescriptor, +} from './release-descriptor'; +import { + createPluginSignatureVerifier, + type PluginSignatureFailureCode, + type PluginSignatureVerifier, +} from './signature-verifier'; +import { + AccountPluginCache, + type AccountBinding, + type MarketplaceLibrarySnapshot, + type MarketplaceResolveItem, + type MarketplaceResolveSnapshot, +} from './account-plugin-cache'; +import type { + DownloadGrant, + DownloadRequest, + InstalledReleaseInput, + MarketplaceClient, + ResolveRequest, +} from './marketplace-client'; +import type { PluginSigningKeyStore } from './trusted-keys'; + +const INDEX_SCHEMA_VERSION = 1; +const DEFAULT_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024; +const DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024; +const DEFAULT_MAX_FILES = 256; +const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024; +const MAX_PLUGIN_ID = 128; +const MAX_RELEASE_ID = 128; +const MAX_VERSION = 128; +const MAX_CONTRACT_VERSION = 2 ** 31 - 1; +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; +const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const INDEX_ROOT_KEYS = new Set(['schema_version', 'releases']); +const INDEX_RELEASE_KEYS = new Set([ + 'plugin_id', + 'release_id', + 'version', + 'package_schema_version', + 'contract_version', + 'runtime_kind', + 'sha256', + 'size_bytes', + 'installed_at', +]); + +type UnknownRecord = Record; + +export type { DownloadGrant, MarketplaceResolveSnapshot } from './marketplace-client'; +export type ResolveSnapshot = MarketplaceResolveSnapshot; + +export interface InstalledReleaseRecord { + readonly pluginId: string; + readonly releaseId: string; + readonly version: string; + readonly packageSchemaVersion: number; + readonly contractVersion: number; + readonly runtimeKind: 'skill_only' | 'platform_hosted'; + readonly sha256: string; + readonly sizeBytes: number; + readonly installedAt: string; +} + +export interface InstalledRelease extends InstalledReleaseRecord { + readonly packageRoot: string; + readonly definition: CodingPluginDefinition; +} + +export type InstallationStatus = 'installed' | 'kept' | 'removed' | 'unavailable'; + +export interface InstallationSnapshot { + readonly status: InstallationStatus; + readonly pluginId: string; + readonly releaseId?: string; + readonly version?: string; + readonly packageRoot?: string; + readonly definition?: CodingPluginDefinition; + readonly reason?: string; +} + +export interface ResolveInstallInput { + readonly pluginId: string; + readonly makeloreVersion: string; + readonly channel?: 'stable' | 'beta'; + /** Beta packages are never selected by background/stable sync. */ + readonly explicitBeta?: boolean; + readonly resolveRequestId?: string; + readonly resolveRequestDigest?: string; + readonly installed?: InstalledReleaseInput | null; +} + +export interface MarketplacePackageClientPort { + resolve(input: ResolveRequest): Promise; + issueDownload(input: DownloadRequest): Promise; + downloadContent(grant: DownloadGrant): Promise; + readLibrary?(): Promise; + getCurrentAccountBinding?(): AccountBinding | null; +} + +export interface PluginPackageStoreOptions { + /** A test-owned root may be supplied; production derives it from appData. */ + readonly rootDir?: string; + readonly marketplace: MarketplacePackageClientPort | MarketplaceClient; + readonly accountCache?: AccountPluginCache; + readonly getAccountBinding?: () => AccountBinding | null; + readonly subscribeSession?: (listener: () => void) => () => void; + readonly clientVersion?: string; + readonly keyStore?: PluginSigningKeyStore | ReadonlyMap; + readonly signatureVerifier?: PluginSignatureVerifier; + readonly now?: () => number; + readonly maxArchiveBytes?: number; + readonly maxExtractedBytes?: number; + readonly maxFiles?: number; + readonly maxFileBytes?: number; + /** Replaced only by focused interruption tests; production is atomic. */ + readonly writeIndex?: (filePath: string, bytes: Uint8Array) => Promise; + readonly activeWorkerReleaseIds?: () => readonly string[]; +} + +export type PluginPackageStoreErrorCode = + | 'plugin_store_index_invalid' + | 'plugin_beta_selection_required' + | 'plugin_release_unavailable' + | 'plugin_account_changed' + | 'plugin_artifact_invalid' + | 'plugin_signature_invalid' + | 'plugin_incompatible_client' + | 'plugin_manifest_invalid' + | 'plugin_runtime_not_supported' + | 'plugin_release_conflict' + | 'plugin_install_failed'; + +export class PluginPackageStoreError extends Error { + constructor( + readonly code: PluginPackageStoreErrorCode, + message: string = code, + ) { + super(message); + this.name = 'PluginPackageStoreError'; + } +} + +interface IndexDocument { + readonly schema_version: typeof INDEX_SCHEMA_VERSION; + readonly releases: readonly InstalledReleaseRecord[]; +} + +function isRecord(value: unknown): value is UnknownRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function fail(code: PluginPackageStoreErrorCode, message: string = code): never { + throw new PluginPackageStoreError(code, message); +} + +function assertExactKeys(value: UnknownRecord, allowed: ReadonlySet, context: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail('plugin_store_index_invalid', `unknown ${context} field: ${key.slice(0, 128)}`); + } +} + +function boundedText( + value: unknown, + field: string, + maximum: number, + code: PluginPackageStoreErrorCode = 'plugin_store_index_invalid', +): string { + if (typeof value !== 'string' || value.length === 0 || value.length > maximum) { + fail(code, `invalid ${field}`); + } + return value; +} + +function positiveInteger(value: unknown, field: string, maximum = Number.MAX_SAFE_INTEGER): number { + if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > maximum) { + fail('plugin_store_index_invalid', `invalid ${field}`); + } + return value as number; +} + +function validPluginId( + value: unknown, + field = 'pluginId', + code: PluginPackageStoreErrorCode = 'plugin_release_unavailable', +): string { + const result = boundedText(value, field, MAX_PLUGIN_ID, code); + if (!PLUGIN_ID_PATTERN.test(result)) fail(code, `invalid ${field}`); + return result; +} + +function validReleaseId( + value: unknown, + field = 'releaseId', + code: PluginPackageStoreErrorCode = 'plugin_release_unavailable', +): string { + const result = boundedText(value, field, MAX_RELEASE_ID, code); + if (!RELEASE_ID_PATTERN.test(result)) fail(code, `invalid ${field}`); + return result; +} + +function validSha( + value: unknown, + field: string, + code: PluginPackageStoreErrorCode = 'plugin_artifact_invalid', +): string { + const result = boundedText(value, field, 64, code); + if (!SHA256_PATTERN.test(result)) fail(code, `invalid ${field}`); + return result; +} + +function validVersion(value: unknown, field: string): string { + const result = boundedText(value, field, MAX_VERSION); + if (!isValidSemVer(result)) fail('plugin_store_index_invalid', `invalid ${field}`); + return result; +} + +function assertPositiveOption(value: number | undefined, fallback: number, field: string): number { + const result = value ?? fallback; + if (!Number.isSafeInteger(result) || result < 1) throw new RangeError(`${field} must be a positive safe integer`); + return result; +} + +function clone(value: T): T { + return structuredClone(value); +} + +function digest(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sameBinding(left: AccountBinding | null, right: AccountBinding): boolean { + return left !== null && left.accountKey === right.accountKey && left.epoch === right.epoch; +} + +function toBuffer(bytes: Uint8Array): Buffer { + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function isNotFound(error: unknown): boolean { + return isRecord(error) && error.code === 'ENOENT'; +} + +function isDirectoryPath(value: string): Promise { + return stat(value).then((entry) => entry.isDirectory()).catch(() => false); +} + +function parseIndexDocument(value: unknown): IndexDocument { + const root = isRecord(value) ? value : fail('plugin_store_index_invalid', 'index root must be an object'); + assertExactKeys(root, INDEX_ROOT_KEYS, 'index'); + if (root.schema_version !== INDEX_SCHEMA_VERSION) fail('plugin_store_index_invalid', 'unsupported index schema'); + if (!Array.isArray(root.releases) || root.releases.length > DEFAULT_MAX_FILES) { + fail('plugin_store_index_invalid', 'invalid index releases'); + } + const seen = new Set(); + const releases = root.releases.map((value, index) => { + const record = isRecord(value) ? value : fail('plugin_store_index_invalid', `invalid release ${index}`); + assertExactKeys(record, INDEX_RELEASE_KEYS, `release ${index}`); + if (record.package_schema_version !== 2) fail('plugin_store_index_invalid', `invalid release ${index} schema`); + const pluginId = validPluginId(record.plugin_id, `release ${index}.plugin_id`, 'plugin_store_index_invalid'); + const releaseId = validReleaseId(record.release_id, `release ${index}.release_id`, 'plugin_store_index_invalid'); + const key = `${pluginId}\u0000${releaseId}`; + if (seen.has(key)) fail('plugin_store_index_invalid', `duplicate release ${releaseId}`); + seen.add(key); + const runtimeKind = record.runtime_kind; + if (runtimeKind !== 'skill_only') { + fail('plugin_store_index_invalid', `invalid release ${index}.runtime_kind`); + } + const installedAt = boundedText(record.installed_at, `release ${index}.installed_at`, 80); + if (!Number.isFinite(Date.parse(installedAt))) fail('plugin_store_index_invalid', `invalid release ${index}.installed_at`); + return Object.freeze({ + pluginId, + releaseId, + version: validVersion(record.version, `release ${index}.version`), + packageSchemaVersion: 2 as const, + contractVersion: positiveInteger(record.contract_version, `release ${index}.contract_version`, MAX_CONTRACT_VERSION), + runtimeKind, + sha256: validSha(record.sha256, `release ${index}.sha256`, 'plugin_store_index_invalid'), + sizeBytes: positiveInteger(record.size_bytes, `release ${index}.size_bytes`, DEFAULT_MAX_ARCHIVE_BYTES), + installedAt, + }); + }); + return Object.freeze({ schema_version: INDEX_SCHEMA_VERSION, releases: Object.freeze(releases) }); +} + +function serializeIndex(document: IndexDocument): Uint8Array { + return Buffer.from(`${JSON.stringify({ + schema_version: document.schema_version, + releases: document.releases.map((record) => ({ + plugin_id: record.pluginId, + release_id: record.releaseId, + version: record.version, + package_schema_version: record.packageSchemaVersion, + contract_version: record.contractVersion, + runtime_kind: record.runtimeKind, + sha256: record.sha256, + size_bytes: record.sizeBytes, + installed_at: record.installedAt, + })), + })}\n`, 'utf8'); +} + +async function parseJsonFile(filePath: string): Promise { + const bytes = await readFile(filePath); + let source: string; + try { + source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + fail('plugin_store_index_invalid', 'index is not valid UTF-8'); + } + try { + return JSON.parse(source) as unknown; + } catch { + fail('plugin_store_index_invalid', 'index is not valid JSON'); + } +} + +async function atomicWriteIndex(filePath: string, bytes: Uint8Array): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`); + try { + await writeFile(temporaryPath, toBuffer(bytes), { flag: 'wx' }); + await rename(temporaryPath, filePath); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +function safeArchiveEntryName(entryName: string, isDirectory: boolean): string[] { + if (entryName.length === 0 || entryName.includes('\\') || entryName.startsWith('/') + || /^[A-Za-z]:/u.test(entryName) || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(entryName)) { + fail('plugin_artifact_invalid', 'archive contains a non-canonical path'); + } + const segments = entryName.split('/'); + if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + fail('plugin_artifact_invalid', 'archive contains a path traversal'); + } + if (isDirectory && !entryName.endsWith('/')) { + fail('plugin_artifact_invalid', 'archive directory path is not canonical'); + } + if (!isDirectory && entryName.endsWith('/')) { + fail('plugin_artifact_invalid', 'archive file path is not canonical'); + } + return isDirectory ? segments.slice(0, -1) : segments; +} + +function entryIsSymlink(entry: AdmZip.IZipEntry): boolean { + const mode = (entry.attr >>> 16) & 0xffff; + return (mode & 0xf000) === 0xa000; +} + +async function extractArchive( + bytes: Uint8Array, + destination: string, + options: { + readonly maxExtractedBytes: number; + readonly maxFiles: number; + readonly maxFileBytes: number; + }, +): Promise { + let archive: AdmZip; + try { + archive = new AdmZip(toBuffer(bytes)); + } catch { + fail('plugin_artifact_invalid', 'artifact is not a readable ZIP archive'); + } + const entries = archive.getEntries(); + if (entries.length === 0 || entries.length > options.maxFiles) { + fail('plugin_artifact_invalid', 'artifact file count is outside its bound'); + } + const seen = new Set(); + let extractedBytes = 0; + for (const entry of entries) { + if (entryIsSymlink(entry)) fail('plugin_artifact_invalid', 'archive symlinks are not supported'); + const segments = safeArchiveEntryName(entry.entryName, entry.isDirectory); + const relative = segments.join('/'); + if (seen.has(relative)) fail('plugin_artifact_invalid', 'archive contains duplicate paths'); + seen.add(relative); + const target = path.resolve(destination, ...segments); + const relativeTarget = path.relative(destination, target); + if (!relativeTarget || relativeTarget === '..' || relativeTarget.startsWith(`..${path.sep}`) || path.isAbsolute(relativeTarget)) { + fail('plugin_artifact_invalid', 'archive path escapes its staging directory'); + } + if (entry.isDirectory) { + await mkdir(target, { recursive: true }); + continue; + } + const declaredSize = entry.header.size; + if (!Number.isSafeInteger(declaredSize) || declaredSize < 0 || declaredSize > options.maxFileBytes) { + fail('plugin_artifact_invalid', 'archive file exceeds its bound'); + } + extractedBytes += declaredSize; + if (extractedBytes > options.maxExtractedBytes) fail('plugin_artifact_invalid', 'archive extraction exceeds its bound'); + let content: Buffer; + try { + content = entry.getData(); + } catch { + fail('plugin_artifact_invalid', 'archive entry could not be decompressed'); + } + if (content.byteLength !== declaredSize || content.byteLength > options.maxFileBytes) { + fail('plugin_artifact_invalid', 'archive entry size is invalid'); + } + await mkdir(path.dirname(target), { recursive: true }); + try { + await writeFile(target, content, { flag: 'wx' }); + } catch { + fail('plugin_artifact_invalid', 'archive extraction could not create a file'); + } + } +} + +function mapVerificationFailure(code: PluginSignatureFailureCode): PluginPackageStoreErrorCode { + return code; +} + +function validateGrantAgainstResolve( + item: MarketplaceResolveItem, + grant: DownloadGrant, + input: ResolveInstallInput, + now: number, + maxArchiveBytes: number, +): void { + if (!item.releaseId || !item.version || !item.sha256 || !item.sizeBytes || !item.releaseAdmissionId) { + fail('plugin_release_unavailable', 'resolve result is missing download admission metadata'); + } + validReleaseId(item.releaseId); + validPluginId(grant.pluginId); + validReleaseId(grant.releaseId); + validReleaseId(grant.releaseAdmissionId); + validSha(item.sha256, 'resolve sha256'); + if (!Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 1 || item.sizeBytes > maxArchiveBytes) { + fail('plugin_artifact_invalid', 'resolve artifact size is outside its bound'); + } + if (!Number.isSafeInteger(grant.sizeBytes) || grant.sizeBytes < 1 || grant.sizeBytes > maxArchiveBytes) { + fail('plugin_artifact_invalid', 'download grant size is outside its bound'); + } + if (item.pluginId !== input.pluginId || grant.pluginId !== input.pluginId + || grant.releaseId !== item.releaseId || grant.version !== item.version + || grant.sha256 !== item.sha256 || grant.sizeBytes !== item.sizeBytes + || grant.releaseAdmissionId !== item.releaseAdmissionId) { + fail('plugin_release_unavailable', 'download grant does not match the resolve result'); + } + if (grant.packageSchemaVersion !== 2) fail('plugin_artifact_invalid', 'downloaded Release must use package schema 2'); + const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN; + if (!Number.isFinite(expiresAt) || expiresAt <= now) fail('plugin_release_unavailable', 'download admission has expired'); +} + +function toInstalledInput(record: InstalledReleaseRecord): InstalledReleaseInput { + return { pluginId: record.pluginId, releaseId: record.releaseId, sha256: record.sha256 }; +} + +/** + * Main-owned device package store. The on-disk index is intentionally + * account-free; account Library/admission state stays in AccountPluginCache. + */ +export class PluginPackageStore { + private readonly rootDir: string; + private readonly packagesDir: string; + private readonly indexPath: string; + private readonly marketplace: MarketplacePackageClientPort; + private readonly accountCache: AccountPluginCache; + private readonly getAccountBindingImpl: () => AccountBinding | null; + private readonly clientVersion: string; + private readonly signatureVerifier: PluginSignatureVerifier; + private readonly now: () => number; + private readonly maxArchiveBytes: number; + private readonly maxExtractedBytes: number; + private readonly maxFiles: number; + private readonly maxFileBytes: number; + private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise; + private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null; + private readonly activeWorkers = new Set(); + private readonly unsubscribeSession: (() => void) | null; + private operation: Promise = Promise.resolve(); + + constructor(options: PluginPackageStoreOptions) { + this.rootDir = path.resolve(options.rootDir ?? path.join(getDataDir(), 'coding-plugins')); + this.packagesDir = path.join(this.rootDir, 'packages'); + this.indexPath = path.join(this.rootDir, 'index.json'); + this.marketplace = options.marketplace; + this.accountCache = options.accountCache ?? new AccountPluginCache(); + this.getAccountBindingImpl = options.getAccountBinding + ?? (() => this.marketplace.getCurrentAccountBinding?.() ?? null); + this.clientVersion = options.clientVersion ?? '1.0.0'; + this.signatureVerifier = options.signatureVerifier ?? createPluginSignatureVerifier({ + clientVersion: this.clientVersion, + keyStore: options.keyStore, + }); + this.now = options.now ?? (() => Date.now()); + this.maxArchiveBytes = assertPositiveOption(options.maxArchiveBytes, DEFAULT_MAX_ARCHIVE_BYTES, 'maxArchiveBytes'); + this.maxExtractedBytes = assertPositiveOption(options.maxExtractedBytes, DEFAULT_MAX_EXTRACTED_BYTES, 'maxExtractedBytes'); + this.maxFiles = assertPositiveOption(options.maxFiles, DEFAULT_MAX_FILES, 'maxFiles'); + this.maxFileBytes = assertPositiveOption(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 'maxFileBytes'); + this.writeIndex = options.writeIndex ?? atomicWriteIndex; + this.activeWorkerReleaseIdsImpl = options.activeWorkerReleaseIds ?? null; + const subscribeSession = options.subscribeSession + ?? ((listener: () => void) => subscribeWorksSquareSession(() => listener())); + this.unsubscribeSession = subscribeSession(() => this.accountCache.invalidateAll()); + } + + get indexFilePath(): string { + return this.indexPath; + } + + get packageRoot(): string { + return this.rootDir; + } + + dispose(): void { + this.unsubscribeSession?.(); + this.accountCache.invalidateAll(); + } + + async syncLibrary(): Promise { + return this.withOperation(async () => { + const binding = this.requireBinding(); + if (!this.marketplace.readLibrary) fail('plugin_install_failed', 'Marketplace Library is unavailable'); + const snapshot = await this.marketplace.readLibrary(); + this.assertBinding(binding); + this.accountCache.setLibrary(binding, snapshot); + return clone(snapshot); + }); + } + + async resolveAndInstall(input: ResolveInstallInput): Promise { + return this.withOperation(() => this.resolveAndInstallLocked(input)); + } + + async getInstalled(pluginId: string): Promise { + const validated = validPluginId(pluginId); + const index = await this.readIndex(); + return this.getInstalledFromIndex(index, validated); + } + + async removeUnused(pluginId: string): Promise { + const validated = validPluginId(pluginId); + return this.withOperation(async () => { + const index = await this.readIndex(); + const records = index.releases.filter((record) => record.pluginId === validated); + if (records.length === 0) return { status: 'removed', pluginId: validated, reason: 'none' }; + const latest = records + .map((record, index) => ({ record, index })) + .sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index)[0] + ?.record; + if (!latest) return { status: 'removed', pluginId: validated, reason: 'none' }; + const protectedIds = new Set([ + ...this.accountCache.referencedReleaseIds(), + ...(this.activeWorkerReleaseIds() ?? []), + ...this.activeWorkers, + latest.releaseId, + ]); + const removable = records.filter((record) => !protectedIds.has(record.releaseId)); + if (removable.length === 0) return { status: 'kept', pluginId: validated, releaseId: latest.releaseId, version: latest.version }; + const remaining = index.releases.filter((record) => !removable.includes(record)); + try { + await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining })); + } catch { + throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed'); + } + await Promise.all(removable.map(async (record) => { + await rm(this.releaseDirectory(record), { recursive: true, force: true }); + })); + return { status: 'removed', pluginId: validated, releaseId: latest.releaseId, version: latest.version }; + }); + } + + registerActiveWorker(releaseId: string): void { + this.activeWorkers.add(validReleaseId(releaseId)); + } + + releaseActiveWorker(releaseId: string): void { + this.activeWorkers.delete(validReleaseId(releaseId)); + } + + async readInstalledIndex(): Promise { + const index = await this.readIndex(); + return clone(index.releases); + } + + private async resolveAndInstallLocked(input: ResolveInstallInput): Promise { + const pluginId = validPluginId(input.pluginId); + if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') { + fail('plugin_release_unavailable', 'invalid Marketplace channel'); + } + const channel = input.channel ?? 'stable'; + if (channel === 'beta' && input.explicitBeta !== true) fail('plugin_beta_selection_required'); + const binding = this.requireBinding(); + const index = await this.readIndex(); + const current = await this.getInstalledFromIndex(index, pluginId); + const installed = input.installed === undefined + ? (current ? [toInstalledInput(current)] : []) + : input.installed === null ? [] : [input.installed]; + const request: ResolveRequest = { + makeloreVersion: input.makeloreVersion, + channel, + installed, + resolveRequestId: input.resolveRequestId, + resolveRequestDigest: input.resolveRequestDigest, + }; + let resolved: MarketplaceResolveSnapshot; + try { + resolved = await this.marketplace.resolve(request); + } catch (error) { + throw this.mapMarketplaceError(error); + } + this.assertBinding(binding); + const item = resolved.items.find((candidate) => candidate.pluginId === pluginId); + if (!item) fail('plugin_release_unavailable', `Plugin ${pluginId} was not included in the resolve result`); + if (item.channel !== undefined && item.channel !== null && item.channel !== channel) { + fail('plugin_release_unavailable', 'resolve channel does not match the requested channel'); + } + if (item.action === 'unavailable') fail('plugin_release_unavailable', item.reason ?? 'Plugin Release is unavailable'); + if (item.action === 'keep') { + if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release'); + return { + status: 'kept', + pluginId, + releaseId: current.releaseId, + version: current.version, + packageRoot: current.packageRoot, + definition: current.definition, + }; + } + if (resolved.stale) fail('plugin_release_unavailable', 'stale resolve data cannot install a Release'); + if (item.action !== 'install' && item.action !== 'update') fail('plugin_release_unavailable', 'unsupported resolve action'); + if (!item.releaseId || !item.releaseAdmissionId || !item.version || !item.sha256 || !item.sizeBytes) { + fail('plugin_release_unavailable', 'resolve result is missing Release metadata'); + } + const releaseId = validReleaseId(item.releaseId); + const releaseAdmissionId = validReleaseId(item.releaseAdmissionId); + validSha(item.sha256, 'resolve sha256'); + if (!Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 1 || item.sizeBytes > this.maxArchiveBytes) { + fail('plugin_artifact_invalid', 'resolve artifact size is outside its bound'); + } + const existingRecord = index.releases.find((record) => record.pluginId === pluginId && record.releaseId === item.releaseId); + if (existingRecord && existingRecord.sha256 === item.sha256 && await isDirectoryPath(this.releaseDirectory(existingRecord))) { + const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId); + if (existing) { + return { + status: 'kept', + pluginId, + releaseId: existing.releaseId, + version: existing.version, + packageRoot: existing.packageRoot, + definition: existing.definition, + }; + } + } + let grant: DownloadGrant; + try { + grant = await this.marketplace.issueDownload({ releaseId, releaseAdmissionId }); + this.assertBinding(binding); + validateGrantAgainstResolve(item, grant, { ...input, pluginId }, this.now(), this.maxArchiveBytes); + } catch (error) { + if (error instanceof PluginPackageStoreError) throw error; + throw this.mapMarketplaceError(error); + } + let artifact: Uint8Array; + try { + artifact = await this.marketplace.downloadContent(grant); + this.assertBinding(binding); + } catch (error) { + if (error instanceof PluginPackageStoreError) throw error; + throw this.mapMarketplaceError(error); + } + const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN; + if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) { + fail('plugin_release_unavailable', 'download admission has expired'); + } + if (!(artifact instanceof Uint8Array)) fail('plugin_artifact_invalid', 'downloaded artifact is not binary data'); + if (artifact.byteLength !== grant.sizeBytes || artifact.byteLength > this.maxArchiveBytes || digest(artifact) !== grant.sha256) { + fail('plugin_artifact_invalid', 'downloaded artifact does not match its grant'); + } + const descriptor = this.buildDescriptor(grant); + if (!isMakeLoreVersionCompatible( + this.clientVersion, + descriptor.minMakeloreVersion, + descriptor.maxMakeloreVersion, + )) { + fail('plugin_incompatible_client', 'Release is incompatible with this MakeLore client'); + } + const verification = this.signatureVerifier.verify({ + keyId: grant.signingKeyId, + signature: grant.descriptorSignature, + descriptor, + artifact, + }); + if (!verification.ok) fail(mapVerificationFailure(verification.code), verification.message); + try { + await mkdir(this.rootDir, { recursive: true }); + } catch { + fail('plugin_install_failed', 'package store is unavailable'); + } + let staging: string; + try { + staging = await mkdtemp(path.join(this.rootDir, `.download-${randomUUID()}-`)); + } catch { + fail('plugin_install_failed', 'package store is unavailable'); + } + let moved = false; + try { + const archivePath = path.join(staging, 'package.zip'); + const extractedPath = path.join(staging, 'package'); + await writeFile(archivePath, toBuffer(artifact), { flag: 'wx' }); + await mkdir(extractedPath, { recursive: true }); + await extractArchive(artifact, extractedPath, { + maxExtractedBytes: this.maxExtractedBytes, + maxFiles: this.maxFiles, + maxFileBytes: this.maxFileBytes, + }); + const definition = await this.loadDefinition(extractedPath, grant, descriptor); + this.assertBinding(binding); + const packageRoot = this.releaseDirectory({ pluginId, releaseId: grant.releaseId } as InstalledReleaseRecord); + await mkdir(path.dirname(packageRoot), { recursive: true }); + if (await isDirectoryPath(packageRoot) || await pathExists(packageRoot)) { + fail('plugin_release_conflict', 'immutable Release directory already exists'); + } + await rename(extractedPath, packageRoot); + moved = true; + const record: InstalledReleaseRecord = Object.freeze({ + pluginId, + releaseId: grant.releaseId, + version: grant.version, + packageSchemaVersion: grant.packageSchemaVersion, + contractVersion: grant.contractVersion, + runtimeKind: 'skill_only', + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + installedAt: new Date(this.now()).toISOString(), + }); + const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId)); + try { + await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: [...records, record] })); + } catch { + await rm(packageRoot, { recursive: true, force: true }).catch(() => undefined); + moved = false; + throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed'); + } + return { + status: 'installed', + pluginId, + releaseId: record.releaseId, + version: record.version, + packageRoot, + definition, + }; + } catch (error) { + if (error instanceof PluginPackageStoreError) throw error; + if (error instanceof Error && error.name === 'CodingPluginManifestError') { + throw new PluginPackageStoreError('plugin_manifest_invalid', 'package manifest is invalid'); + } + throw new PluginPackageStoreError('plugin_install_failed', 'package installation failed'); + } finally { + if (moved) { + await rm(path.join(staging, 'package.zip'), { force: true }).catch(() => undefined); + } + await rm(staging, { recursive: true, force: true }).catch(() => undefined); + } + } + + private buildDescriptor(grant: DownloadGrant): PluginReleaseDescriptor { + try { + return buildPluginReleaseDescriptor({ + pluginId: grant.pluginId, + version: grant.version, + packageSchemaVersion: grant.packageSchemaVersion, + contractVersion: grant.contractVersion, + minMakeloreVersion: grant.minMakeloreVersion, + maxMakeloreVersion: grant.maxMakeloreVersion, + artifact: { sha256: grant.sha256, sizeBytes: grant.sizeBytes }, + }); + } catch (error) { + fail('plugin_artifact_invalid', error instanceof Error ? error.message : 'invalid Release descriptor'); + } + } + + private async loadDefinition( + packageRoot: string, + grant: DownloadGrant, + descriptor: PluginReleaseDescriptor, + ): Promise { + let definition: CodingPluginDefinition; + try { + definition = await loadCodingPluginDefinition(packageRoot, { + runtimeKind: 'skill_only', + acquisitionMode: 'user_acquired', + releaseId: grant.releaseId, + provenance: { source: 'marketplace', packageRoot }, + }); + } catch (error) { + if (error instanceof PluginPackageStoreError) throw error; + throw new PluginPackageStoreError('plugin_manifest_invalid', 'package manifest is invalid'); + } + if (definition.id !== descriptor.pluginId || definition.version !== descriptor.version + || definition.contractVersion !== descriptor.contractVersion || definition.releaseId !== grant.releaseId + || definition.runtimeKind !== 'skill_only' || definition.acquisitionMode !== 'user_acquired') { + fail('plugin_manifest_invalid', 'package definition does not match the signed Release'); + } + return definition; + } + + private requireBinding(): AccountBinding { + const binding = this.getAccountBindingImpl(); + if (!binding || typeof binding.accountKey !== 'string' || binding.accountKey.length === 0 + || binding.accountKey.length > 512 || !Number.isSafeInteger(binding.epoch) || binding.epoch < 0) { + fail('plugin_account_changed', 'Marketplace account session is unavailable'); + } + return { accountKey: binding.accountKey, epoch: binding.epoch }; + } + + private assertBinding(binding: AccountBinding): void { + if (!sameBinding(this.getAccountBindingImpl(), binding)) fail('plugin_account_changed', 'Marketplace account changed during package operation'); + } + + private activeWorkerReleaseIds(): readonly string[] { + return this.activeWorkerReleaseIdsImpl?.() ?? []; + } + + private async readIndex(): Promise { + try { + return parseIndexDocument(await parseJsonFile(this.indexPath)); + } catch (error) { + if (isNotFound(error)) return { schema_version: INDEX_SCHEMA_VERSION, releases: [] }; + if (error instanceof PluginPackageStoreError) throw error; + throw new PluginPackageStoreError('plugin_store_index_invalid', 'package index is unavailable'); + } + } + + private releaseDirectory(record: Pick): string { + return path.join(this.packagesDir, record.pluginId, record.releaseId); + } + + private async getInstalledFromIndex( + index: IndexDocument, + pluginId: string, + releaseId?: string, + ): Promise { + const records = index.releases + .map((record, index) => ({ record, index })) + .filter(({ record }) => record.pluginId === pluginId && (releaseId === undefined || record.releaseId === releaseId)) + .sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index) + .map(({ record }) => record); + for (const record of records) { + const packageRoot = this.releaseDirectory(record); + if (!await isDirectoryPath(packageRoot)) continue; + try { + const definition = await loadCodingPluginDefinition(packageRoot, { + runtimeKind: record.runtimeKind, + acquisitionMode: 'user_acquired', + releaseId: record.releaseId, + provenance: { source: 'marketplace', packageRoot }, + }); + if (definition.id !== record.pluginId || definition.version !== record.version + || definition.contractVersion !== record.contractVersion || definition.runtimeKind !== record.runtimeKind) { + throw new PluginPackageStoreError('plugin_manifest_invalid', 'installed package does not match its immutable index record'); + } + return { ...record, packageRoot, definition }; + } catch (error) { + if (error instanceof PluginPackageStoreError) throw error; + throw new PluginPackageStoreError('plugin_manifest_invalid', 'installed package manifest is invalid'); + } + } + return null; + } + + private mapMarketplaceError(error: unknown): PluginPackageStoreError { + if (error instanceof PluginPackageStoreError) return error; + if (isRecord(error) && error.code === 'marketplace_account_changed') { + return new PluginPackageStoreError('plugin_account_changed', 'Marketplace account changed during package operation'); + } + if (isRecord(error) && error.code === 'marketplace_beta_selection_required') { + return new PluginPackageStoreError('plugin_beta_selection_required'); + } + return new PluginPackageStoreError('plugin_install_failed', 'Marketplace package operation failed'); + } + + private async withOperation(operation: () => Promise): Promise { + let release!: () => void; + const previous = this.operation; + this.operation = new Promise((resolve) => { release = resolve; }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } +} + +async function pathExists(value: string): Promise { + try { + await lstat(value); + return true; + } catch { + return false; + } +} + +export async function readPluginPackageIndex(filePath: string): Promise { + try { + const value = parseIndexDocument(await parseJsonFile(filePath)); + return clone(value.releases); + } catch (error) { + if (error instanceof PluginPackageStoreError) throw error; + throw new PluginPackageStoreError('plugin_store_index_invalid', 'package index is unavailable'); + } +} diff --git a/tests/unit/coding-plugin-marketplace-client.test.ts b/tests/unit/coding-plugin-marketplace-client.test.ts new file mode 100644 index 0000000..74049e0 --- /dev/null +++ b/tests/unit/coding-plugin-marketplace-client.test.ts @@ -0,0 +1,619 @@ +// @vitest-environment node + +import { createHash, generateKeyPairSync, sign } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import AdmZip from 'adm-zip'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + AccountPluginCache, + type AccountBinding, + type MarketplaceLibrarySnapshot, +} from '../../electron/coding-plugins/account-plugin-cache'; +import { + MarketplaceClientError, + createMarketplaceClient, + type MarketplaceClient, + type ResolveRequest, +} from '../../electron/coding-plugins/marketplace-client'; +import { + PluginPackageStore, + type DownloadGrant, + type ResolveSnapshot, +} from '../../electron/coding-plugins/package-store'; +import { + buildPluginReleaseDescriptor, + serializePluginReleaseDescriptor, +} from '../../electron/coding-plugins/release-descriptor'; + +const ACCOUNT_A: AccountBinding = { accountKey: 'a'.repeat(64), epoch: 1 }; +const ACCOUNT_B: AccountBinding = { accountKey: 'b'.repeat(64), epoch: 2 }; +const PLUGIN_ID = 'makelore.example'; +const RELEASE_ID = 'release-1'; +const ADMISSION_ID = 'admission-1'; +const SHA256 = 'a'.repeat(64); + +const catalogPage = { + items: [{ + plugin_id: PLUGIN_ID, + title: 'Example', + summary: 'Example Skill', + category: 'tools', + tags: ['example'], + provider_display_name: 'MakeLore', + runtime_kind: 'skill_only', + runtime_status: 'enabled', + acquisition: 'free', + usage_billing: 'included', + included_operation_count: 0, + metered_operation_count: 0, + stable_version: '1.0.0', + beta_version: null, + }], + next_cursor: null, + total: 1, + catalog_generation: 7, +}; + +function response(body: unknown, init: ResponseInit = {}, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json', ...headers }, + ...init, + }); +} + +function requestBody(fetcher: ReturnType, index: number): Record { + return JSON.parse(fetcher.mock.calls[index]?.[1]?.body as string) as Record; +} + +function makeResolveResult(input: ResolveRequest, itemOverrides: Record = {}): ResolveSnapshot { + return { + resolveRequestId: input.resolveRequestId ?? 'generated', + resolveRequestDigest: input.resolveRequestDigest ?? SHA256, + items: [{ + pluginId: PLUGIN_ID, + action: 'install', + releaseId: RELEASE_ID, + version: '1.0.0', + sha256: SHA256, + sizeBytes: 1, + releaseAdmissionId: ADMISSION_ID, + expiresAt: '2026-08-29T00:00:00Z', + channel: input.channel, + reason: null, + ...itemOverrides, + }], + catalogGeneration: 7, + etag: '"plugins-7-tp-none"', + stale: false, + }; +} + +function buildSkillOnlyArchive(): Buffer { + const zip = new AdmZip(); + zip.addFile('plugin.json', Buffer.from(JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: PLUGIN_ID, + version: '1.0.0', + description: 'Example Skill', + author: { name: 'MakeLore' }, + extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } }, + }))); + zip.addFile('com.makelore/capability.json', Buffer.from(JSON.stringify({ + schemaVersion: 2, + pluginId: PLUGIN_ID, + contractVersion: 1, + scope: 'project', + runtime: { kind: 'skill_only' }, + skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: [] }], + tools: [], + }))); + zip.addFile('skills/example-skill/SKILL.md', Buffer.from('# Example\n')); + return zip.toBuffer(); +} + +function signedGrant( + archive: Buffer, + options: { + readonly releaseId?: string; + readonly minMakeloreVersion?: string; + readonly maxMakeloreVersion?: string | null; + } = {}, +): { grant: DownloadGrant; publicKey: Buffer; signature: string } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const sha256 = createHash('sha256').update(archive).digest('hex'); + const releaseId = options.releaseId ?? RELEASE_ID; + const descriptor = buildPluginReleaseDescriptor({ + pluginId: PLUGIN_ID, + version: '1.0.0', + packageSchemaVersion: 2, + contractVersion: 1, + minMakeloreVersion: options.minMakeloreVersion ?? '1.0.0', + maxMakeloreVersion: options.maxMakeloreVersion ?? null, + artifact: { sha256, sizeBytes: archive.byteLength }, + }); + const signature = sign(null, serializePluginReleaseDescriptor(descriptor), privateKey).toString('base64url'); + return { + grant: { + releaseAdmissionId: ADMISSION_ID, + releaseId, + pluginId: PLUGIN_ID, + version: '1.0.0', + packageSchemaVersion: 2, + contractVersion: 1, + minMakeloreVersion: options.minMakeloreVersion ?? '1.0.0', + maxMakeloreVersion: options.maxMakeloreVersion ?? null, + sizeBytes: archive.byteLength, + sha256, + signingKeyId: 'test-key', + descriptorSignature: signature, + expiresAt: '2026-08-29T00:00:00Z', + contentUrl: `/api/plugin-marketplace/v1/releases/${releaseId}/content?release_admission_id=${ADMISSION_ID}`, + }, + publicKey: publicKey.export({ type: 'spki', format: 'der' }) as Buffer, + signature, + }; +} + +describe('Marketplace client and account cache', () => { + afterEach(() => vi.restoreAllMocks()); + + it('keeps Library and admission snapshots isolated by account and invalidates on logout', () => { + const cache = new AccountPluginCache(); + const aLibrary: MarketplaceLibrarySnapshot = { + items: [{ + pluginId: PLUGIN_ID, + title: 'A', + summary: 'A', + category: 'tools', + acquisition: 'free', + acquisitionMode: 'user_acquired', + catalogStatus: 'active', + runtimeStatus: 'enabled', + acquiredAt: null, + removedAt: null, + stableVersion: '1.0.0', + betaVersion: null, + }], + total: 1, + stale: false, + fetchedAt: 1, + }; + cache.setLibrary(ACCOUNT_A, aLibrary); + cache.setResolve(ACCOUNT_A, 'resolve-a', { + resolveRequestId: 'resolve-a', + resolveRequestDigest: SHA256, + items: [{ pluginId: PLUGIN_ID, action: 'install', releaseId: RELEASE_ID }], + catalogGeneration: 1, + etag: null, + stale: false, + }); + expect(cache.getLibrary(ACCOUNT_A)).toEqual(aLibrary); + expect(cache.getLibrary(ACCOUNT_B)).toBeNull(); + expect(cache.getResolve(ACCOUNT_B, 'resolve-a')).toBeNull(); + expect(cache.referencedReleaseIds()).toEqual(new Set([RELEASE_ID])); + cache.invalidateAll(); + expect(cache.getLibrary(ACCOUNT_A)).toBeNull(); + expect(cache.referencedReleaseIds()).toEqual(new Set()); + }); + + it('parses bounded catalog metadata, refreshes exactly once after a 401, and marks stale data', async () => { + const fetcher = vi.fn(); + fetcher + .mockResolvedValueOnce(new Response('', { status: 401 })) + .mockResolvedValueOnce(response(catalogPage, {}, { + ETag: '"plugins-7-tp-none"', + 'X-Plugin-Catalog-Generation': '7', + 'X-Token-Point-Pricing-Version': 'none', + })); + const refresh = vi.fn(async () => 'refreshed-token'); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async (options) => options?.forceRefresh ? refresh() : 'initial-token', + getAccountBinding: () => ACCOUNT_A, + subscribeSession: () => () => undefined, + }); + await expect(client.readCatalog({ limit: 10 })).resolves.toMatchObject({ + total: 1, + etag: '"plugins-7-tp-none"', + stale: false, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer refreshed-token' }); + + fetcher.mockRejectedValueOnce(new Error('offline')); + await expect(client.readCatalog({ limit: 10 })).resolves.toMatchObject({ stale: true, total: 1 }); + }); + + it('refreshes download authentication at most once before accepting the artifact', async () => { + const archive = Buffer.from('signed-artifact'); + const { grant } = signedGrant(archive); + const fetcher = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce(new Response(archive, { + status: 200, + headers: { 'content-length': String(archive.byteLength) }, + })); + const refresh = vi.fn(async () => 'refreshed-token'); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async (options) => options?.forceRefresh ? refresh() : 'initial-token', + getAccountBinding: () => ACCOUNT_A, + subscribeSession: () => () => undefined, + }); + const downloaded = await client.downloadContent(grant); + expect(Buffer.from(downloaded)).toEqual(archive); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer refreshed-token' }); + }); + + it('parses the detail DTO with its nested release projection', async () => { + const fetcher = vi.fn().mockResolvedValue(response({ + ...catalogPage.items[0], + description_markdown: 'Example details', + permissions: ['plugin.example.read'], + operations: [], + stable_release: null, + beta_release: null, + }, {}, { + ETag: '"plugins-7-tp-none"', + 'X-Plugin-Catalog-Generation': '7', + 'X-Token-Point-Pricing-Version': 'none', + })); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async () => null, + subscribeSession: () => () => undefined, + }); + await expect(client.readDetail(PLUGIN_ID)).resolves.toMatchObject({ + pluginId: PLUGIN_ID, + descriptionMarkdown: 'Example details', + permissions: ['plugin.example.read'], + etag: '"plugins-7-tp-none"', + }); + }); + + it('derives stable resolve identity from one logical request and changes it when installed state changes', async () => { + const fetcher = vi.fn().mockImplementation(async (_input, init) => { + const request = JSON.parse(init?.body as string) as Record; + return response({ + resolve_request_id: request.resolve_request_id, + resolve_request_digest: request.resolve_request_digest, + items: [], + catalog_generation: 7, + }, {}, { ETag: '"plugins-7-tp-none"' }); + }); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async () => 'token', + getAccountBinding: () => ACCOUNT_A, + subscribeSession: () => () => undefined, + }); + const base: ResolveRequest = { + makeloreVersion: '1.0.0', + channel: 'stable', + installed: [], + }; + await client.resolve(base); + await client.resolve(base); + const firstId = requestBody(fetcher, 0).resolve_request_id; + const secondId = requestBody(fetcher, 1).resolve_request_id; + expect(firstId).toBe(secondId); + await client.resolve({ ...base, installed: [{ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, sha256: SHA256 }] }); + expect(requestBody(fetcher, 2).resolve_request_id).not.toBe(firstId); + }); + + it('rejects a response body above the bounded DTO limit', async () => { + const fetcher = vi.fn().mockResolvedValue(new Response('x'.repeat(2_100_000), { status: 200 })); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async () => null, + subscribeSession: () => () => undefined, + }); + await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({ code: 'marketplace_response_too_large' }); + }); + + it('rejects a malformed authenticated response with a stable client error', async () => { + const fetcher = vi.fn().mockResolvedValue(response({ items: [] })); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async () => 'token', + getAccountBinding: () => ACCOUNT_A, + subscribeSession: () => () => undefined, + }); + await expect(client.readLibrary()).rejects.toBeInstanceOf(MarketplaceClientError); + }); +}); + +describe('PluginPackageStore', () => { + let temporaryRoot: string | null = null; + + afterEach(async () => { + if (temporaryRoot) await rm(temporaryRoot, { recursive: true, force: true }); + temporaryRoot = null; + }); + + it('verifies and atomically installs a signed Skill-only package without account data in index', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const { grant, publicKey } = signedGrant(archive); + const resolve = vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + })); + const issueDownload = vi.fn(async () => grant); + const marketplace: MarketplaceClient = { + resolve, + issueDownload, + downloadContent: async () => archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', publicKey]]), + accountCache: new AccountPluginCache(), + getAccountBinding: () => ACCOUNT_A, + }); + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) + .resolves.toMatchObject({ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, status: 'installed' }); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ + pluginId: PLUGIN_ID, + releaseId: RELEASE_ID, + version: '1.0.0', + }); + const index = JSON.parse(await readFile(path.join(temporaryRoot, 'index.json'), 'utf8')) as Record; + expect(JSON.stringify(index)).not.toContain('account'); + expect(JSON.stringify(index)).not.toContain('admission'); + expect(JSON.stringify(index)).not.toContain('token'); + expect(resolve).toHaveBeenCalledTimes(1); + expect(issueDownload).toHaveBeenCalledWith({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID }); + }); + + it('preserves the old immutable release when index replacement fails', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const { grant, publicKey } = signedGrant(archive); + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + })), + issueDownload: vi.fn(async () => grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', publicKey]]), + getAccountBinding: () => ACCOUNT_A, + }); + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + const replacement = signedGrant(archive, { releaseId: 'release-2' }); + const replacementMarketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + releaseId: replacement.grant.releaseId, + sha256: replacement.grant.sha256, + sizeBytes: replacement.grant.sizeBytes, + })), + issueDownload: vi.fn(async () => replacement.grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const failingStore = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace: replacementMarketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', replacement.publicKey]]), + getAccountBinding: () => ACCOUNT_A, + writeIndex: async () => { throw new Error('simulated index interruption'); }, + }); + await expect(failingStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) + .rejects.toMatchObject({ code: 'plugin_install_failed' }); + await expect(failingStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID }); + }); + + it('preserves the old release across download, signature, and extraction failures', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const oldArchive = buildSkillOnlyArchive(); + const old = signedGrant(oldArchive, { releaseId: RELEASE_ID }); + const oldMarketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: old.grant.sha256, + sizeBytes: old.grant.sizeBytes, + })), + issueDownload: vi.fn(async () => old.grant), + downloadContent: async () => oldArchive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const oldStore = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace: oldMarketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', old.publicKey]]), + getAccountBinding: () => ACCOUNT_A, + }); + await oldStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + + const invalidArchive = Buffer.from('not a ZIP archive'); + const cases = [ + { + releaseId: 'release-download-failure', + expectedCode: 'plugin_install_failed', + artifact: oldArchive, + }, + { + releaseId: 'release-signature-failure', + expectedCode: 'plugin_signature_invalid', + artifact: oldArchive, + }, + { + releaseId: 'release-extraction-failure', + expectedCode: 'plugin_artifact_invalid', + artifact: invalidArchive, + }, + ] as const; + + for (const [index, scenario] of cases.entries()) { + const signed = signedGrant(scenario.artifact, { releaseId: scenario.releaseId }); + const grant = scenario.expectedCode === 'plugin_signature_invalid' + ? { ...signed.grant, descriptorSignature: signed.grant.descriptorSignature[0] === 'A' + ? `B${signed.grant.descriptorSignature.slice(1)}` + : `A${signed.grant.descriptorSignature.slice(1)}` } + : signed.grant; + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + releaseId: grant.releaseId, + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + })), + issueDownload: vi.fn(async () => grant), + downloadContent: async () => { + if (index === 0) throw new Error('simulated download interruption'); + return scenario.artifact; + }, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', signed.publicKey]]), + getAccountBinding: () => ACCOUNT_A, + }); + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) + .rejects.toMatchObject({ code: scenario.expectedCode }); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ + releaseId: RELEASE_ID, + version: '1.0.0', + }); + await expect(store.readInstalledIndex()).resolves.toHaveLength(1); + } + }); + + it('rejects a Release outside the MakeLore client range before installation', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const { grant, publicKey } = signedGrant(archive, { maxMakeloreVersion: '1.5.0' }); + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + })), + issueDownload: vi.fn(async () => grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + clientVersion: '2.0.0', + keyStore: new Map([['test-key', publicKey]]), + getAccountBinding: () => ACCOUNT_A, + }); + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '2.0.0' })) + .rejects.toMatchObject({ code: 'plugin_incompatible_client' }); + await expect(store.readInstalledIndex()).resolves.toEqual([]); + }); + + it('aborts an in-flight install when the Main account changes', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const { grant, publicKey } = signedGrant(archive); + let binding: AccountBinding | null = ACCOUNT_A; + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + })), + issueDownload: vi.fn(async () => grant), + downloadContent: vi.fn(async () => { + binding = ACCOUNT_B; + return archive; + }), + getCurrentAccountBinding: () => binding, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + keyStore: new Map([['test-key', publicKey]]), + getAccountBinding: () => binding, + }); + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) + .rejects.toMatchObject({ code: 'plugin_account_changed' }); + await expect(store.readInstalledIndex()).resolves.toEqual([]); + }); + + it('rejects archive traversal before materializing a package', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const zip = new AdmZip(); + zip.addFile('C:/outside.txt', Buffer.from('outside')); + const archive = zip.toBuffer(); + const { grant, publicKey } = signedGrant(archive); + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + })), + issueDownload: vi.fn(async () => grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + keyStore: new Map([['test-key', publicKey]]), + getAccountBinding: () => ACCOUNT_A, + }); + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) + .rejects.toMatchObject({ code: 'plugin_artifact_invalid' }); + await expect(store.readInstalledIndex()).resolves.toEqual([]); + }); + + it('requires explicit beta selection and rejects content paths outside the server route', async () => { + const fetcher = vi.fn().mockResolvedValue(response({ + release_admission_id: ADMISSION_ID, + release_id: RELEASE_ID, + plugin_id: PLUGIN_ID, + version: '1.0.0', + package_schema_version: 2, + contract_version: 1, + min_makelore_version: '1.0.0', + max_makelore_version: null, + size_bytes: 1, + sha256: SHA256, + signing_key_id: 'test-key', + descriptor_signature: 'a'.repeat(86), + expires_at: '2026-08-29T00:00:00Z', + content_url: 'https://evil.example/archive.zip', + })); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async () => 'token', + getAccountBinding: () => ACCOUNT_A, + subscribeSession: () => () => undefined, + }); + await expect(client.issueDownload({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID })) + .rejects.toMatchObject({ code: 'marketplace_response_invalid' }); + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input)), + issueDownload: vi.fn(), + } as MarketplaceClient; + const store = new PluginPackageStore({ rootDir: await mkdtemp(path.join(process.cwd(), '.marketplace-test-')), marketplace }); + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, channel: 'beta', makeloreVersion: '1.0.0' })) + .rejects.toMatchObject({ code: 'plugin_beta_selection_required' }); + }); +});