223 lines
7.9 KiB
TypeScript
223 lines
7.9 KiB
TypeScript
import type { MarketplaceResolveItem } from './account-plugin-cache';
|
|
import type { MarketplacePackageClientPort, PluginPackageStore } from './package-store';
|
|
|
|
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 REQUEST_ID_PATTERN = /^[\x21-\x7e]{1,128}$/u;
|
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
|
|
export interface HostedAdmissionWorkerSnapshot {
|
|
/** Main-issued identity for the logical worker operation. */
|
|
readonly requestId: string;
|
|
/** Release frozen into this parent worker's effective snapshot. */
|
|
readonly pluginReleaseId?: string;
|
|
}
|
|
|
|
export interface MarketplaceHostedAdmissionInput {
|
|
readonly pluginId: string;
|
|
readonly workerSnapshot: HostedAdmissionWorkerSnapshot;
|
|
}
|
|
|
|
export interface MarketplaceHostedAdmission {
|
|
readonly releaseId: string;
|
|
readonly releaseAdmissionId: string;
|
|
}
|
|
|
|
export type MarketplaceHostedAdmissionErrorCode =
|
|
| 'plugin_release_unavailable'
|
|
| 'plugin_runtime_stale'
|
|
| 'plugin_account_changed'
|
|
| 'plugin_backend_unavailable';
|
|
|
|
export class MarketplaceHostedAdmissionError extends Error {
|
|
constructor(
|
|
readonly code: MarketplaceHostedAdmissionErrorCode,
|
|
readonly status: number,
|
|
readonly retryable: boolean,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'MarketplaceHostedAdmissionError';
|
|
}
|
|
}
|
|
|
|
export interface MarketplaceHostedAdmissionResolverOptions {
|
|
readonly packageStore: Pick<PluginPackageStore, 'getInstalledRelease'>;
|
|
readonly marketplace: Pick<MarketplacePackageClientPort, 'resolve'>;
|
|
readonly makeloreVersion: string;
|
|
readonly bundledReleases?: Readonly<Record<string, BundledHostedRelease>>;
|
|
}
|
|
|
|
export interface BundledHostedRelease {
|
|
readonly releaseId: string;
|
|
readonly version: string;
|
|
readonly channel: 'stable';
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function errorCode(value: unknown): unknown {
|
|
return isRecord(value) ? value.code : undefined;
|
|
}
|
|
|
|
function releaseId(value: unknown): string | null {
|
|
return typeof value === 'string' && RELEASE_ID_PATTERN.test(value) ? value : null;
|
|
}
|
|
|
|
function pluginId(value: unknown): string | null {
|
|
return typeof value === 'string' && PLUGIN_ID_PATTERN.test(value) ? value : null;
|
|
}
|
|
|
|
function requestId(value: unknown): string | null {
|
|
return typeof value === 'string' && REQUEST_ID_PATTERN.test(value) ? value : null;
|
|
}
|
|
|
|
function stale(message: string): MarketplaceHostedAdmissionError {
|
|
return new MarketplaceHostedAdmissionError('plugin_runtime_stale', 409, false, message);
|
|
}
|
|
|
|
function unavailable(message: string): MarketplaceHostedAdmissionError {
|
|
return new MarketplaceHostedAdmissionError('plugin_release_unavailable', 409, false, message);
|
|
}
|
|
|
|
function backendUnavailable(): MarketplaceHostedAdmissionError {
|
|
return new MarketplaceHostedAdmissionError(
|
|
'plugin_backend_unavailable',
|
|
503,
|
|
true,
|
|
'Hosted Plugin admission service is temporarily unavailable',
|
|
);
|
|
}
|
|
|
|
function accountChanged(): MarketplaceHostedAdmissionError {
|
|
return new MarketplaceHostedAdmissionError(
|
|
'plugin_account_changed',
|
|
409,
|
|
false,
|
|
'Marketplace account changed while resolving hosted Plugin admission',
|
|
);
|
|
}
|
|
|
|
function matchesFrozenRelease(
|
|
item: MarketplaceResolveItem,
|
|
installed: Awaited<ReturnType<PluginPackageStore['getInstalledRelease']>>,
|
|
plugin: string,
|
|
): item is MarketplaceResolveItem & {
|
|
readonly releaseId: string;
|
|
readonly releaseAdmissionId: string;
|
|
} {
|
|
if (!installed) return false;
|
|
if (item.pluginId !== plugin || (item.action !== 'keep' && item.action !== 'install')) return false;
|
|
if (releaseId(item.releaseId) !== installed.releaseId
|
|
|| releaseId(item.releaseAdmissionId) === null
|
|
|| item.releaseAdmissionId === undefined
|
|
|| item.releaseAdmissionId === null) return false;
|
|
if (item.version !== installed.version || item.sha256 !== installed.sha256) return false;
|
|
if (item.sizeBytes !== installed.sizeBytes || item.channel !== installed.channel) return false;
|
|
return item.sha256 !== null && item.sha256 !== undefined && SHA256_PATTERN.test(item.sha256);
|
|
}
|
|
|
|
function matchesBundledFrozenRelease(
|
|
item: MarketplaceResolveItem,
|
|
bundled: BundledHostedRelease,
|
|
plugin: string,
|
|
): item is MarketplaceResolveItem & {
|
|
readonly releaseId: string;
|
|
readonly releaseAdmissionId: string;
|
|
} {
|
|
return item.pluginId === plugin
|
|
&& item.action === 'keep'
|
|
&& releaseId(item.releaseId) === bundled.releaseId
|
|
&& item.version === bundled.version
|
|
&& item.channel === bundled.channel
|
|
&& releaseId(item.releaseAdmissionId) !== null
|
|
&& item.sha256 == null
|
|
&& item.sizeBytes == null;
|
|
}
|
|
|
|
/**
|
|
* Resolves the current account admission for the exact Release frozen into a
|
|
* parent worker. Marketplace identity and Release freshness are the only
|
|
* concerns here; provider, payload, billing, and transport routes remain
|
|
* outside this boundary.
|
|
*/
|
|
export class MarketplaceHostedAdmissionResolver {
|
|
constructor(private readonly options: MarketplaceHostedAdmissionResolverOptions) {}
|
|
|
|
async resolve(input: MarketplaceHostedAdmissionInput): Promise<MarketplaceHostedAdmission> {
|
|
const plugin = pluginId(input?.pluginId);
|
|
const worker = input?.workerSnapshot;
|
|
const request = requestId(worker?.requestId);
|
|
const frozenRelease = releaseId(worker?.pluginReleaseId);
|
|
if (!plugin || !request) throw unavailable('Hosted Plugin worker identity is unavailable');
|
|
if (!frozenRelease) throw unavailable('Hosted Plugin worker Release is unavailable');
|
|
|
|
const bundled = this.options.bundledReleases?.[plugin];
|
|
let installed: Awaited<ReturnType<PluginPackageStore['getInstalledRelease']>> = null;
|
|
if (bundled) {
|
|
if (releaseId(bundled.releaseId) !== frozenRelease || bundled.channel !== 'stable') {
|
|
throw unavailable('Bundled hosted Plugin Release is unavailable');
|
|
}
|
|
} else {
|
|
try {
|
|
installed = await this.options.packageStore.getInstalledRelease(plugin, frozenRelease);
|
|
} catch (error) {
|
|
throw this.mapError(error);
|
|
}
|
|
if (!installed || installed.pluginId !== plugin || installed.releaseId !== frozenRelease
|
|
|| installed.unavailableReason || !installed.channel) {
|
|
throw unavailable('Installed hosted Plugin Release is unavailable');
|
|
}
|
|
}
|
|
|
|
let resolved;
|
|
try {
|
|
resolved = await this.options.marketplace.resolve({
|
|
resolveRequestId: request,
|
|
makeloreVersion: this.options.makeloreVersion,
|
|
channel: bundled?.channel ?? installed?.channel ?? 'stable',
|
|
installed: bundled ? [] : [{
|
|
pluginId: installed!.pluginId,
|
|
releaseId: installed!.releaseId,
|
|
sha256: installed!.sha256,
|
|
}],
|
|
});
|
|
} catch (error) {
|
|
throw this.mapError(error);
|
|
}
|
|
|
|
if (resolved.stale || resolved.resolveRequestId !== request) {
|
|
throw stale('Hosted Plugin worker admission is stale');
|
|
}
|
|
const item = resolved.items.find(({ pluginId: candidate }) => candidate === plugin);
|
|
if (item?.action === 'unavailable') {
|
|
throw unavailable('Hosted Plugin Release is unavailable');
|
|
}
|
|
const matches = item && (bundled
|
|
? matchesBundledFrozenRelease(item, bundled, plugin)
|
|
: matchesFrozenRelease(item, installed, plugin));
|
|
if (!matches) {
|
|
throw stale('Hosted Plugin worker Release admission is stale');
|
|
}
|
|
return Object.freeze({
|
|
releaseId: item.releaseId,
|
|
releaseAdmissionId: item.releaseAdmissionId,
|
|
});
|
|
}
|
|
|
|
private mapError(error: unknown): MarketplaceHostedAdmissionError {
|
|
if (error instanceof MarketplaceHostedAdmissionError) return error;
|
|
const code = errorCode(error);
|
|
if (code === 'marketplace_account_changed' || code === 'plugin_account_changed') return accountChanged();
|
|
return backendUnavailable();
|
|
}
|
|
}
|
|
|
|
export function createMarketplaceHostedAdmissionResolver(
|
|
options: MarketplaceHostedAdmissionResolverOptions,
|
|
): MarketplaceHostedAdmissionResolver {
|
|
return new MarketplaceHostedAdmissionResolver(options);
|
|
}
|