@verax-ai/body 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/approve-cli.d.ts +12 -0
  2. package/dist/approve-cli.js +93 -0
  3. package/dist/atomic-write.d.ts +1 -0
  4. package/dist/atomic-write.js +65 -0
  5. package/dist/auth.d.ts +10 -0
  6. package/dist/auth.js +59 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +112 -0
  9. package/dist/config.d.ts +27 -0
  10. package/dist/config.js +71 -0
  11. package/dist/desktop.d.ts +29 -0
  12. package/dist/desktop.js +341 -0
  13. package/dist/doctor.d.ts +8 -0
  14. package/dist/doctor.js +342 -0
  15. package/dist/halt.d.ts +1 -0
  16. package/dist/halt.js +12 -0
  17. package/dist/health-extras.d.ts +11 -0
  18. package/dist/health-extras.js +47 -0
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.js +4 -0
  21. package/dist/inputs-read.d.ts +12 -0
  22. package/dist/inputs-read.js +64 -0
  23. package/dist/inventory-file.d.ts +18 -0
  24. package/dist/inventory-file.js +41 -0
  25. package/dist/keys.d.ts +12 -0
  26. package/dist/keys.js +55 -0
  27. package/dist/main.d.ts +1 -0
  28. package/dist/main.js +34 -0
  29. package/dist/metrics.d.ts +2 -0
  30. package/dist/metrics.js +23 -0
  31. package/dist/no-bypass-scan.d.ts +10 -0
  32. package/dist/no-bypass-scan.js +95 -0
  33. package/dist/operator-cli.d.ts +1 -0
  34. package/dist/operator-cli.js +25 -0
  35. package/dist/operator-credentials.d.ts +23 -0
  36. package/dist/operator-credentials.js +56 -0
  37. package/dist/operator-pairing.d.ts +25 -0
  38. package/dist/operator-pairing.js +67 -0
  39. package/dist/policy-store.d.ts +3 -0
  40. package/dist/policy-store.js +44 -0
  41. package/dist/reconcile-cli.d.ts +24 -0
  42. package/dist/reconcile-cli.js +258 -0
  43. package/dist/revoke.d.ts +1 -0
  44. package/dist/revoke.js +16 -0
  45. package/dist/rp-config.d.ts +15 -0
  46. package/dist/rp-config.js +30 -0
  47. package/dist/server.d.ts +5 -0
  48. package/dist/server.js +484 -0
  49. package/dist/tools/audit.d.ts +2 -0
  50. package/dist/tools/audit.js +48 -0
  51. package/dist/tools/memory.d.ts +10 -0
  52. package/dist/tools/memory.js +137 -0
  53. package/dist/tools/message.d.ts +4 -0
  54. package/dist/tools/message.js +44 -0
  55. package/dist/tools/spend.d.ts +3 -0
  56. package/dist/tools/spend.js +19 -0
  57. package/dist/unlock.d.ts +9 -0
  58. package/dist/unlock.js +109 -0
  59. package/dist/wiring.d.ts +23 -0
  60. package/dist/wiring.js +101 -0
  61. package/dist/witness.d.ts +33 -0
  62. package/dist/witness.js +321 -0
  63. package/package.json +40 -0
