import { Buffer } from 'node:buffer'; export const PLUGIN_RELEASE_DESCRIPTOR_SCHEMA = 'makelore-plugin-release.v1' as const; const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; const DIGEST_PATTERN = /^[a-f0-9]{64}$/u; const DESCRIPTOR_INPUT_KEYS = [ 'pluginId', 'version', 'packageSchemaVersion', 'contractVersion', 'minMakeloreVersion', 'maxMakeloreVersion', 'artifact', ] as const; export type PluginReleaseDescriptorInput = { readonly pluginId: string; readonly version: string; readonly packageSchemaVersion: number; readonly contractVersion: number; readonly minMakeloreVersion: string; readonly maxMakeloreVersion: string | null; readonly artifact: { readonly sha256: string; readonly sizeBytes: number; }; }; export interface PluginReleaseDescriptor extends PluginReleaseDescriptorInput { readonly schema: typeof PLUGIN_RELEASE_DESCRIPTOR_SCHEMA; } export class PluginReleaseDescriptorError extends Error { readonly code = 'plugin_descriptor_invalid' as const; constructor(readonly field: string, message: string) { super(`Invalid plugin release descriptor (${field}): ${message}`); this.name = 'PluginReleaseDescriptorError'; } } interface ParsedSemVer { major: number; minor: number; patch: number; prerelease: readonly (number | string)[]; } function fail(field: string, message: string): never { throw new PluginReleaseDescriptorError(field, message); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function assertDescriptorInputKeys(value: Record, includeSchema: boolean): void { const allowed = new Set([ ...DESCRIPTOR_INPUT_KEYS, ...(includeSchema ? ['schema'] : []), ]); for (const key of Object.keys(value)) { if (!allowed.has(key)) fail(`root.${key}`, 'unknown field'); } } function parseSemVer(value: unknown, field: string): ParsedSemVer { if (typeof value !== 'string' || value.length > 128) fail(field, 'must be a SemVer string'); const match = SEMVER_PATTERN.exec(value); if (!match) fail(field, 'must be a valid SemVer'); const prerelease = (match[4] ?? '').split('.').filter(Boolean).map((item) => { if (/^\d+$/u.test(item)) { if (item.length > 1 && item.startsWith('0')) fail(field, 'numeric prerelease identifiers cannot have leading zeroes'); return Number(item); } return item; }); return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease, }; } export function isValidSemVer(value: unknown): value is string { try { parseSemVer(value, 'version'); return true; } catch { return false; } } export function compareSemVer(left: string, right: string): -1 | 0 | 1 { const a = parseSemVer(left, 'left'); const b = parseSemVer(right, 'right'); for (const key of ['major', 'minor', 'patch'] as const) { if (a[key] < b[key]) return -1; if (a[key] > b[key]) return 1; } if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0; if (a.prerelease.length === 0) return 1; if (b.prerelease.length === 0) return -1; const length = Math.max(a.prerelease.length, b.prerelease.length); for (let index = 0; index < length; index += 1) { const leftPart = a.prerelease[index]; const rightPart = b.prerelease[index]; if (leftPart === undefined) return -1; if (rightPart === undefined) return 1; if (leftPart === rightPart) continue; if (typeof leftPart === 'number' && typeof rightPart === 'string') return -1; if (typeof leftPart === 'string' && typeof rightPart === 'number') return 1; return leftPart < rightPart ? -1 : 1; } return 0; } export function isMakeLoreVersionCompatible( clientVersion: string, minimumVersion: string, maximumVersion: string | null, ): boolean { if (!isValidSemVer(clientVersion) || !isValidSemVer(minimumVersion) || (maximumVersion !== null && !isValidSemVer(maximumVersion))) return false; if (compareSemVer(clientVersion, minimumVersion) < 0) return false; return maximumVersion === null || compareSemVer(clientVersion, maximumVersion) <= 0; } function positiveInteger(value: unknown, field: string, allowZero = false): number { if (!Number.isSafeInteger(value) || (value as number) < (allowZero ? 0 : 1)) { fail(field, allowZero ? 'must be a non-negative safe integer' : 'must be a positive safe integer'); } return value as number; } function descriptorFromInput(input: PluginReleaseDescriptorInput): PluginReleaseDescriptor { if (!isRecord(input)) fail('root', 'must be an object'); assertDescriptorInputKeys(input, 'schema' in input); if ('schema' in input && input.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) { fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`); } if (typeof input.pluginId !== 'string' || !PLUGIN_ID_PATTERN.test(input.pluginId)) { fail('plugin_id', 'must be a stable Plugin ID'); } parseSemVer(input.version, 'version'); if (input.packageSchemaVersion !== 2) fail('package_schema_version', 'must equal 2 for an artifact Release'); positiveInteger(input.contractVersion, 'contract_version'); parseSemVer(input.minMakeloreVersion, 'min_makelore_version'); if (input.maxMakeloreVersion !== null) { parseSemVer(input.maxMakeloreVersion, 'max_makelore_version'); if (compareSemVer(input.maxMakeloreVersion, input.minMakeloreVersion) < 0) { fail('max_makelore_version', 'must not be lower than min_makelore_version'); } } if (!isRecord(input.artifact)) fail('artifact', 'must be an object'); if (typeof input.artifact.sha256 !== 'string' || !DIGEST_PATTERN.test(input.artifact.sha256)) { fail('artifact.sha256', 'must be 64 lowercase hexadecimal characters'); } positiveInteger(input.artifact.sizeBytes, 'artifact.size_bytes', true); return Object.freeze({ schema: PLUGIN_RELEASE_DESCRIPTOR_SCHEMA, pluginId: input.pluginId, version: input.version, packageSchemaVersion: input.packageSchemaVersion, contractVersion: input.contractVersion, minMakeloreVersion: input.minMakeloreVersion, maxMakeloreVersion: input.maxMakeloreVersion, artifact: Object.freeze({ sha256: input.artifact.sha256, sizeBytes: input.artifact.sizeBytes, }), }); } export function buildPluginReleaseDescriptor(input: PluginReleaseDescriptorInput): PluginReleaseDescriptor { return descriptorFromInput(input); } export const createPluginReleaseDescriptor = buildPluginReleaseDescriptor; export function serializePluginReleaseDescriptor(descriptor: PluginReleaseDescriptor): Buffer { if (!isRecord(descriptor) || descriptor.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) { fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`); } const valid = descriptorFromInput(descriptor); return Buffer.from(JSON.stringify({ schema: valid.schema, plugin_id: valid.pluginId, version: valid.version, package_schema_version: valid.packageSchemaVersion, contract_version: valid.contractVersion, min_makelore_version: valid.minMakeloreVersion, max_makelore_version: valid.maxMakeloreVersion, artifact: { sha256: valid.artifact.sha256, size_bytes: valid.artifact.sizeBytes, }, }), 'utf8'); } function exactKeys(value: Record, keys: readonly string[], field: string): void { const allowed = new Set(keys); for (const key of Object.keys(value)) { if (!allowed.has(key)) fail(`${field}.${key}`, 'unknown field'); } for (const key of keys) { if (!(key in value)) fail(`${field}.${key}`, 'required field is missing'); } } export function parsePluginReleaseDescriptor(input: Uint8Array | string): PluginReleaseDescriptor { const bytes = typeof input === 'string' ? Buffer.from(input, 'utf8') : Buffer.from(input); if (bytes.length === 0 || bytes.length > 16_384) fail('root', 'descriptor size is outside the supported bound'); const source = bytes.toString('utf8'); let value: unknown; try { value = JSON.parse(source) as unknown; } catch { fail('root', 'must be valid UTF-8 JSON'); } const root = isRecord(value) ? value : fail('root', 'must be an object'); exactKeys(root, [ 'schema', 'plugin_id', 'version', 'package_schema_version', 'contract_version', 'min_makelore_version', 'max_makelore_version', 'artifact', ], 'root'); if (root.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) { fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`); } const artifact = isRecord(root.artifact) ? root.artifact : fail('artifact', 'must be an object'); exactKeys(artifact, ['sha256', 'size_bytes'], 'artifact'); const descriptor = descriptorFromInput({ pluginId: root.plugin_id as string, version: root.version as string, packageSchemaVersion: root.package_schema_version as number, contractVersion: root.contract_version as number, minMakeloreVersion: root.min_makelore_version as string, maxMakeloreVersion: root.max_makelore_version as string | null, artifact: { sha256: artifact.sha256 as string, sizeBytes: artifact.size_bytes as number, }, }); const canonical = serializePluginReleaseDescriptor(descriptor); if (!Buffer.from(canonical).equals(bytes)) fail('root', 'descriptor must use the canonical compact byte representation'); return descriptor; } export const parseReleaseDescriptor = parsePluginReleaseDescriptor; export const serializeReleaseDescriptor = serializePluginReleaseDescriptor;