Commit1d3847ddeleted the entire tree (2584 files) and added nothing, so main was empty. This restores main todc43a6a(the PR 22 merge) exactly. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
115 lines
7.2 KiB
JavaScript
115 lines
7.2 KiB
JavaScript
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
|
|
// Test-only wire audit. It forwards to the real demo and never records headers or bodies.
|
|
import { createServer, request } from 'node:http';
|
|
|
|
const methods = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
|
|
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
export async function startSemanticApiProxy(origin) {
|
|
const response = await fetch(`${origin}/qqq/v1/openapi.json`);
|
|
if (!response.ok) throw new Error('Cannot load the real server UI API document.');
|
|
const document = await response.json();
|
|
const operations = Object.entries(document.paths).flatMap(([path, item]) =>
|
|
Object.entries(item).filter(([method]) => methods.has(method)).map(([method, operation]) => ({
|
|
method: method.toUpperCase(), path, operationId: operation.operationId,
|
|
matcher: new RegExp(`^${path.split('/').map((part) => /^\{[^}]+\}$/.test(part) ? '[^/]+' : escape(part)).join('/')}$`),
|
|
})));
|
|
let mode = 'compatible';
|
|
let calls = [];
|
|
const proxy = createServer((incoming, outgoing) => {
|
|
const path = new URL(incoming.url, origin).pathname;
|
|
const operation = operations.find((candidate) => candidate.method === incoming.method && candidate.matcher.test(path));
|
|
const call = {
|
|
method: incoming.method,
|
|
path: operation?.path ?? path,
|
|
operationId: operation?.operationId,
|
|
credentials: Boolean(incoming.headers.authorization || incoming.headers.cookie || incoming.headers['x-qqq-device-token']),
|
|
android: String(incoming.headers['x-qqq-client'] ?? '').startsWith('android/'),
|
|
};
|
|
calls.push(call);
|
|
const started = Date.now();
|
|
const upstream = request(new URL(incoming.url, origin), { method: incoming.method, headers: incoming.headers }, (result) => {
|
|
call.status = result.statusCode;
|
|
call.responseBytes = 0;
|
|
result.on('data', (chunk) => { call.responseBytes += chunk.length; });
|
|
result.on('end', () => { call.elapsedMillis = Date.now() - started; call.completed = true; });
|
|
if (path === '/qqq/v1/version' && mode !== 'compatible') {
|
|
const chunks = [];
|
|
result.on('data', (chunk) => chunks.push(chunk));
|
|
result.on('end', () => {
|
|
try {
|
|
const version = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
if (mode === 'unsupported-api') version.apiVersion = '2.0.0';
|
|
if (mode === 'missing-extension') version.extensions = (version.extensions ?? []).filter((extension) => extension.name !== 'mobile-scanners');
|
|
if (mode === 'missing-channel') version.clientChannels = (version.clientChannels ?? []).filter((channel) => channel !== 'android');
|
|
if (mode === 'additive') {
|
|
version.apiVersion = '1.99.0';
|
|
version.futureOptionalProperty = { supportedByNewerClients: true };
|
|
version.features = [...(version.features ?? []), 'futureOptionalFeature'];
|
|
version.extensions = [...(version.extensions ?? []), { name: 'future-extension', version: '1.0.0', pathPrefix: '/qbit/future-extension' }];
|
|
}
|
|
const body = JSON.stringify(version);
|
|
const headers = { ...result.headers, 'content-length': Buffer.byteLength(body) };
|
|
delete headers['transfer-encoding'];
|
|
outgoing.writeHead(result.statusCode, headers);
|
|
outgoing.end(body);
|
|
} catch {
|
|
outgoing.writeHead(502);
|
|
outgoing.end();
|
|
}
|
|
});
|
|
} else {
|
|
outgoing.writeHead(result.statusCode, result.headers);
|
|
result.pipe(outgoing);
|
|
}
|
|
});
|
|
upstream.on('error', () => { outgoing.writeHead(502); outgoing.end(); });
|
|
incoming.pipe(upstream);
|
|
});
|
|
await new Promise((done, reject) => { proxy.once('error', reject); proxy.listen(0, '127.0.0.1', done); });
|
|
return {
|
|
port: proxy.address().port,
|
|
setMode(value) { mode = value; calls = []; },
|
|
evidence() { return calls.map(({ method, operationId, credentials, android, status, responseBytes, elapsedMillis, completed }) => ({ method, operationId, credentials, android, status, responseBytes, elapsedMillis, completed })); },
|
|
verify(compatible, requireCancel = false, requireSession = false, requirePhotos = false) {
|
|
if (!calls.length || calls[0].path !== '/qqq/v1/version' || calls[0].credentials) throw new Error('Native startup must discover compatibility before sending credentials or other requests.');
|
|
if (calls.some((call) => !call.operationId)) throw new Error('Native flow made a request outside the published semantic API contract.');
|
|
if (!compatible && calls.some((call) => call.path !== '/qqq/v1/version' || call.credentials)) throw new Error('An incompatible server received requests beyond public version discovery.');
|
|
if (compatible) {
|
|
for (const path of ['/qqq/v1/metaData/authentication', '/qqq/v1/metaData', '/qqq/v1/qbit/mobile-scanners/devices/enroll', '/qqq/v1/qbit/mobile-scanners/devices/current']) {
|
|
if (!calls.some((call) => call.path === path && call.android)) throw new Error(`Missing native semantic operation: ${path}`);
|
|
}
|
|
}
|
|
if (requireCancel && !calls.some((call) => call.android && call.method === 'POST' && call.path.endsWith('/cancel'))) {
|
|
throw new Error('Native process cancellation did not reach the semantic API.');
|
|
}
|
|
if (requireSession) {
|
|
for (const path of ['/qqq/v1/qbit/mobile-scanners/sessions/heartbeat', '/qqq/v1/qbit/mobile-scanners/sessions/current/screen', '/qqq/v1/qbit/mobile-scanners/sessions/current/end']) {
|
|
if (!calls.some((call) => call.path === path && call.android)) throw new Error(`Missing native session operation: ${path}`);
|
|
}
|
|
}
|
|
if (requirePhotos) {
|
|
for (const [method, path] of [
|
|
['GET', '/qqq/v1/qbit/mobile-record-files/contract'],
|
|
['GET', '/qqq/v1/qbit/record-files/capabilities'],
|
|
['POST', '/qqq/v1/qbit/record-files/uploads'],
|
|
['PUT', '/qqq/v1/qbit/record-files/uploads/{uploadId}/items/{itemId}/chunks/{chunkIndex}'],
|
|
['POST', '/qqq/v1/qbit/record-files/uploads/{uploadId}/finalize'],
|
|
]) {
|
|
if (!calls.some(call => call.method === method && call.path === path && call.android && call.status >= 200 && call.status < 300)) {
|
|
throw new Error(`Missing successful native photo operation: ${method} ${path}`);
|
|
}
|
|
}
|
|
if (calls.some(call => call.path === '/qqq/v1/qbit/mobile-record-files/contract' && call.credentials)) {
|
|
throw new Error('Photo vocabulary discovery must not carry credentials.');
|
|
}
|
|
}
|
|
return { mode, requests: calls.length, operations: [...new Set(calls.map((call) => call.operationId))].sort(), documentedRequests: calls.length };
|
|
},
|
|
async close() {
|
|
proxy.closeAllConnections();
|
|
await new Promise((done) => proxy.close(done));
|
|
},
|
|
};
|
|
}
|