#!/usr/bin/env node /* OnDuty Connector v1 conformance check (PLAN_2026-09-11_saomaisg-premium-pilot.md, step A10). node scripts/connector-probe.mjs [--calls 10] [--question "còn hàng không?"] [--tenant probe] [--debug] What a shop's endpoint must do, checked from the outside the way OnDuty itself calls it: manifest GET answers 200 JSON with v 1 and "query" among its capabilities signed POST a correctly signed query answers 200 JSON with a `facts` array facts shape every fact has a title and a non-empty body, a url only if https, ttl only as a number within caps at most 8 facts, 600 characters a body, 120 a title, 16 KB the whole answer context field a query carrying the optional `context` and locale "en" still answers 200 (additive fields) unsigned POST refused with 401 wrong signature refused with 401 stale ts a correctly signed query whose ts is 10 minutes old is refused with 401 (replay rule) latency p50 and p95 over N signed calls; p95 must stay under OnDuty's 2000 ms timeout no redirect no request is answered with a redirect (OnDuty refuses to follow one) A manifest with `demo: true` (OnDuty's own demo connector) does not verify signatures by design, so the three refusal checks report SKIP for it instead of FAIL. Exit code 0 only when nothing failed. Every request has its own deadline, so a hung endpoint ends the run with a FAIL instead of hanging it. */ import { createHmac, randomUUID } from "node:crypto"; import { pathToFileURL } from "node:url"; export const CAPS = { facts: 8, body: 600, title: 120, bytes: 16_000 }; export const ONDUTY_TIMEOUT_MS = 2000; const STALE_MS = 10 * 60 * 1000; // twice the 300 000 ms window the docs ask a shop to enforce export function sign(secret, body) { return "sha256=" + createHmac("sha256", secret).update(body).digest("hex"); } function queryBody(tenant, question, extra = {}) { return JSON.stringify({ v: 1, type: "query", tenant, question, locale: "vi", requestId: randomUUID(), ts: Date.now(), ...extra }); } async function send(url, { method = "POST", body, headers = {}, timeoutMs }) { const started = Date.now(); try { const res = await fetch(url, { method, body, headers: { "user-agent": "OnDuty-Connector-Probe/1", ...(body ? { "content-type": "application/json" } : {}), ...headers }, redirect: "manual", signal: AbortSignal.timeout(timeoutMs), }); const text = await res.text(); return { status: res.status, location: res.headers.get("location"), text, ms: Date.now() - started }; } catch (e) { const timedOut = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError"); const cause = e instanceof Error && e.cause ? e.cause.code ?? e.cause.message : ""; return { status: 0, error: timedOut ? `no answer within ${timeoutMs} ms` : `request failed${cause ? ` (${cause})` : ""}`, ms: Date.now() - started }; } } function parseJson(text) { try { return { ok: true, value: JSON.parse(text) }; } catch { return { ok: false, snippet: String(text).replace(/\s+/g, " ").trim().slice(0, 120) }; } } /* Why a response is not a valid answer, or "" when it is one. */ function answerProblem(r) { if (r.error) return r.error; if (r.status >= 300 && r.status < 400) return `redirects to ${r.location ?? "?"} (OnDuty does not follow redirects)`; if (r.status !== 200) return `status ${r.status}, want 200`; const j = parseJson(r.text); if (!j.ok) return `not JSON: ${j.snippet}`; if (!j.value || !Array.isArray(j.value.facts)) return "JSON has no facts array"; return ""; } export function factsProblems(payload) { const out = []; const facts = payload.facts; facts.forEach((f, i) => { if (!f || typeof f !== "object") return out.push(`fact ${i} is not an object`); if (typeof f.title !== "string" || !f.title.trim()) out.push(`fact ${i} has no title`); if (typeof f.body !== "string" || !f.body.trim()) out.push(`fact ${i} has no body`); if (f.url !== undefined && (typeof f.url !== "string" || !/^https:\/\//.test(f.url))) out.push(`fact ${i} url is not https`); }); if (payload.ttl !== undefined && (typeof payload.ttl !== "number" || !Number.isFinite(payload.ttl) || payload.ttl < 0)) out.push("ttl is not a number of seconds"); return out; } export function capProblems(payload, bytes) { const out = []; if (bytes > CAPS.bytes) out.push(`${bytes} bytes, cap ${CAPS.bytes}`); if (payload.facts.length > CAPS.facts) out.push(`${payload.facts.length} facts, cap ${CAPS.facts}`); payload.facts.forEach((f, i) => { if (typeof f?.body === "string" && f.body.length > CAPS.body) out.push(`fact ${i} body ${f.body.length} chars, cap ${CAPS.body}`); if (typeof f?.title === "string" && f.title.length > CAPS.title) out.push(`fact ${i} title ${f.title.length} chars, cap ${CAPS.title}`); }); return out; } /* Nearest-rank percentile: with 10 samples p95 is the slowest one, which is the honest reading. */ export function percentile(values, p) { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]; } export async function runProbe(url, secret, opts = {}) { const calls = opts.calls ?? 10; const question = opts.question ?? "còn hàng không?"; const tenant = opts.tenant ?? "probe"; const timeoutMs = opts.timeoutMs ?? 5000; const results = []; const add = (name, status, detail) => results.push({ name, status, detail }); /* 1. Manifest */ let demo = false; const m = await send(url, { method: "GET", timeoutMs }); const mj = m.status === 200 ? parseJson(m.text) : null; if (m.error) add("manifest", "fail", m.error); else if (m.status >= 300 && m.status < 400) add("manifest", "fail", `redirects to ${m.location ?? "?"} (OnDuty does not follow redirects)`); else if (m.status !== 200) add("manifest", "fail", `GET status ${m.status}, want 200`); else if (!mj.ok) add("manifest", "fail", `not JSON: ${mj.snippet}`); else { const v = mj.value ?? {}; demo = v.demo === true; const caps = Array.isArray(v.capabilities) ? v.capabilities : []; if (v.v !== 1) add("manifest", "fail", `v is ${JSON.stringify(v.v)}, want 1`); else if (!caps.includes("query")) add("manifest", "fail", `capabilities ${JSON.stringify(caps)} lack "query"`); else add("manifest", "pass", `200, v1, capabilities: ${caps.join(", ")}${v.name ? ` (${v.name})` : ""}${demo ? ", demo" : ""}`); } /* 2. Signed POST, and the shape and size of what it returns */ /* --debug asks the endpoint for its own timing breakdown (the PHP kit answers a signed `debug: true` with `timing`); an endpoint that ignores the flag is unaffected. */ const body = queryBody(tenant, question, opts.debug ? { debug: true } : {}); const s = await send(url, { body, headers: { "x-onduty-tenant": tenant, "x-onduty-signature": sign(secret, body) }, timeoutMs }); const sProblem = answerProblem(s); if (sProblem) { add("signed POST", "fail", sProblem); add("facts shape", "fail", "no valid answer to check"); add("within caps", "fail", "no valid answer to check"); } else { const payload = JSON.parse(s.text); add("signed POST", "pass", `200 JSON, ${payload.facts.length} facts, ${s.ms} ms${payload.ttl !== undefined ? `, ttl ${payload.ttl}` : ""}`); if (opts.debug && payload.timing && typeof payload.timing === "object") { add("timing", "info", Object.entries(payload.timing).map(([k, v]) => `${k}=${v}`).join(" ")); } const shape = factsProblems(payload); add("facts shape", shape.length ? "fail" : "pass", shape.length ? shape.join("; ") : `${payload.facts.length} facts valid`); const bytes = Buffer.byteLength(s.text, "utf8"); const caps = capProblems(payload, bytes); add("within caps", caps.length ? "fail" : "pass", caps.length ? caps.join("; ") : `${bytes} bytes, ${payload.facts.length} facts`); } /* 3. The optional fields added after v1 shipped must not break a shop that does not read them */ let pageUrl = "https://example.com/"; try { pageUrl = new URL("/", url).toString(); } catch { /* keep the placeholder */ } const cbody = queryBody(tenant, "giá bao nhiêu?", { locale: "en", context: { page: { url: pageUrl, title: "Probe page" }, previous: question } }); const c = await send(url, { body: cbody, headers: { "x-onduty-tenant": tenant, "x-onduty-signature": sign(secret, cbody) }, timeoutMs }); const cProblem = answerProblem(c); add("context field", cProblem ? "fail" : "pass", cProblem || "200 JSON with context and locale en"); /* 4. Refusals: unsigned, wrongly signed, replayed */ const refusal = async (name, reqBody, headers) => { if (demo) return add(name, "skip", "demo, skipped (the demo connector does not verify signatures)"); const r = await send(url, { body: reqBody, headers: { "x-onduty-tenant": tenant, ...headers }, timeoutMs }); if (r.error) return add(name, "fail", r.error); add(name, r.status === 401 ? "pass" : "fail", r.status === 401 ? "401" : `status ${r.status}, want 401`); }; const ub = queryBody(tenant, question); await refusal("unsigned POST -> 401", ub, {}); const wb = queryBody(tenant, question); await refusal("wrong signature -> 401", wb, { "x-onduty-signature": sign(`${secret}-wrong`, wb) }); const staleBody = queryBody(tenant, question, { ts: Date.now() - STALE_MS }); await refusal("stale ts -> 401", staleBody, { "x-onduty-signature": sign(secret, staleBody) }); /* 5. Latency over N signed calls, after the calls above have warmed the connection and any cold start */ const times = []; const bad = []; for (let i = 0; i < calls; i++) { const b = queryBody(tenant, question); const r = await send(url, { body: b, headers: { "x-onduty-tenant": tenant, "x-onduty-signature": sign(secret, b) }, timeoutMs }); const p = answerProblem(r); if (p) bad.push(`call ${i + 1}: ${p}`); else times.push(r.ms); } const p50 = percentile(times, 50); const p95 = percentile(times, 95); const latency = { calls, ok: times.length, p50, p95 }; if (bad.length) add("latency", "fail", `${bad.length} of ${calls} calls failed: ${bad[0]}`); else if (p95 >= ONDUTY_TIMEOUT_MS) add("latency", "fail", `p50 ${p50} ms, p95 ${p95} ms, OnDuty gives up at ${ONDUTY_TIMEOUT_MS} ms`); else add("latency", "pass", `p50 ${p50} ms, p95 ${p95} ms over ${calls} calls`); return { url, demo, results, latency, ok: results.every((r) => r.status !== "fail") }; } export function formatReport(report) { const lines = [`OnDuty Connector v1 probe: ${report.url}`]; for (const r of report.results) lines.push(`${r.status.toUpperCase().padEnd(5)} ${r.name.padEnd(24)} ${r.detail}`); const n = (st) => report.results.filter((r) => r.status === st).length; lines.push(`RESULT: ${n("pass")} pass, ${n("fail")} fail, ${n("skip")} skip, exit ${report.ok ? 0 : 1}`); return lines.join("\n"); } function arg(name, fallback) { const i = process.argv.indexOf(name); return i >= 0 && process.argv[i + 1] !== undefined ? process.argv[i + 1] : fallback; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [url, secret] = process.argv.slice(2); if (!url || !secret || url.startsWith("--")) { console.error('usage: node scripts/connector-probe.mjs [--calls 10] [--question "còn hàng không?"] [--tenant probe] [--debug]'); process.exit(2); } const calls = Number(arg("--calls", "10")); const report = await runProbe(url, secret, { calls: Number.isInteger(calls) && calls > 0 ? calls : 10, question: arg("--question", undefined), tenant: arg("--tenant", undefined), debug: process.argv.includes("--debug"), }); console.log(formatReport(report)); process.exit(report.ok ? 0 : 1); // explicit, so an open socket can never keep the run alive after the verdict }