eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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
13import { readFileSync, writeFileSync, existsSync } from "node:fs";
14import { fileURLToPath } from "node:url";
15import path from "node:path";
16import {
17 randomSecret,
18 deriveInvite,
19 generateUserKeypair,
20 generateKey,
21 encrypt,
22 wrapThreadKey,
23} from "../../web/static/crypto.js";
24import { buildInviteFragment } from "../../web/static/invite.js";
25
26const HERE = path.dirname(fileURLToPath(import.meta.url));
27export const STATE_PATH = path.join(HERE, ".demo-state.json");
28
29export function readState() {
30 if (!existsSync(STATE_PATH)) return null;
31 return JSON.parse(readFileSync(STATE_PATH, "utf8"));
32}
33export function writeState(s) {
34 writeFileSync(STATE_PATH, JSON.stringify(s, null, 2) + "\n");
35}
36
37export 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
46export 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).
54export 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.
76export 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".
96export 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.
113export 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.
136export 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).
149export function today(h, m) {
150 const d = new Date();
151 d.setHours(h, m, 0, 0);
152 return d.getTime();
153}
154