package/dist/halt.js ADDED
@@ -0,0 +1,12 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function runHalt(stateDir, writeErr = (s) => process.stderr.write(s)) {
4
+ if (!stateDir) {
5
+ writeErr("verax halt <stateDir>\n");
6
+ return 78;
7
+ }
8
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
9
+ writeFileSync(join(stateDir, "halted"), "", { encoding: "utf8", mode: 0o600 });
10
+ writeErr("halted\n");
11
+ return 0;
12
+ }
@@ -0,0 +1,11 @@
1
+ export type HealthHeartbeat = {
2
+ atMs: number;
3
+ lastDecisionN: number;
4
+ lastEffectN: number;
5
+ };
6
+ export type HealthWitness = {
7
+ class: "self" | "same-org";
8
+ atMs: number;
9
+ };
10
+ export declare function readHeartbeat(stateDir: string): HealthHeartbeat | null;
11
+ export declare function readWitnessPulse(stateDir: string): HealthWitness | null;
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function readHeartbeat(stateDir) {
4
+ const path = join(stateDir, "heartbeat.json");
5
+ if (!existsSync(path))
6
+ return null;
7
+ try {
8
+ const raw = JSON.parse(readFileSync(path, "utf8"));
9
+ if (typeof raw.atMs !== "number" || !Number.isFinite(raw.atMs))
10
+ return null;
11
+ return {
12
+ atMs: raw.atMs,
13
+ lastDecisionN: typeof raw.lastDecisionN === "number" ? raw.lastDecisionN : 0,
14
+ lastEffectN: typeof raw.lastEffectN === "number" ? raw.lastEffectN : 0,
15
+ };
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function readWitnessPulse(stateDir) {
22
+ const path = join(stateDir, "witness-status.jsonl");
23
+ if (!existsSync(path))
24
+ return null;
25
+ try {
26
+ const lines = readFileSync(path, "utf8")
27
+ .split("\n")
28
+ .map((l) => l.trim())
29
+ .filter((l) => l !== "");
30
+ const last = lines[lines.length - 1];
31
+ if (!last)
32
+ return null;
33
+ const row = JSON.parse(last);
34
+ if (typeof row.atMs !== "number" || !Number.isFinite(row.atMs))
35
+ return null;
36
+ if (row.result === "signed" || row.class === "same-org") {
37
+ return { class: "same-org", atMs: row.atMs };
38
+ }
39
+ if (row.result === "self-fallback" || row.class === "self") {
40
+ return { class: "self", atMs: row.atMs };
41
+ }
42
+ return null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
@@ -0,0 +1,4 @@
1
+ export { loadConfig, EX_CONFIG } from "./config.ts";
2
+ export { listen } from "./server.ts";
3
+ export { runDoctor, doctorExit } from "./doctor.ts";
4
+ export { createBodyServices, TOOL_NAMES } from "./wiring.ts";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { loadConfig, EX_CONFIG } from "./config.js";
2
+ export { listen } from "./server.js";
3
+ export { runDoctor, doctorExit } from "./doctor.js";
4
+ export { createBodyServices, TOOL_NAMES } from "./wiring.js";
@@ -0,0 +1,12 @@
1
+ export declare function matchingInputs(stateDir: string, decisions: {
2
+ claims: {
3
+ ref: string | null;
4
+ inputsHash: string | null;
5
+ };
6
+ }[]): Promise<Record<string, unknown>>;
7
+ export declare function inputsPrincipal(stateDir: string, ref: string): Promise<{
8
+ brain: string;
9
+ iss?: string;
10
+ tenant?: string;
11
+ org?: string;
12
+ } | null>;
@@ -0,0 +1,64 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { canonical } from "@cedulon/core";
5
+ function sha256Canonical(value) {
6
+ return createHash("sha256").update(canonical(value), "utf8").digest("hex");
7
+ }
8
+ export async function matchingInputs(stateDir, decisions) {
9
+ let text;
10
+ try {
11
+ text = await readFile(join(stateDir, "inputs.jsonl"), "utf8");
12
+ }
13
+ catch (err) {
14
+ if (err.code === "ENOENT")
15
+ return {};
16
+ throw err;
17
+ }
18
+ const byRef = new Map();
19
+ for (const line of text.split("\n")) {
20
+ if (line === "")
21
+ continue;
22
+ const row = JSON.parse(line);
23
+ if (typeof row.ref === "string")
24
+ byRef.set(row.ref, row.inputs);
25
+ }
26
+ const out = {};
27
+ for (const d of decisions) {
28
+ const ref = d.claims.ref;
29
+ if (!ref || typeof d.claims.inputsHash !== "string")
30
+ continue;
31
+ const doc = byRef.get(ref);
32
+ if (doc !== undefined && sha256Canonical(doc) === d.claims.inputsHash) {
33
+ out[ref] = doc;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ export async function inputsPrincipal(stateDir, ref) {
39
+ let text;
40
+ try {
41
+ text = await readFile(join(stateDir, "inputs.jsonl"), "utf8");
42
+ }
43
+ catch (err) {
44
+ if (err.code === "ENOENT")
45
+ return null;
46
+ throw err;
47
+ }
48
+ let found = null;
49
+ for (const line of text.split("\n")) {
50
+ if (line === "")
51
+ continue;
52
+ const row = JSON.parse(line);
53
+ if (row.ref === ref)
54
+ found = row.inputs?.principal ?? null;
55
+ }
56
+ if (!found || typeof found.brain !== "string")
57
+ return null;
58
+ return {
59
+ brain: found.brain,
60
+ ...(typeof found.iss === "string" ? { iss: found.iss } : {}),
61
+ ...(typeof found.tenant === "string" ? { tenant: found.tenant } : {}),
62
+ ...(typeof found.org === "string" ? { org: found.org } : {}),
63
+ };
64
+ }
@@ -0,0 +1,18 @@
1
+ import { type Inventory } from "@verax-ai/inventory";
2
+ export type InventoryDoor = {
3
+ inventory: Inventory;
4
+ } | {
5
+ inventory: null;
6
+ reason?: string;
7
+ };
8
+ export type InventoryHealth = {
9
+ source: string;
10
+ takenAtMs: number;
11
+ agents: number;
12
+ };
13
+ /**
14
+ * Read the declared roster. A missing path or missing file is absence.
15
+ * A broken document is still absence, with a reason; no half-filled model.
16
+ */
17
+ export declare function readInventoryFile(path: string | null | undefined): InventoryDoor;
18
+ export declare function inventoryHealth(door: InventoryDoor): InventoryHealth | null;
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { parseInventory } from "@verax-ai/inventory";
3
+ /**
4
+ * Read the declared roster. A missing path or missing file is absence.
5
+ * A broken document is still absence, with a reason; no half-filled model.
6
+ */
7
+ export function readInventoryFile(path) {
8
+ if (path === undefined || path === null || path.trim() === "") {
9
+ return { inventory: null };
10
+ }
11
+ if (!existsSync(path)) {
12
+ return { inventory: null };
13
+ }
14
+ let text;
15
+ try {
16
+ text = readFileSync(path, "utf8");
17
+ }
18
+ catch {
19
+ return { inventory: null, reason: "unreadable" };
20
+ }
21
+ let raw;
22
+ try {
23
+ raw = JSON.parse(text);
24
+ }
25
+ catch {
26
+ return { inventory: null, reason: "invalid-json" };
27
+ }
28
+ const parsed = parseInventory(raw);
29
+ if (!parsed.ok)
30
+ return { inventory: null, reason: parsed.reason };
31
+ return { inventory: parsed.value };
32
+ }
33
+ export function inventoryHealth(door) {
34
+ if (!door.inventory)
35
+ return null;
36
+ return {
37
+ source: door.inventory.source,
38
+ takenAtMs: door.inventory.takenAtMs,
39
+ agents: door.inventory.agents.length,
40
+ };
41
+ }
package/dist/keys.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export type KeyPair = {
2
+ privateKeyPem: string;
3
+ publicKeyPem: string;
4
+ };
5
+ export declare class KeysPartialError extends Error {
6
+ readonly code = 78;
7
+ constructor();
8
+ }
9
+ export declare function loadOrCreateSigners(stateDir: string): {
10
+ recordSigner: KeyPair;
11
+ effectSigner: KeyPair;
12
+ };
package/dist/keys.js ADDED
@@ -0,0 +1,55 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { EX_CONFIG } from "./config.js";
5
+ export class KeysPartialError extends Error {
6
+ code = EX_CONFIG;
7
+ constructor() {
8
+ super("keys-partial");
9
+ this.name = "KeysPartialError";
10
+ }
11
+ }
12
+ function pair() {
13
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
14
+ return {
15
+ publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
16
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
17
+ };
18
+ }
19
+ function writePemAtomic(path, pem) {
20
+ const tmp = `${path}.tmp`;
21
+ writeFileSync(tmp, pem, { encoding: "utf8", mode: 0o600 });
22
+ renameSync(tmp, path);
23
+ }
24
+ export function loadOrCreateSigners(stateDir) {
25
+ const dir = join(stateDir, "keys");
26
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
27
+ const recPriv = join(dir, "record.private.pem");
28
+ const recPub = join(dir, "record.public.pem");
29
+ const effPriv = join(dir, "effect.private.pem");
30
+ const effPub = join(dir, "effect.public.pem");
31
+ const paths = [recPriv, recPub, effPriv, effPub];
32
+ const present = paths.filter((p) => existsSync(p)).length;
33
+ if (present !== 0 && present !== 4) {
34
+ throw new KeysPartialError();
35
+ }
36
+ if (present === 0) {
37
+ const record = pair();
38
+ const effect = pair();
39
+ writePemAtomic(recPriv, record.privateKeyPem);
40
+ writePemAtomic(recPub, record.publicKeyPem);
41
+ writePemAtomic(effPriv, effect.privateKeyPem);
42
+ writePemAtomic(effPub, effect.publicKeyPem);
43
+ return { recordSigner: record, effectSigner: effect };
44
+ }
45
+ return {
46
+ recordSigner: {
47
+ privateKeyPem: readFileSync(recPriv, "utf8"),
48
+ publicKeyPem: readFileSync(recPub, "utf8"),
49
+ },
50
+ effectSigner: {
51
+ privateKeyPem: readFileSync(effPriv, "utf8"),
52
+ publicKeyPem: readFileSync(effPub, "utf8"),
53
+ },
54
+ };
55
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function main(env?: NodeJS.ProcessEnv, argv?: readonly string[]): Promise<void>;
package/dist/main.js ADDED
@@ -0,0 +1,34 @@
1
+ import { EX_CONFIG, loadConfig, overlayInventoryArg } from "./config.js";
2
+ import { KeysPartialError } from "./keys.js";
3
+ import { listen } from "./server.js";
4
+ export async function main(env = process.env, argv = process.argv) {
5
+ const overlaid = overlayInventoryArg(env, argv);
6
+ if ("error" in overlaid) {
7
+ process.stderr.write(`${overlaid.error}\n`);
8
+ process.exit(EX_CONFIG);
9
+ }
10
+ const loaded = loadConfig(overlaid);
11
+ if (!loaded.ok) {
12
+ process.stderr.write(`${loaded.reason}\n`);
13
+ process.exit(loaded.code);
14
+ }
15
+ try {
16
+ const server = await listen(loaded.value);
17
+ const shutdown = () => {
18
+ server.close(() => process.exit(0));
19
+ };
20
+ process.once("SIGINT", shutdown);
21
+ process.once("SIGTERM", shutdown);
22
+ }
23
+ catch (err) {
24
+ if (err instanceof KeysPartialError) {
25
+ process.stderr.write("keys-partial\n");
26
+ process.exit(err.code);
27
+ }
28
+ throw err;
29
+ }
30
+ }
31
+ const invoked = process.argv[1] && process.argv[1].replace(/\\/g, "/").endsWith("/main.ts");
32
+ if (invoked) {
33
+ await main();
34
+ }
@@ -0,0 +1,2 @@
1
+ export declare function bumpMetric(stateDir: string, key: string): Promise<number>;
2
+ export declare function bumpUnauthenticated(stateDir: string): Promise<number>;
@@ -0,0 +1,23 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function bumpMetric(stateDir, key) {
4
+ const path = join(stateDir, "metrics.json");
5
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
6
+ let raw = {};
7
+ try {
8
+ raw = JSON.parse(await readFile(path, "utf8"));
9
+ }
10
+ catch {
11
+ raw = {};
12
+ }
13
+ const count = Number(raw[key] ?? 0) + 1;
14
+ raw[key] = count;
15
+ await writeFile(path, `${JSON.stringify(raw)}\n`, {
16
+ encoding: "utf8",
17
+ mode: 0o600,
18
+ });
19
+ return count;
20
+ }
21
+ export async function bumpUnauthenticated(stateDir) {
22
+ return bumpMetric(stateDir, "unauthenticated_requests");
23
+ }
@@ -0,0 +1,10 @@
1
+ export type BypassHit = {
2
+ file: string;
3
+ line: number;
4
+ why: string;
5
+ text: string;
6
+ };
7
+ export declare function scanNoBypass(base?: string, extra?: Array<{
8
+ file: string;
9
+ text: string;
10
+ }>): BypassHit[];
@@ -0,0 +1,95 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
5
+ function walk(dir, out = []) {
6
+ for (const name of readdirSync(dir)) {
7
+ const full = join(dir, name);
8
+ if (statSync(full).isDirectory()) {
9
+ walk(full, out);
10
+ continue;
11
+ }
12
+ if (name.endsWith(".ts") || name.endsWith(".js") || name.endsWith(".mjs")) {
13
+ out.push(full);
14
+ }
15
+ }
16
+ return out;
17
+ }
18
+ const IMPORT_TOOLS = /(?:from|import)\s+["'][^"']*tools\/[^"']+["']/;
19
+ const DYNAMIC_IMPORT = /\bimport\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*\(/g;
20
+ const NEW_FUNCTION = /new Function\s*\(/;
21
+ const EVAL_CALL = /\beval\s*\(/;
22
+ const VM = /\bvm\b/;
23
+ const PROCESS_BINDING = /process\.binding/;
24
+ const REQUIRE = /\brequire\s*\(/;
25
+ const CREATE_REQUIRE = /\bcreateRequire\b/;
26
+ const CONCAT_TOOLS = /["']\.\/to["']\s*\+\s*["']ols/;
27
+ const CHILD = /\bchild_process\b/;
28
+ const WORKER = /\bworker_threads\b/;
29
+ function lineAt(text, index) {
30
+ return text.slice(0, index).split(/\n/).length;
31
+ }
32
+ export function scanNoBypass(base = pkgRoot, extra = []) {
33
+ const hits = [];
34
+ const files = [
35
+ ...walk(join(base, "src")).map((full) => ({
36
+ file: relative(base, full).replace(/\\/g, "/"),
37
+ text: readFileSync(full, "utf8"),
38
+ })),
39
+ ...(existsSync(join(base, "tests"))
40
+ ? walk(join(base, "tests")).map((full) => ({
41
+ file: relative(base, full).replace(/\\/g, "/"),
42
+ text: readFileSync(full, "utf8"),
43
+ }))
44
+ : []),
45
+ ...extra,
46
+ ];
47
+ for (const { file, text } of files) {
48
+ const rel = file.replace(/\\/g, "/");
49
+ if (rel === "src/no-bypass-scan.ts")
50
+ continue;
51
+ const wiring = rel === "src/wiring.ts";
52
+ // Desktop supervises issuer/body/panel. It is not a tool-call path.
53
+ const desktop = rel === "src/desktop.ts";
54
+ const originalLines = text.split(/\r?\n/);
55
+ for (const match of text.matchAll(DYNAMIC_IMPORT)) {
56
+ const index = match.index ?? 0;
57
+ const line = lineAt(text, index);
58
+ hits.push({
59
+ file,
60
+ line,
61
+ why: "dynamic-import",
62
+ text: (originalLines[line - 1] ?? "").trim(),
63
+ });
64
+ }
65
+ const lines = text.split(/\r?\n/);
66
+ for (let i = 0; i < lines.length; i += 1) {
67
+ const line = lines[i] ?? "";
68
+ const push = (why) => {
69
+ hits.push({ file, line: i + 1, why, text: (originalLines[i] ?? "").trim() });
70
+ };
71
+ if (IMPORT_TOOLS.test(line) && !wiring) {
72
+ push("static import of tools/");
73
+ }
74
+ if (NEW_FUNCTION.test(line))
75
+ push("new-function");
76
+ if (EVAL_CALL.test(line))
77
+ push("eval");
78
+ if (VM.test(line))
79
+ push("vm");
80
+ if (PROCESS_BINDING.test(line))
81
+ push("process.binding");
82
+ if (REQUIRE.test(line))
83
+ push("require(");
84
+ if (CREATE_REQUIRE.test(line))
85
+ push("createRequire");
86
+ if (CONCAT_TOOLS.test(line))
87
+ push("concatenated tools path");
88
+ if (CHILD.test(line) && !desktop)
89
+ push("child_process");
90
+ if (WORKER.test(line))
91
+ push("worker_threads");
92
+ }
93
+ }
94
+ return hits;
95
+ }
@@ -0,0 +1 @@
1
+ export declare function runOperator(argv: string[]): Promise<number>;
@@ -0,0 +1,25 @@
1
+ import { beginPairing } from "./operator-pairing.js";
2
+ export async function runOperator(argv) {
3
+ if (argv[1] !== "enroll") {
4
+ process.stderr.write("verax operator enroll --state <dir>\n");
5
+ return 78;
6
+ }
7
+ let stateDir = "";
8
+ for (let i = 2; i < argv.length; i += 1) {
9
+ if (argv[i] === "--state") {
10
+ stateDir = argv[++i] ?? "";
11
+ continue;
12
+ }
13
+ if (argv[i]?.startsWith("-")) {
14
+ process.stderr.write(`flag-unknown:${argv[i]}\n`);
15
+ return 78;
16
+ }
17
+ }
18
+ if (!stateDir) {
19
+ process.stderr.write("verax operator enroll --state <dir>\n");
20
+ return 78;
21
+ }
22
+ const { code } = beginPairing(stateDir);
23
+ process.stdout.write(`${code}\n`);
24
+ return 0;
25
+ }
@@ -0,0 +1,23 @@
1
+ export declare const CREDENTIALS_FILE = "operator-credentials.json";
2
+ export declare const DEFAULT_OPERATOR_SUB = "operator-1";
3
+ export type StoredCredential = {
4
+ id: string;
5
+ publicKey: string;
6
+ counter: number;
7
+ sub: string;
8
+ };
9
+ export type CredentialStore = {
10
+ credentials: StoredCredential[];
11
+ };
12
+ export declare function credentialsPath(stateDir: string): string;
13
+ export declare function readCredentials(stateDir: string): StoredCredential[];
14
+ /**
15
+ * The file is written only when an operator enrolls, so its presence is the
16
+ * answer. Asking whether it parses would read a truncated or mistyped file as
17
+ * "no operator" and reopen the passkey-less flow; the issuer stays closed
18
+ * instead, and a fresh enrollment replaces the file.
19
+ */
20
+ export declare function hasRegisteredOperator(stateDir: string): boolean;
21
+ export declare function findCredential(stateDir: string, id: string): StoredCredential | null;
22
+ export declare function saveCredential(stateDir: string, credential: StoredCredential): void;
23
+ export declare function updateCounter(stateDir: string, id: string, counter: number): boolean;
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeFileAtomic } from "./atomic-write.js";
4
+ export const CREDENTIALS_FILE = "operator-credentials.json";
5
+ export const DEFAULT_OPERATOR_SUB = "operator-1";
6
+ export function credentialsPath(stateDir) {
7
+ return join(stateDir, CREDENTIALS_FILE);
8
+ }
9
+ export function readCredentials(stateDir) {
10
+ const path = credentialsPath(stateDir);
11
+ if (!existsSync(path))
12
+ return [];
13
+ try {
14
+ const row = JSON.parse(readFileSync(path, "utf8"));
15
+ if (!Array.isArray(row.credentials))
16
+ return [];
17
+ return row.credentials.filter((c) => typeof c?.id === "string" &&
18
+ typeof c.publicKey === "string" &&
19
+ typeof c.counter === "number" &&
20
+ typeof c.sub === "string");
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ /**
27
+ * The file is written only when an operator enrolls, so its presence is the
28
+ * answer. Asking whether it parses would read a truncated or mistyped file as
29
+ * "no operator" and reopen the passkey-less flow; the issuer stays closed
30
+ * instead, and a fresh enrollment replaces the file.
31
+ */
32
+ export function hasRegisteredOperator(stateDir) {
33
+ return existsSync(credentialsPath(stateDir));
34
+ }
35
+ export function findCredential(stateDir, id) {
36
+ return readCredentials(stateDir).find((c) => c.id === id) ?? null;
37
+ }
38
+ export function saveCredential(stateDir, credential) {
39
+ const credentials = readCredentials(stateDir);
40
+ const next = credentials.filter((c) => c.id !== credential.id);
41
+ next.push(credential);
42
+ writeFileAtomic(credentialsPath(stateDir), `${JSON.stringify({ credentials: next })}\n`);
43
+ }
44
+ export function updateCounter(stateDir, id, counter) {
45
+ const credentials = readCredentials(stateDir);
46
+ const row = credentials.find((c) => c.id === id);
47
+ if (!row)
48
+ return false;
49
+ if (counter < row.counter)
50
+ return false;
51
+ if (counter === row.counter && counter > 0)
52
+ return false;
53
+ row.counter = counter;
54
+ writeFileAtomic(credentialsPath(stateDir), `${JSON.stringify({ credentials })}\n`);
55
+ return true;
56
+ }
@@ -0,0 +1,25 @@
1
+ export declare const PAIRING_TTL_MS: number;
2
+ export declare const PAIRING_MAX_ATTEMPTS = 5;
3
+ export declare const PAIRING_FILE = "operator-pairing.json";
4
+ export type PairingRecord = {
5
+ hash: string;
6
+ expiresAtMs: number;
7
+ attempts: number;
8
+ };
9
+ export type PairingCheck = {
10
+ ok: true;
11
+ } | {
12
+ ok: false;
13
+ reason: "missing" | "expired" | "mismatch" | "burned";
14
+ };
15
+ export declare function pairingPath(stateDir: string): string;
16
+ export declare function hashPairingCode(code: string): string;
17
+ export declare function mintPairingCode(): string;
18
+ export declare function writePairing(stateDir: string, record: PairingRecord): void;
19
+ export declare function readPairing(stateDir: string): PairingRecord | null;
20
+ export declare function beginPairing(stateDir: string, nowMs?: number): {
21
+ code: string;
22
+ };
23
+ export declare function checkPairing(stateDir: string, code: string, nowMs?: number): PairingCheck;
24
+ /** A used pairing is spent so the same code cannot enroll twice. */
25
+ export declare function consumePairing(stateDir: string): void;
@@ -0,0 +1,67 @@
1
+ import { createHash, randomInt } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeFileAtomic } from "./atomic-write.js";
5
+ export const PAIRING_TTL_MS = 5 * 60_000;
6
+ export const PAIRING_MAX_ATTEMPTS = 5;
7
+ export const PAIRING_FILE = "operator-pairing.json";
8
+ export function pairingPath(stateDir) {
9
+ return join(stateDir, PAIRING_FILE);
10
+ }
11
+ export function hashPairingCode(code) {
12
+ return createHash("sha256").update(code, "utf8").digest("hex");
13
+ }
14
+ export function mintPairingCode() {
15
+ return String(randomInt(0, 100_000_000)).padStart(8, "0");
16
+ }
17
+ export function writePairing(stateDir, record) {
18
+ writeFileAtomic(pairingPath(stateDir), `${JSON.stringify(record)}\n`);
19
+ }
20
+ export function readPairing(stateDir) {
21
+ const path = pairingPath(stateDir);
22
+ if (!existsSync(path))
23
+ return null;
24
+ try {
25
+ const row = JSON.parse(readFileSync(path, "utf8"));
26
+ if (typeof row.hash !== "string" || typeof row.expiresAtMs !== "number" || typeof row.attempts !== "number") {
27
+ return null;
28
+ }
29
+ return { hash: row.hash, expiresAtMs: row.expiresAtMs, attempts: row.attempts };
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function beginPairing(stateDir, nowMs = Date.now()) {
36
+ const code = mintPairingCode();
37
+ writePairing(stateDir, {
38
+ hash: hashPairingCode(code),
39
+ expiresAtMs: nowMs + PAIRING_TTL_MS,
40
+ attempts: 0,
41
+ });
42
+ return { code };
43
+ }
44
+ export function checkPairing(stateDir, code, nowMs = Date.now()) {
45
+ const row = readPairing(stateDir);
46
+ if (!row)
47
+ return { ok: false, reason: "missing" };
48
+ if (row.attempts >= PAIRING_MAX_ATTEMPTS)
49
+ return { ok: false, reason: "burned" };
50
+ if (nowMs > row.expiresAtMs)
51
+ return { ok: false, reason: "expired" };
52
+ if (hashPairingCode(code) !== row.hash) {
53
+ writePairing(stateDir, { ...row, attempts: row.attempts + 1 });
54
+ const again = readPairing(stateDir);
55
+ if (again && again.attempts >= PAIRING_MAX_ATTEMPTS)
56
+ return { ok: false, reason: "burned" };
57
+ return { ok: false, reason: "mismatch" };
58
+ }
59
+ return { ok: true };
60
+ }
61
+ /** A used pairing is spent so the same code cannot enroll twice. */
62
+ export function consumePairing(stateDir) {
63
+ const path = pairingPath(stateDir);
64
+ if (!existsSync(path))
65
+ return;
66
+ writeFileAtomic(path, `${JSON.stringify({ hash: "", expiresAtMs: 0, attempts: PAIRING_MAX_ATTEMPTS })}\n`);
67
+ }