473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
|
import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch';
|
|
|
|
const MAX_CATALOG_BYTES = 1_310_720;
|
|
const MAX_CATALOG_VERSION = 64;
|
|
const MAX_PLUGIN_ID = 48;
|
|
const MAX_CAPABILITY_ID = 64;
|
|
const MAX_OPERATION_ID = 64;
|
|
const MAX_NOTICE = 160;
|
|
const DEFAULT_POLICY_REQUEST_TIMEOUT_MS = 10_000;
|
|
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,47}$/u;
|
|
const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u;
|
|
const OPERATION_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
|
|
const TOKEN_POINT_AMOUNT_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/u;
|
|
|
|
type UnknownRecord = Record<string, unknown>;
|
|
type FetchImplementation = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
|
|
export interface PluginPricingVersion {
|
|
version: number;
|
|
version_id: string;
|
|
effective_at: string;
|
|
}
|
|
|
|
export type PluginBillingPolicy =
|
|
| {
|
|
mode: 'included';
|
|
entitlement_scope: null;
|
|
notice: string;
|
|
}
|
|
| {
|
|
mode: 'external_account';
|
|
notice: string;
|
|
}
|
|
| {
|
|
mode: 'platform_metered';
|
|
entitlement_scope: string;
|
|
notice: string;
|
|
unit_name: string;
|
|
unit_size: number;
|
|
rate_points: string;
|
|
minimum_charge_points: string;
|
|
rounding_mode: 'ceil';
|
|
}
|
|
| {
|
|
mode: 'platform_metered';
|
|
status: 'billing_unavailable';
|
|
entitlement_scope: string;
|
|
notice: string;
|
|
};
|
|
|
|
export interface PluginCatalogOperation {
|
|
operation: string;
|
|
billing: PluginBillingPolicy;
|
|
}
|
|
|
|
export interface PluginCatalogCapability {
|
|
capability_id: string;
|
|
operations: PluginCatalogOperation[];
|
|
}
|
|
|
|
export interface PluginCatalogPlugin {
|
|
plugin_id: string;
|
|
supported_contract_versions: number[];
|
|
status: 'active';
|
|
capabilities: PluginCatalogCapability[];
|
|
}
|
|
|
|
export interface PluginCatalog {
|
|
schema_version: 1;
|
|
catalog_version: string;
|
|
pricing_version: PluginPricingVersion | null;
|
|
plugins: PluginCatalogPlugin[];
|
|
}
|
|
|
|
export type PluginPolicyAvailability = 'unavailable' | 'current' | 'stale';
|
|
|
|
export interface PluginPolicyClientState {
|
|
status: PluginPolicyAvailability;
|
|
catalog: PluginCatalog | null;
|
|
revision: number;
|
|
lastVerifiedAt: number | null;
|
|
errorCode?: 'plugin_backend_unavailable';
|
|
}
|
|
|
|
export interface PluginPolicyClientOptions {
|
|
fetchImpl?: FetchImplementation;
|
|
apiBaseUrl?: string;
|
|
now?: () => number;
|
|
requestTimeoutMs?: number;
|
|
}
|
|
|
|
export class PluginPolicyCatalogError extends Error {
|
|
readonly code = 'plugin_policy_invalid' as const;
|
|
|
|
constructor(message = 'Plugin policy catalog is invalid') {
|
|
super(message);
|
|
this.name = 'PluginPolicyCatalogError';
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is UnknownRecord {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function exactKeys(
|
|
value: UnknownRecord,
|
|
required: readonly string[],
|
|
optional: readonly string[] = [],
|
|
): void {
|
|
const allowed = new Set([...required, ...optional]);
|
|
const keys = Object.keys(value);
|
|
if (keys.some((key) => !allowed.has(key))
|
|
|| required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
|
|
throw new PluginPolicyCatalogError('Plugin policy catalog contains unexpected fields');
|
|
}
|
|
}
|
|
|
|
function text(value: unknown, maximum: number, field: string): string {
|
|
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
|
throw new PluginPolicyCatalogError(`${field} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function identifier(
|
|
value: unknown,
|
|
maximum: number,
|
|
pattern: RegExp,
|
|
field: string,
|
|
): string {
|
|
const result = text(value, maximum, field);
|
|
if (!pattern.test(result)) throw new PluginPolicyCatalogError(`${field} has invalid shape`);
|
|
return result;
|
|
}
|
|
|
|
function positiveInteger(value: unknown, field: string): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
throw new PluginPolicyCatalogError(`${field} must be a positive integer`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function unique(values: readonly string[], field: string): void {
|
|
if (new Set(values).size !== values.length) {
|
|
throw new PluginPolicyCatalogError(`${field} contains duplicates`);
|
|
}
|
|
}
|
|
|
|
function timestamp(value: unknown, field: string): string {
|
|
const result = text(value, 64, field);
|
|
if (!result.endsWith('Z') || Number.isNaN(Date.parse(result))) {
|
|
throw new PluginPolicyCatalogError(`${field} is invalid`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function publicAmount(value: unknown, field: string): string {
|
|
const result = text(value, 32, field);
|
|
if (!TOKEN_POINT_AMOUNT_PATTERN.test(result)) {
|
|
throw new PluginPolicyCatalogError(`${field} is invalid`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parsePricingVersion(value: unknown): PluginPricingVersion | null {
|
|
if (value === null) return null;
|
|
if (!isRecord(value)) throw new PluginPolicyCatalogError('pricing_version is invalid');
|
|
exactKeys(value, ['version', 'version_id', 'effective_at']);
|
|
return {
|
|
version: positiveInteger(value.version, 'pricing_version.version'),
|
|
version_id: text(value.version_id, 36, 'pricing_version.version_id'),
|
|
effective_at: timestamp(value.effective_at, 'pricing_version.effective_at'),
|
|
};
|
|
}
|
|
|
|
function parseBilling(value: unknown): PluginBillingPolicy {
|
|
if (!isRecord(value)) throw new PluginPolicyCatalogError('billing is invalid');
|
|
if (value.mode === 'included') {
|
|
exactKeys(value, ['mode', 'entitlement_scope', 'notice']);
|
|
if (value.entitlement_scope !== null) {
|
|
throw new PluginPolicyCatalogError('included entitlement_scope must be null');
|
|
}
|
|
return {
|
|
mode: 'included',
|
|
entitlement_scope: null,
|
|
notice: text(value.notice, MAX_NOTICE, 'billing.notice'),
|
|
};
|
|
}
|
|
if (value.mode === 'external_account') {
|
|
exactKeys(value, ['mode', 'notice']);
|
|
return {
|
|
mode: 'external_account',
|
|
notice: text(value.notice, MAX_NOTICE, 'billing.notice'),
|
|
};
|
|
}
|
|
if (value.mode !== 'platform_metered') {
|
|
throw new PluginPolicyCatalogError('billing.mode is invalid');
|
|
}
|
|
if (value.status === 'billing_unavailable') {
|
|
exactKeys(value, ['mode', 'status', 'entitlement_scope', 'notice']);
|
|
return {
|
|
mode: 'platform_metered',
|
|
status: 'billing_unavailable',
|
|
entitlement_scope: identifier(
|
|
value.entitlement_scope,
|
|
64,
|
|
OPERATION_ID_PATTERN,
|
|
'billing.entitlement_scope',
|
|
),
|
|
notice: text(value.notice, MAX_NOTICE, 'billing.notice'),
|
|
};
|
|
}
|
|
exactKeys(value, [
|
|
'mode',
|
|
'entitlement_scope',
|
|
'notice',
|
|
'unit_name',
|
|
'unit_size',
|
|
'rate_points',
|
|
'minimum_charge_points',
|
|
'rounding_mode',
|
|
]);
|
|
if (value.rounding_mode !== 'ceil') {
|
|
throw new PluginPolicyCatalogError('billing.rounding_mode is invalid');
|
|
}
|
|
return {
|
|
mode: 'platform_metered',
|
|
entitlement_scope: identifier(
|
|
value.entitlement_scope,
|
|
64,
|
|
OPERATION_ID_PATTERN,
|
|
'billing.entitlement_scope',
|
|
),
|
|
notice: text(value.notice, MAX_NOTICE, 'billing.notice'),
|
|
unit_name: text(value.unit_name, 80, 'billing.unit_name'),
|
|
unit_size: positiveInteger(value.unit_size, 'billing.unit_size'),
|
|
rate_points: publicAmount(value.rate_points, 'billing.rate_points'),
|
|
minimum_charge_points: publicAmount(
|
|
value.minimum_charge_points,
|
|
'billing.minimum_charge_points',
|
|
),
|
|
rounding_mode: 'ceil',
|
|
};
|
|
}
|
|
|
|
function parseOperation(value: unknown): PluginCatalogOperation {
|
|
if (!isRecord(value)) throw new PluginPolicyCatalogError('operation is invalid');
|
|
exactKeys(value, ['operation', 'billing']);
|
|
return {
|
|
operation: identifier(value.operation, MAX_OPERATION_ID, OPERATION_ID_PATTERN, 'operation'),
|
|
billing: parseBilling(value.billing),
|
|
};
|
|
}
|
|
|
|
function parseCapability(value: unknown): PluginCatalogCapability {
|
|
if (!isRecord(value)) throw new PluginPolicyCatalogError('capability is invalid');
|
|
exactKeys(value, ['capability_id', 'operations']);
|
|
if (!Array.isArray(value.operations) || value.operations.length < 1 || value.operations.length > 256) {
|
|
throw new PluginPolicyCatalogError('capability.operations is invalid');
|
|
}
|
|
const operations = value.operations.map(parseOperation);
|
|
unique(operations.map(({ operation }) => operation), 'capability.operations');
|
|
return {
|
|
capability_id: identifier(
|
|
value.capability_id,
|
|
MAX_CAPABILITY_ID,
|
|
CAPABILITY_ID_PATTERN,
|
|
'capability_id',
|
|
),
|
|
operations,
|
|
};
|
|
}
|
|
|
|
function parsePlugin(value: unknown): PluginCatalogPlugin {
|
|
if (!isRecord(value)) throw new PluginPolicyCatalogError('plugin is invalid');
|
|
exactKeys(value, ['plugin_id', 'supported_contract_versions', 'status', 'capabilities']);
|
|
if (value.status !== 'active') throw new PluginPolicyCatalogError('plugin.status is invalid');
|
|
if (!Array.isArray(value.supported_contract_versions)
|
|
|| value.supported_contract_versions.length < 1
|
|
|| value.supported_contract_versions.length > 32) {
|
|
throw new PluginPolicyCatalogError('supported_contract_versions is invalid');
|
|
}
|
|
const supportedContractVersions = value.supported_contract_versions.map((item) => (
|
|
positiveInteger(item, 'supported_contract_versions')
|
|
));
|
|
if (new Set(supportedContractVersions).size !== supportedContractVersions.length) {
|
|
throw new PluginPolicyCatalogError('supported_contract_versions contains duplicates');
|
|
}
|
|
if (!Array.isArray(value.capabilities) || value.capabilities.length < 1 || value.capabilities.length > 128) {
|
|
throw new PluginPolicyCatalogError('plugin.capabilities is invalid');
|
|
}
|
|
const capabilities = value.capabilities.map(parseCapability);
|
|
unique(capabilities.map(({ capability_id }) => capability_id), 'plugin.capabilities');
|
|
return {
|
|
plugin_id: identifier(value.plugin_id, MAX_PLUGIN_ID, PLUGIN_ID_PATTERN, 'plugin_id'),
|
|
supported_contract_versions: supportedContractVersions,
|
|
status: 'active',
|
|
capabilities,
|
|
};
|
|
}
|
|
|
|
/** Parse the exact public server catalog; unknown fields are rejected. */
|
|
export function parsePluginCatalog(value: unknown): PluginCatalog {
|
|
if (!isRecord(value)) throw new PluginPolicyCatalogError();
|
|
exactKeys(value, ['schema_version', 'catalog_version', 'pricing_version', 'plugins']);
|
|
if (value.schema_version !== 1) throw new PluginPolicyCatalogError('schema_version must equal 1');
|
|
if (!Array.isArray(value.plugins) || value.plugins.length > 128) {
|
|
throw new PluginPolicyCatalogError('plugins is invalid');
|
|
}
|
|
const plugins = value.plugins.map(parsePlugin);
|
|
unique(plugins.map(({ plugin_id }) => plugin_id), 'plugins');
|
|
return Object.freeze({
|
|
schema_version: 1 as const,
|
|
catalog_version: text(value.catalog_version, MAX_CATALOG_VERSION, 'catalog_version'),
|
|
pricing_version: parsePricingVersion(value.pricing_version),
|
|
plugins,
|
|
});
|
|
}
|
|
|
|
async function readBoundedText(response: Response): Promise<string> {
|
|
const declaredLength = response.headers.get('content-length');
|
|
if (declaredLength && /^\d+$/u.test(declaredLength)
|
|
&& Number(declaredLength) > MAX_CATALOG_BYTES) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
throw new PluginPolicyCatalogError('catalog response is too large');
|
|
}
|
|
if (!response.body) {
|
|
const textValue = await response.text();
|
|
if (Buffer.byteLength(textValue, 'utf8') > MAX_CATALOG_BYTES) {
|
|
throw new PluginPolicyCatalogError('catalog response is too large');
|
|
}
|
|
return textValue;
|
|
}
|
|
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 > MAX_CATALOG_BYTES) {
|
|
await reader.cancel().catch(() => undefined);
|
|
throw new PluginPolicyCatalogError('catalog response is too large');
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8');
|
|
}
|
|
|
|
async function readCatalog(response: Response): Promise<PluginCatalog> {
|
|
let source: string;
|
|
try {
|
|
source = await readBoundedText(response);
|
|
} catch (error) {
|
|
if (error instanceof PluginPolicyCatalogError) throw error;
|
|
throw new PluginPolicyCatalogError();
|
|
}
|
|
let payload: unknown;
|
|
try {
|
|
payload = JSON.parse(source) as unknown;
|
|
} catch {
|
|
throw new PluginPolicyCatalogError();
|
|
}
|
|
return parsePluginCatalog(payload);
|
|
}
|
|
|
|
function cloneState(state: PluginPolicyClientState): PluginPolicyClientState {
|
|
return {
|
|
...state,
|
|
catalog: state.catalog ? structuredClone(state.catalog) : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Narrow HTTP adapter for the unauthenticated, user-independent policy catalog.
|
|
* Only the last successfully parsed catalog is retained after a refresh error;
|
|
* it is visibly stale and never becomes a source of billing authority.
|
|
*/
|
|
export class PluginPolicyClient {
|
|
private readonly fetchImpl: FetchImplementation;
|
|
private readonly apiBaseUrl: string;
|
|
private readonly now: () => number;
|
|
private readonly requestTimeoutMs: number;
|
|
private state: PluginPolicyClientState = {
|
|
status: 'unavailable',
|
|
catalog: null,
|
|
revision: 0,
|
|
lastVerifiedAt: null,
|
|
};
|
|
private refreshFlight: Promise<PluginPolicyClientState> | null = null;
|
|
|
|
constructor(options: PluginPolicyClientOptions = {}) {
|
|
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
|
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, '');
|
|
this.now = options.now ?? (() => Date.now());
|
|
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_POLICY_REQUEST_TIMEOUT_MS;
|
|
}
|
|
|
|
getState(): PluginPolicyClientState {
|
|
return cloneState(this.state);
|
|
}
|
|
|
|
getStatus(): PluginPolicyAvailability {
|
|
return this.state.status;
|
|
}
|
|
|
|
get catalog(): PluginCatalog | null {
|
|
return this.state.catalog ? structuredClone(this.state.catalog) : null;
|
|
}
|
|
|
|
refresh(): Promise<PluginPolicyClientState> {
|
|
if (this.refreshFlight) return this.refreshFlight;
|
|
const flight = this.fetchCatalog()
|
|
.then((catalog) => {
|
|
this.state = {
|
|
status: 'current',
|
|
catalog,
|
|
revision: this.state.revision + 1,
|
|
lastVerifiedAt: this.now(),
|
|
};
|
|
return this.getState();
|
|
})
|
|
.catch(() => {
|
|
this.state = {
|
|
...this.state,
|
|
status: this.state.catalog ? 'stale' : 'unavailable',
|
|
errorCode: 'plugin_backend_unavailable',
|
|
};
|
|
return this.getState();
|
|
})
|
|
.finally(() => {
|
|
if (this.refreshFlight === flight) this.refreshFlight = null;
|
|
});
|
|
this.refreshFlight = flight;
|
|
return flight;
|
|
}
|
|
|
|
refreshCatalog(): Promise<PluginPolicyClientState> {
|
|
return this.refresh();
|
|
}
|
|
|
|
private async fetchCatalog(): Promise<PluginCatalog> {
|
|
try {
|
|
return await runWithDeadline(async (signal) => {
|
|
const response = await this.fetchImpl(
|
|
`${this.apiBaseUrl}/api/plugins/v1/catalog`,
|
|
{
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
redirect: 'manual',
|
|
signal,
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
throw new PluginPolicyCatalogError('catalog request failed');
|
|
}
|
|
return await readCatalog(response);
|
|
}, this.requestTimeoutMs);
|
|
} catch {
|
|
throw new PluginPolicyCatalogError('catalog request failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
export const parsePluginPolicyCatalog = parsePluginCatalog;
|