#!/usr/bin/env node // Publish an account folder to Streamliner through the signed config write API — a DRY RUN unless --yes. // Node 20+, no dependencies. The same protocol as the repo's scripts/publish-config.mjs (config-schema §8b). // // node publish.mjs --host --key [--key-id ] [--account ] // [--baseline ] [--json] [--yes] // // the account folder (tenant.json, agents/, templates/, forms/, private/…, manifest.json signed by // sign-config.mjs). The account id defaults to tenant.json `id`. // --key an Ed25519 private JWK whose public half is one of the account's server keys with a config scope. // Read from the path, never printed. Ask the owner for a scoped, expiring key; never an employee login. // --baseline a local copy of what is published now, for a field-level diff of the JSON files (the server only // reports hashes, so without it the diff is per file). // --yes actually write. Without it the Worker checks everything (`?dryRun=1`) and writes nothing. // ONLY pass --yes after the account owner has seen the dry run and said yes in so many words. // // Prints paths, counts and a readable diff; never file contents of private/ files, keys or tokens. import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { join, relative, sep } from "node:path"; import { createHash, createPrivateKey, randomBytes, sign } from "node:crypto"; const argv = process.argv.slice(2); const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; }; const flag = (n) => argv.includes(`--${n}`); const VALUED = new Set(["--host", "--key", "--key-id", "--account", "--baseline"]); const dir = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1])); const die = (m, c = 1) => { console.error(`publish: ${m}`); process.exit(c); }; if (!dir || !existsSync(join(dir, "tenant.json")) || !opt("host") || !opt("key")) die("usage: node publish.mjs --host --key [--key-id id] [--account id] [--baseline folder] [--json] [--yes]", 2); const base = opt("host").replace(/\/$/, ""); if (/^http:\/\//.test(base) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(base)) die("refusing http:// to a non-local host; use https://", 2); const yes = flag("yes"), asJSON = flag("json"); const tenantJSON = JSON.parse(readFileSync(join(dir, "tenant.json"), "utf8")); const account = opt("account") || tenantJSON.id; if (!/^[a-z0-9-]{3,40}$/.test(account || "")) die(`bad account id ${account}`); if (tenantJSON.id !== account) die(`tenant.json id is ${tenantJSON.id}, not ${account}`); const CONFIG_PATH = /^(?:tenant\.json|enrolment\.json|manifest\.json|(?:agents|templates|forms)\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,79}\.json|assets\/[a-f0-9]{16,64}\.(?:png|jpe?g|pdf)|demo\/(?:backend\.js|agent\.js|fixtures\.json)|private\/tenant\.json|private\/(?:agents|connections)\/[A-Za-z0-9_-][A-Za-z0-9._@-]{0,79}\.json)$/; const deviceVisible = (p) => !p.startsWith("private/") && p !== "manifest.json" && p !== "enrolment.json"; const sha = (b) => createHash("sha256").update(b).digest("hex"); const walk = (d) => readdirSync(d).flatMap((n) => { const p = join(d, n); return statSync(p).isDirectory() ? walk(p) : n.startsWith(".") ? [] : [p]; }); const readFolder = (d) => new Map(walk(d).map((f) => [relative(d, f).split(sep).join("/"), readFileSync(f)])); const local = readFolder(dir); const odd = [...local.keys()].filter((p) => !CONFIG_PATH.test(p)); if (odd.length) die(`not config paths (move them out of the folder): ${odd.join(", ")}`); if (!local.has("manifest.json")) die("no manifest.json: sign first (sign-config.mjs)"); const manifest = JSON.parse(local.get("manifest.json")); const listed = (manifest.files || []).map((f) => f.path).sort().join("\n"); const want = [...local.keys()].filter(deviceVisible).sort().join("\n"); const stale = (manifest.files || []).filter((f) => !local.has(f.path) || sha(local.get(f.path)) !== f.sha256); if (listed !== want || stale.length) die("the manifest is stale (a file changed since signing): run sign-config.mjs again"); // ── the server key (never printed) ── if (!existsSync(opt("key"))) die("no such key file"); const jwk = JSON.parse(readFileSync(opt("key"), "utf8")); if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.d) die("the server key must be an Ed25519 private JWK"); const privateKey = createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", d: jwk.d, x: jwk.x }, format: "jwk" }); const ptLocal = local.has("private/tenant.json") ? JSON.parse(local.get("private/tenant.json")) : null; const keyId = opt("key-id") || jwk.kid || (ptLocal?.serverKeys || []).find((k) => k.publicKey === jwk.x)?.id; if (!keyId) die("which key? --key-id (the id the account knows this server key by)"); if (ptLocal && !(ptLocal.serverKeys || []).some((k) => k.id === keyId && k.publicKey === jwk.x && !k.disabled)) console.error(`warning: private/tenant.json in this folder does not list ${keyId}; publishing it would remove that key (the Worker refuses a publish that locks the account out)`); /// streamline.admin.v1|||||| async function signed(method, path, body) { const bytes = body == null ? Buffer.alloc(0) : Buffer.from(JSON.stringify(body)); const ts = String(Math.floor(Date.now() / 1000)), nonce = randomBytes(18).toString("base64url"); const signature = sign(null, Buffer.from(`streamline.admin.v1|${account}|${method}|${path}|${sha(bytes)}|${ts}|${nonce}`), privateKey).toString("base64url"); let res; try { res = await fetch(base + path, { method, body: method === "GET" ? undefined : bytes, headers: { "Content-Type": "application/json", "X-Streamline-Key": keyId, "X-Streamline-Timestamp": ts, "X-Streamline-Nonce": nonce, "X-Streamline-Signature": signature } }); } catch (e) { die(`can't reach ${base}: ${e.cause?.code || e.message}`); } return { status: res.status, ok: res.ok, j: await res.json().catch(() => ({})) }; } const refusal = (r) => [`${r.status} ${r.j.error || "error"}: ${r.j.message || ""}`, ...(r.j.problems || []).slice(1).map((p) => ` also ${p.code}: ${p.message}`), ...(r.j.problems || []).flatMap((p) => [...(p.missing || []).map((x) => ` missing ${x}`), ...(p.mismatched || []).map((x) => ` mismatched ${x}`)])].join("\n"); // ── what is published ── const idx = await signed("GET", `/v1/admin/${account}/config`); if (!idx.ok) die(`reading the published index: ${refusal(idx)}${idx.status === 401 ? "\n(is this key listed in the account's private/tenant.json serverKeys? has it expired?)" : idx.status === 403 ? "\n(the key lacks a config scope)" : ""}`); const remote = new Map((idx.j.files || []).map((f) => [f.path, f])); const published = idx.j.manifest; if (published && published.version >= manifest.version) die(`manifest version ${manifest.version} is not above the published ${published.version}: re-sign with sign-config.mjs --min-version ${published.version + 1}`); const added = [...local.keys()].filter((p) => p !== "manifest.json" && !remote.has(p)).sort(); const changed = [...local.keys()].filter((p) => p !== "manifest.json" && remote.has(p) && remote.get(p).sha256 !== sha(local.get(p))).sort(); const gone = [...remote.keys()].filter((p) => p !== "manifest.json" && !local.has(p)).sort(); // ── the readable diff (field level when a baseline folder is given) ── const baseline = opt("baseline") && existsSync(opt("baseline")) ? readFolder(opt("baseline")) : null; function jsonDiff(a, b, path = "", out = []) { if (out.length > 40) return out; if (typeof a !== typeof b || Array.isArray(a) !== Array.isArray(b) || a === null || b === null || typeof a !== "object") { if (JSON.stringify(a) !== JSON.stringify(b)) out.push(a === undefined ? `+ ${path || "/"}` : b === undefined ? `- ${path || "/"}` : `~ ${path || "/"}`); return out; } if (Array.isArray(a)) { const named = (x) => x && typeof x === "object" && (x.name || x.id); if (a.every(named) && b.every(named)) { const am = new Map(a.map((x) => [named(x), x])), bm = new Map(b.map((x) => [named(x), x])); for (const [k, v] of bm) jsonDiff(am.get(k), v, `${path}/${k}`, out); for (const k of am.keys()) if (!bm.has(k)) out.push(`- ${path}/${k}`); return out; } for (let i = 0; i < Math.max(a.length, b.length); i++) jsonDiff(a[i], b[i], `${path}/${i}`, out); return out; } for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) jsonDiff(a[k], b[k], `${path}/${k}`, out); return out; } const details = {}; for (const p of changed) { if (!baseline?.has(p) || !p.endsWith(".json")) continue; try { details[p] = jsonDiff(JSON.parse(baseline.get(p)), JSON.parse(local.get(p))); } catch {} } function summary(p) { // one line saying what a new or changed streamline does, from the files themselves try { const j = JSON.parse(local.get(p)); if (/^private\/agents\//.test(p)) { const tools = (j.mcp || []).flatMap((s) => (s.tools || []).map((t) => `${t.name}${t.confirm ? " (confirm)" : t.readOnly ? " (read)" : ""}`)); return `model ${j.model?.id || "?"}; tools: ${tools.join(", ") || "none"}`; } if (/^agents\//.test(p)) return `"${j.name}" for roles ${(j.roles || []).join(", ")}`; } catch {} return ""; } if (!added.length && !changed.length && !gone.length && remote.get("manifest.json")?.sha256 === sha(local.get("manifest.json"))) { console.log(`${account} on ${base}: already up to date (version ${published?.version})`); process.exit(0); } const body = { files: Object.fromEntries([...added, ...changed, "manifest.json"].map((p) => [p, local.get(p).toString("base64")])), delete: gone }; if (Buffer.byteLength(JSON.stringify(body)) > 30 * 1024 * 1024) die("this publish is over 30 MB; publish large assets one at a time first (the repo's publish-config.mjs does that)"); const r = await signed("POST", `/v1/admin/${account}/config/publish${yes ? "" : "?dryRun=1"}`, body); if (!r.ok) die(`${yes ? "publish" : "dry run"} refused:\n${refusal(r)}`); if (asJSON) { console.log(JSON.stringify({ account, host: base, dryRun: !yes, version: r.j.version, from: published?.version ?? null, key: keyId, added, changed, deleted: gone, fieldChanges: details, worker: r.j }, null, 2)); process.exit(0); } console.log(`${yes ? "PUBLISHED" : "DRY RUN — nothing was written"}: ${account} on ${base}, version ${published?.version ?? "none"} → ${r.j.version} (key ${keyId})`); for (const p of added) console.log(` + ${p}${summary(p) ? ` ${summary(p)}` : ""}`); for (const p of changed) { console.log(` ~ ${p}${summary(p) ? ` ${summary(p)}` : ""}`); for (const line of (details[p] || []).slice(0, 25)) console.log(` ${line}`); } for (const p of gone) console.log(` - ${p}`); console.log(` ${added.length} added, ${changed.length} changed, ${gone.length} deleted; the manifest goes last`); if (!yes) console.log("\nShow this to the account owner. Publish only after they say yes: rerun with --yes.");