/** * In-process stand-in for the two HTTP surfaces a plugin talks to: * - WebDAV at /webdav/... (JSON directory listing, HEAD, Range GET, PUT) * - meta API at /meta/... (GET record / property) * - POST /api/claims (signed MMUR2 claims — every signature is verified) * Records are stored FLAT (`subtitles/fre/`), the way meta-core keeps them. * * Claims are folded back into the old call shape so tests keep asserting on * writes per record: a batch's text claims become one `PATCH /meta/` * (body = key → value) and each retraction a `DELETE /meta//`. */ import http from 'http'; import type { AddressInfo } from 'net'; import { verifyClaim, type ClaimValue } from '../src/meta-core-client.js'; // Fixed test signing key (scalar 0x0101…01) — the plugin signs as this. process.env.METAMESH_SIGNING_KEY ??= '01'.repeat(32); export interface FakeCall { method: string; path: string; body?: unknown; } export interface FakeCore { url: string; webdavUrl: string; /** Keyed by path under /files, e.g. `/watch/show/a.mkv`. */ files: Map; records: Map>; calls: FakeCall[]; /** GET requests per file path (each ffmpeg pass opens the file at least once). */ fileGets: Map; failPatch: boolean; close(): Promise; } function readBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } export async function startFakeCore(): Promise { const core = { files: new Map(), records: new Map>(), calls: [] as FakeCall[], fileGets: new Map(), failPatch: false, }; const server = http.createServer(async (req, res) => { const url = new URL(req.url ?? '/', 'http://x'); const pathname = decodeURIComponent(url.pathname); const send = (status: number, body?: unknown, headers: Record = {}) => { if (body === undefined) { res.writeHead(status, headers); res.end(); } else if (Buffer.isBuffer(body)) { res.writeHead(status, headers); res.end(body); } else { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(body)); } }; if (pathname.startsWith('/webdav')) { const p = pathname.slice('/webdav'.length) || '/'; if (req.method === 'PUT') { core.files.set(p, await readBody(req)); return send(201); } if (p.endsWith('/')) { const dir = p.replace(/\/$/, ''); const entries = new Map(); for (const [fp, data] of core.files) { if (!fp.startsWith(`${dir}/`)) continue; const rest = fp.slice(dir.length + 1); const slash = rest.indexOf('/'); if (slash === -1) entries.set(rest, { name: rest, type: 'file', size: data.length }); else entries.set(rest.slice(0, slash), { name: rest.slice(0, slash), type: 'directory', size: 512 }); } return send(200, [...entries.values()]); } const data = core.files.get(p); if (!data) return send(404); if (req.method === 'HEAD') { return send(200, undefined, { 'Content-Length': String(data.length), 'Accept-Ranges': 'bytes' }); } core.fileGets.set(p, (core.fileGets.get(p) ?? 0) + 1); const range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.range ?? ''); if (range) { const start = Number(range[1]); const end = range[2] ? Math.min(Number(range[2]), data.length - 1) : data.length - 1; if (start >= data.length) return send(416, undefined, { 'Content-Range': `bytes */${data.length}` }); return send(206, data.subarray(start, end + 1), { 'Content-Range': `bytes ${start}-${end}/${data.length}`, 'Content-Length': String(end - start + 1), 'Accept-Ranges': 'bytes', }); } return send(200, data, { 'Content-Length': String(data.length), 'Accept-Ranges': 'bytes' }); } if (pathname === '/api/claims' && req.method === 'POST') { const claims = JSON.parse((await readBody(req)).toString('utf8')) as Array<{ cid: string; uid: string; key: string; value: string | { b64: string } | null; ts: number; sig: string; }>; if (core.failPatch) return send(500, { error: 'boom' }); const statuses: Array<{ status: string }> = []; const sets = new Map>(); for (const c of claims) { const value: ClaimValue = c.value === null ? null : typeof c.value === 'string' ? c.value : Buffer.from(c.value.b64, 'base64'); if (!verifyClaim({ ...c, value, sig: Buffer.from(c.sig, 'base64') })) { statuses.push({ status: 'bad_signature' }); continue; } statuses.push({ status: 'accepted' }); if (value === null) { core.calls.push({ method: 'DELETE', path: `/meta/${c.cid}/${c.key}` }); const record = core.records.get(c.cid); if (record) delete record[c.key]; } else { sets.set(c.cid, { ...(sets.get(c.cid) ?? {}), [c.key]: String(value) }); } } for (const [cid, body] of sets) { core.calls.push({ method: 'PATCH', path: `/meta/${cid}`, body }); core.records.set(cid, { ...(core.records.get(cid) ?? {}), ...body }); } return send(200, statuses); } const m = /^\/meta\/([^/]+)(?:\/(.+))?$/.exec(pathname); if (m) { const [, hash, key] = m; const raw = await readBody(req); const body = raw.length ? JSON.parse(raw.toString('utf8')) : undefined; if (req.method !== 'GET') core.calls.push({ method: req.method ?? '', path: pathname, body }); const record = core.records.get(hash); if (req.method === 'GET' && !key) return record ? send(200, { metadata: record }) : send(404, { error: 'not found' }); if (req.method === 'GET' && key) { return record && key in record ? send(200, { value: record[key] }) : send(404, { error: 'Property not found' }); } if (req.method === 'PATCH' && !key) { if (core.failPatch) return send(500, { error: 'boom' }); core.records.set(hash, { ...(record ?? {}), ...(body as Record) }); return send(200, { status: 'ok' }); } if (req.method === 'DELETE' && key) { if (record) delete record[key]; return send(200, { status: 'ok' }); } } send(404, { error: 'no route' }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address() as AddressInfo; const url = `http://127.0.0.1:${port}`; return { ...core, get failPatch() { return core.failPatch; }, set failPatch(v: boolean) { core.failPatch = v; }, url, webdavUrl: `${url}/webdav`, close: () => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); }), }; } /** Mutating calls (claims folded to PATCH / DELETE) against one record. */ export function writesTo(core: FakeCore, hash: string): FakeCall[] { return core.calls.filter((c) => c.path === `/meta/${hash}` || c.path.startsWith(`/meta/${hash}/`)); }