// Visitor-token minting — admin-side surface (Task 362). // // Mirrors the mint half of platform/ui/app/lib/visitor-token.ts. Verification // still lives in the UI server (the only verifier; mxy_v cookie + /v/event + // /listings/:slug/click consume tokens). Both processes share one secret file // under /credentials/visitor-token-secret via the wx-create pattern, // so whichever process reads first mints; the other reads the same bytes. // // MAXY_DIR is resolved from process.env.LOG_DIR's parent — the buyers MCP is // spawned with LOG_DIR=/logs (see buyers/PLUGIN.md env block), so // dirname(LOG_DIR) is the brand-scoped config dir whether the brand is maxy, // realagent, or any future brand. import { createHmac, randomBytes } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; const TOKEN_PREFIX = "v1."; const SECRET_BYTES = 32; const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days interface TokenPayload { p: string; e: number; } let cachedSecret: Buffer | null = null; function secretFilePath(): string { const logDir = process.env.LOG_DIR; if (!logDir) { throw new Error("[buyers] LOG_DIR unset — cannot resolve visitor-token secret path"); } return resolve(dirname(logDir), "credentials", "visitor-token-secret"); } function getSecret(): Buffer { if (cachedSecret) return cachedSecret; const path = secretFilePath(); try { const hex = readFileSync(path, "utf-8").trim(); if (hex.length === SECRET_BYTES * 2) { cachedSecret = Buffer.from(hex, "hex"); return cachedSecret; } } catch { /* fall through to mint */ } const fresh = randomBytes(SECRET_BYTES).toString("hex"); try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, fresh, { mode: 0o600, flag: "wx" }); console.error(`[buyers] visitor-token secret minted path=${path}`); } catch { // Race with the UI server (or a peer reader) between read and write; // the second read below picks up their value. } const hex = readFileSync(path, "utf-8").trim(); cachedSecret = Buffer.from(hex, "hex"); return cachedSecret; } function base64urlEncode(buf: Buffer): string { return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } export interface MintOpts { personId: string; expiryMs?: number; } export function mintVisitorToken(opts: MintOpts): { token: string; expiryMs: number } { const expiryMs = opts.expiryMs ?? Date.now() + DEFAULT_TTL_MS; const payload: TokenPayload = { p: opts.personId, e: expiryMs }; const payloadJson = JSON.stringify(payload); const payloadB64 = base64urlEncode(Buffer.from(payloadJson, "utf-8")); const sig = base64urlEncode(createHmac("sha256", getSecret()).update(payloadJson).digest()); return { token: `${TOKEN_PREFIX}${payloadB64}.${sig}`, expiryMs }; }