// 🤖 tools/marketing-shots/demo-lib.mjs — shared helpers for the screenshot kit. // // Seeds an E2EE demo space THROUGH THE REAL PROTOCOL (the server only ever // holds ciphertext, so there's no INSERT-plaintext shortcut). Every member is // provisioned via an admin key-invite → claim → /api/pubkey exactly the way the // browser does it; threads and messages are encrypted client-side here with the // very crypto the web app runs (web/static/crypto.js), then POSTed. // // This module is imported by seed-team.mjs, device-link.mjs, and web-check.mjs. // It is Node-only (uses global fetch + WebCrypto, both present in modern Node) // and imports the repo's real crypto/invite modules by relative path. import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { randomSecret, deriveInvite, generateUserKeypair, generateKey, encrypt, wrapThreadKey, } from "../../web/static/crypto.js"; import { buildInviteFragment } from "../../web/static/invite.js"; const HERE = path.dirname(fileURLToPath(import.meta.url)); export const STATE_PATH = path.join(HERE, ".demo-state.json"); export function readState() { if (!existsSync(STATE_PATH)) return null; return JSON.parse(readFileSync(STATE_PATH, "utf8")); } export function writeState(s) { writeFileSync(STATE_PATH, JSON.stringify(s, null, 2) + "\n"); } export async function postJSON(url, body, headers = { "Content-Type": "application/json" }) { const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); if (!res.ok) { const detail = await res.text().catch(() => ""); throw new Error(`POST ${url} -> HTTP ${res.status} ${detail}`.trim()); } return res.json(); } export function adminHeaders(adminToken) { return { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }; } // Fully provision a member: mint an admin key-invite, claim it (so the member // gets a device token we can post as), generate its keypair, and register the // public key. Returns everything later steps need. When grantsAdmin, the member // becomes an admin (used for "Cass", the operator's signed-in identity). export async function provisionMember(base, adminToken, name, { grantsAdmin = false } = {}) { const admin = adminHeaders(adminToken); const T = randomSecret(); const d = await deriveInvite(T); const create = await postJSON( `${base}/api/admin/key-invites`, { label: name, id: d.id, wrapped: "", claim_hash: d.claimHash, grants_admin: grantsAdmin }, admin, ); const claim = await postJSON(`${base}/api/key-invite/${encodeURIComponent(d.id)}/claim`, { claim_secret: d.claimSecret, }); const kp = await generateUserKeypair(); await postJSON(`${base}/api/pubkey`, { token: claim.token, public_key: kp.publicKey }); return { name, userId: create.user_id, token: claim.token, publicKey: kp.publicKey, privateKey: kp.privateKey }; } // Create a group thread named `name` with everyone in `members` (each a // provisionMember result). `creator` must be one of them. The thread key is // generated here and wrapped to each member's public key; the name rides // encrypted under the thread key. Returns { threadId, key } — key is the raw // AES CryptoKey, reused to encrypt this thread's messages. export async function createThread(base, name, creator, members) { const group = await generateKey(); const memberKeys = []; for (const m of members) { memberKeys.push({ user_id: m.userId, wrapped: await wrapThreadKey(group.b64, m.publicKey) }); } const nameBlob = await encrypt(group.key, { name }); const thread = await postJSON(`${base}/api/threads`, { token: creator.token, kind: "group", name_blob: nameBlob, members: memberKeys, }); return { threadId: thread.thread_id, key: group.key }; } // Post one encrypted chat message as `author` into a thread. `extra` folds in // optional fields (replyTo, action, …). Returns the server row { id, ... } so // callers can target it for replies/reactions. `ts` sets the DISPLAYED time // (the payload ts the web reads); server created_at is always "now". export async function postMessage(base, thread, author, text, ts, extra = {}) { const blob = await encrypt(thread.key, { name: author.name, text, ts, uid: author.userId, ...extra, }); return postJSON(`${base}/api/messages?token=${encodeURIComponent(author.token)}`, { thread_id: thread.threadId, blob, }); } // Post a reaction. Reactions are ordinary encrypted messages of type:"reaction" // (NOT a separate frame) that clients fold into the target message's reaction // tally. targetSid is the reacted-to message's server id; targetUid its author. export async function postReaction(base, thread, reactor, targetMsg, targetAuthor, emoji, ts) { const blob = await encrypt(thread.key, { type: "reaction", targetSid: targetMsg.id, targetUid: targetAuthor.userId, emoji, name: reactor.name, uid: reactor.userId, ts, on: true, }); return postJSON(`${base}/api/messages?token=${encodeURIComponent(reactor.token)}`, { thread_id: thread.threadId, blob, }); } // Mint a fresh single-use "add a device" invite for an existing member (here // always Cass, the operator's identity), shipping that member's keypair wrapped // in the invite so opening the link signs a browser/native app straight in as // them. `origin` is the base the link should point at (localhost for the web // check / a Mac that can reach it, or the https tunnel origin for phones). // Each link is single-use — call again per device. export async function mintDeviceLink(base, adminToken, member, origin, label = "You") { const T = randomSecret(); const d = await deriveInvite(T); const wrapped = await encrypt(d.wrapKey, { pub: member.publicKey, priv: member.privateKey }); await postJSON( `${base}/api/admin/key-invites`, { id: d.id, wrapped, claim_hash: d.claimHash, user_id: member.userId }, adminHeaders(adminToken), ); return `${origin.replace(/\/$/, "")}/join/join#${buildInviteFragment(T, label)}`; } // today(h, m) → epoch ms for h:m local time today (for plausible in-thread times). export function today(h, m) { const d = new Date(); d.setHours(h, m, 0, 0); return d.getTime(); }