| 1 | // π€ tools/marketing-shots/demo-lib.mjs β shared helpers for the screenshot kit. |
| 2 | // |
| 3 | // Seeds an E2EE demo space THROUGH THE REAL PROTOCOL (the server only ever |
| 4 | // holds ciphertext, so there's no INSERT-plaintext shortcut). Every member is |
| 5 | // provisioned via an admin key-invite β claim β /api/pubkey exactly the way the |
| 6 | // browser does it; threads and messages are encrypted client-side here with the |
| 7 | // very crypto the web app runs (web/static/crypto.js), then POSTed. |
| 8 | // |
| 9 | // This module is imported by seed-team.mjs, device-link.mjs, and web-check.mjs. |
| 10 | // It is Node-only (uses global fetch + WebCrypto, both present in modern Node) |
| 11 | // and imports the repo's real crypto/invite modules by relative path. |
| 12 | |
| 13 | import { readFileSync, writeFileSync, existsSync } from "node:fs"; |
| 14 | import { fileURLToPath } from "node:url"; |
| 15 | import path from "node:path"; |
| 16 | import { |
| 17 | randomSecret, |
| 18 | deriveInvite, |
| 19 | generateUserKeypair, |
| 20 | generateKey, |
| 21 | encrypt, |
| 22 | wrapThreadKey, |
| 23 | } from "../../web/static/crypto.js"; |
| 24 | import { buildInviteFragment } from "../../web/static/invite.js"; |
| 25 | |
| 26 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 27 | export const STATE_PATH = path.join(HERE, ".demo-state.json"); |
| 28 | |
| 29 | export function readState() { |
| 30 | if (!existsSync(STATE_PATH)) return null; |
| 31 | return JSON.parse(readFileSync(STATE_PATH, "utf8")); |
| 32 | } |
| 33 | export function writeState(s) { |
| 34 | writeFileSync(STATE_PATH, JSON.stringify(s, null, 2) + "\n"); |
| 35 | } |
| 36 | |
| 37 | export async function postJSON(url, body, headers = { "Content-Type": "application/json" }) { |
| 38 | const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); |
| 39 | if (!res.ok) { |
| 40 | const detail = await res.text().catch(() => ""); |
| 41 | throw new Error(`POST ${url} -> HTTP ${res.status} ${detail}`.trim()); |
| 42 | } |
| 43 | return res.json(); |
| 44 | } |
| 45 | |
| 46 | export function adminHeaders(adminToken) { |
| 47 | return { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }; |
| 48 | } |
| 49 | |
| 50 | // Fully provision a member: mint an admin key-invite, claim it (so the member |
| 51 | // gets a device token we can post as), generate its keypair, and register the |
| 52 | // public key. Returns everything later steps need. When grantsAdmin, the member |
| 53 | // becomes an admin (used for "Cass", the operator's signed-in identity). |
| 54 | export async function provisionMember(base, adminToken, name, { grantsAdmin = false } = {}) { |
| 55 | const admin = adminHeaders(adminToken); |
| 56 | const T = randomSecret(); |
| 57 | const d = await deriveInvite(T); |
| 58 | const create = await postJSON( |
| 59 | `${base}/api/admin/key-invites`, |
| 60 | { label: name, id: d.id, wrapped: "", claim_hash: d.claimHash, grants_admin: grantsAdmin }, |
| 61 | admin, |
| 62 | ); |
| 63 | const claim = await postJSON(`${base}/api/key-invite/${encodeURIComponent(d.id)}/claim`, { |
| 64 | claim_secret: d.claimSecret, |
| 65 | }); |
| 66 | const kp = await generateUserKeypair(); |
| 67 | await postJSON(`${base}/api/pubkey`, { token: claim.token, public_key: kp.publicKey }); |
| 68 | return { name, userId: create.user_id, token: claim.token, publicKey: kp.publicKey, privateKey: kp.privateKey }; |
| 69 | } |
| 70 | |
| 71 | // Create a group thread named `name` with everyone in `members` (each a |
| 72 | // provisionMember result). `creator` must be one of them. The thread key is |
| 73 | // generated here and wrapped to each member's public key; the name rides |
| 74 | // encrypted under the thread key. Returns { threadId, key } β key is the raw |
| 75 | // AES CryptoKey, reused to encrypt this thread's messages. |
| 76 | export async function createThread(base, name, creator, members) { |
| 77 | const group = await generateKey(); |
| 78 | const memberKeys = []; |
| 79 | for (const m of members) { |
| 80 | memberKeys.push({ user_id: m.userId, wrapped: await wrapThreadKey(group.b64, m.publicKey) }); |
| 81 | } |
| 82 | const nameBlob = await encrypt(group.key, { name }); |
| 83 | const thread = await postJSON(`${base}/api/threads`, { |
| 84 | token: creator.token, |
| 85 | kind: "group", |
| 86 | name_blob: nameBlob, |
| 87 | members: memberKeys, |
| 88 | }); |
| 89 | return { threadId: thread.thread_id, key: group.key }; |
| 90 | } |
| 91 | |
| 92 | // Post one encrypted chat message as `author` into a thread. `extra` folds in |
| 93 | // optional fields (replyTo, action, β¦). Returns the server row { id, ... } so |
| 94 | // callers can target it for replies/reactions. `ts` sets the DISPLAYED time |
| 95 | // (the payload ts the web reads); server created_at is always "now". |
| 96 | export async function postMessage(base, thread, author, text, ts, extra = {}) { |
| 97 | const blob = await encrypt(thread.key, { |
| 98 | name: author.name, |
| 99 | text, |
| 100 | ts, |
| 101 | uid: author.userId, |
| 102 | ...extra, |
| 103 | }); |
| 104 | return postJSON(`${base}/api/messages?token=${encodeURIComponent(author.token)}`, { |
| 105 | thread_id: thread.threadId, |
| 106 | blob, |
| 107 | }); |
| 108 | } |
| 109 | |
| 110 | // Post a reaction. Reactions are ordinary encrypted messages of type:"reaction" |
| 111 | // (NOT a separate frame) that clients fold into the target message's reaction |
| 112 | // tally. targetSid is the reacted-to message's server id; targetUid its author. |
| 113 | export async function postReaction(base, thread, reactor, targetMsg, targetAuthor, emoji, ts) { |
| 114 | const blob = await encrypt(thread.key, { |
| 115 | type: "reaction", |
| 116 | targetSid: targetMsg.id, |
| 117 | targetUid: targetAuthor.userId, |
| 118 | emoji, |
| 119 | name: reactor.name, |
| 120 | uid: reactor.userId, |
| 121 | ts, |
| 122 | on: true, |
| 123 | }); |
| 124 | return postJSON(`${base}/api/messages?token=${encodeURIComponent(reactor.token)}`, { |
| 125 | thread_id: thread.threadId, |
| 126 | blob, |
| 127 | }); |
| 128 | } |
| 129 | |
| 130 | // Mint a fresh single-use "add a device" invite for an existing member (here |
| 131 | // always Cass, the operator's identity), shipping that member's keypair wrapped |
| 132 | // in the invite so opening the link signs a browser/native app straight in as |
| 133 | // them. `origin` is the base the link should point at (localhost for the web |
| 134 | // check / a Mac that can reach it, or the https tunnel origin for phones). |
| 135 | // Each link is single-use β call again per device. |
| 136 | export async function mintDeviceLink(base, adminToken, member, origin, label = "You") { |
| 137 | const T = randomSecret(); |
| 138 | const d = await deriveInvite(T); |
| 139 | const wrapped = await encrypt(d.wrapKey, { pub: member.publicKey, priv: member.privateKey }); |
| 140 | await postJSON( |
| 141 | `${base}/api/admin/key-invites`, |
| 142 | { id: d.id, wrapped, claim_hash: d.claimHash, user_id: member.userId }, |
| 143 | adminHeaders(adminToken), |
| 144 | ); |
| 145 | return `${origin.replace(/\/$/, "")}/join/join#${buildInviteFragment(T, label)}`; |
| 146 | } |
| 147 | |
| 148 | // today(h, m) β epoch ms for h:m local time today (for plausible in-thread times). |
| 149 | export function today(h, m) { |
| 150 | const d = new Date(); |
| 151 | d.setHours(h, m, 0, 0); |
| 152 | return d.getTime(); |
| 153 | } |
| 154 | |