1219 lines
53 KiB
TypeScript
1219 lines
53 KiB
TypeScript
import { Buffer } from 'node:buffer';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
|
import {
|
|
getValidWorksSquareAccessToken,
|
|
getWorksSquareAccountBinding,
|
|
subscribeWorksSquareSession,
|
|
} from '../services/works-square-session';
|
|
import { proxyAwareFetch, runWithDeadline } 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 = /^(?:W\/)?"plugins-(\d+)-tp-([A-Za-z0-9._-]{1,128})"$/u;
|
|
|
|
type UnknownRecord = Record<string, unknown>;
|
|
type FetchImplementation = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
|
|
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<string | null>;
|
|
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'
|
|
| 'plugin_auth_required'
|
|
| 'plugin_account_changed'
|
|
| 'plugin_library_required'
|
|
| 'plugin_release_not_ready'
|
|
| 'plugin_release_yanked'
|
|
| 'plugin_incompatible_client'
|
|
| 'plugin_signature_invalid'
|
|
| 'plugin_artifact_invalid'
|
|
| 'plugin_runtime_suspended'
|
|
| 'plugin_backend_unavailable';
|
|
|
|
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<T> {
|
|
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);
|
|
}
|
|
|
|
const SERVER_ERROR_CODES = new Set<MarketplaceErrorCode>([
|
|
'plugin_auth_required',
|
|
'plugin_account_changed',
|
|
'plugin_library_required',
|
|
'plugin_release_not_ready',
|
|
'plugin_release_yanked',
|
|
'plugin_incompatible_client',
|
|
'plugin_signature_invalid',
|
|
'plugin_artifact_invalid',
|
|
'plugin_runtime_suspended',
|
|
'plugin_backend_unavailable',
|
|
]);
|
|
|
|
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<T>(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<CatalogPage, 'etag' | 'pricingVersionId' | 'stale' | 'fetchedAt'> {
|
|
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<PluginDetail, 'etag' | 'pricingVersionId' | 'stale' | 'fetchedAt'> {
|
|
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<MarketplaceLibrarySnapshot, 'stale' | 'fetchedAt'> {
|
|
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<MarketplaceResolveSnapshot, 'etag' | 'stale'> {
|
|
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<Uint8Array> {
|
|
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)));
|
|
}
|
|
|
|
function parseJsonBytes(bytes: Uint8Array): unknown {
|
|
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 failForResponse(
|
|
response: Response,
|
|
bytes: Uint8Array | null,
|
|
fallbackMessage: string,
|
|
): never {
|
|
let code: MarketplaceErrorCode | null = null;
|
|
let responseMessage = fallbackMessage;
|
|
if (bytes && bytes.byteLength > 0) {
|
|
try {
|
|
const payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown;
|
|
if (isRecord(payload)) {
|
|
if (typeof payload.code === 'string' && SERVER_ERROR_CODES.has(payload.code as MarketplaceErrorCode)) {
|
|
code = payload.code as MarketplaceErrorCode;
|
|
}
|
|
if (typeof payload.error === 'string' && payload.error.length > 0 && payload.error.length <= 256) {
|
|
responseMessage = payload.error;
|
|
}
|
|
}
|
|
} catch {
|
|
// A malformed error body must stay a bounded generic client failure.
|
|
}
|
|
}
|
|
fail(code ?? 'marketplace_request_failed', responseMessage, response.status);
|
|
}
|
|
|
|
async function fetchResponseWithBodyDeadline(
|
|
fetchImpl: FetchImplementation,
|
|
input: string | URL,
|
|
init: RequestInit,
|
|
timeoutMs: number,
|
|
maximum: number,
|
|
): Promise<{ response: Response; bytes: Uint8Array | null }> {
|
|
return runWithDeadline(async (signal) => {
|
|
const response = await fetchImpl(input, { ...init, signal });
|
|
if (response.status >= 300 && response.status < 400) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
return { response, bytes: null };
|
|
}
|
|
return { response, bytes: await readBoundedBytes(response, maximum) };
|
|
}, timeoutMs, init.signal);
|
|
}
|
|
|
|
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<ResolveRequest, 'makeloreVersion' | 'channel' | 'installed'>,
|
|
): 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<string, unknown>;
|
|
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;
|
|
// The digest identifies request content; the ID identifies one logical sync.
|
|
// Deriving both from content would replay an expired Admission forever.
|
|
const requestId = validateRequestId(requestedId ?? `makelore-resolve-${randomUUID()}`);
|
|
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<CatalogPage>;
|
|
readDetail(pluginId: string): Promise<PluginDetail>;
|
|
readLibrary(): Promise<MarketplaceLibrarySnapshot>;
|
|
acquire(pluginId: string): Promise<MarketplaceLibrarySnapshot>;
|
|
remove(pluginId: string): Promise<MarketplaceLibrarySnapshot>;
|
|
resolve(input: ResolveRequest): Promise<MarketplaceResolveSnapshot>;
|
|
issueDownload(input: DownloadRequest): Promise<DownloadGrant>;
|
|
downloadContent(grant: DownloadGrant): Promise<Uint8Array>;
|
|
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<string, CatalogPage>();
|
|
private readonly detailCache = new Map<string, PluginDetail>();
|
|
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<CatalogPage> {
|
|
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<PluginDetail> {
|
|
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<MarketplaceLibrarySnapshot> {
|
|
const binding = this.requireBinding();
|
|
const intent = this.accountCache.beginLibraryIntent(binding);
|
|
return this.readLibraryForIntent(binding, intent);
|
|
}
|
|
|
|
private async readLibraryForIntent(
|
|
binding: AccountBinding,
|
|
intent: number,
|
|
): Promise<MarketplaceLibrarySnapshot> {
|
|
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);
|
|
if (!this.accountCache.commitLibrary(binding, intent, snapshot)) {
|
|
return clone(this.accountCache.getLibrary(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, intent);
|
|
if (stale) return clone(stale);
|
|
}
|
|
throw normalizeError(error);
|
|
}
|
|
}
|
|
|
|
async acquire(pluginId: string): Promise<MarketplaceLibrarySnapshot> {
|
|
return this.mutateLibrary('PUT', pluginId);
|
|
}
|
|
|
|
async remove(pluginId: string): Promise<MarketplaceLibrarySnapshot> {
|
|
return this.mutateLibrary('DELETE', pluginId);
|
|
}
|
|
|
|
async resolve(input: ResolveRequest): Promise<MarketplaceResolveSnapshot> {
|
|
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<DownloadGrant> {
|
|
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<Uint8Array> {
|
|
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, bytes } = await fetchResponseWithBodyDeadline(
|
|
this.fetchImpl as typeof fetch,
|
|
url,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/zip',
|
|
...(sendAuthorization ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
redirect: 'manual',
|
|
},
|
|
this.requestTimeoutMs,
|
|
Math.min(this.maxArtifactBytes, grant.sizeBytes),
|
|
);
|
|
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) {
|
|
failForResponse(response, bytes, 'Marketplace download failed');
|
|
}
|
|
if (!bytes) fail('marketplace_download_invalid', 'Marketplace response body is unavailable');
|
|
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<MarketplaceLibrarySnapshot> {
|
|
const validated = idValue(pluginId, 'pluginId');
|
|
const binding = this.requireBinding();
|
|
const intent = this.accountCache.beginLibraryIntent(binding);
|
|
const previous = this.accountCache.getLibrary(binding);
|
|
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.readLibraryForIntent(binding, intent);
|
|
} catch (error) {
|
|
if (!this.bindingMatches(binding)) {
|
|
fail('marketplace_account_changed', 'Marketplace account changed while the request was active');
|
|
}
|
|
if (!canServeStale(error)) throw normalizeError(error);
|
|
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,
|
|
};
|
|
if (this.accountCache.commitLibrary(binding, intent, snapshot)) return clone(snapshot);
|
|
return clone(this.accountCache.getLibrary(binding) ?? 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<T>(
|
|
route: string,
|
|
options: {
|
|
readonly auth: 'required' | 'optional';
|
|
readonly method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
readonly body?: Record<string, unknown>;
|
|
readonly etag?: string;
|
|
},
|
|
parser: (value: unknown) => T,
|
|
): Promise<RequestResult<T>> {
|
|
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<string, string> = {
|
|
Accept: 'application/json',
|
|
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...(options.etag ? { 'If-None-Match': options.etag } : {}),
|
|
};
|
|
const { response, bytes } = await fetchResponseWithBodyDeadline(
|
|
this.fetchImpl as typeof fetch,
|
|
url,
|
|
{ method, headers, body, redirect: 'manual' },
|
|
this.requestTimeoutMs,
|
|
this.maxResponseBytes,
|
|
);
|
|
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) {
|
|
failForResponse(response, bytes, 'Marketplace request failed');
|
|
}
|
|
if (!bytes) fail('marketplace_response_invalid', 'Marketplace response body is unavailable');
|
|
const value = parser(parseJsonBytes(bytes));
|
|
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;
|