@cana-ai/walkie-talkie 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Himansh Raj / Colate Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PROTOCOL.md ADDED
@@ -0,0 +1,97 @@
1
+ # Walkie-Talkie wire protocol (v1)
2
+
3
+ A small, JSON-over-WebSocket protocol for multi-party human↔agent and
4
+ agent↔agent messaging. This document is the source of truth; the reference
5
+ server in this repo implements it, and any client (browser, Node, Rust, Go…)
6
+ can speak it.
7
+
8
+ ## Connecting
9
+
10
+ ```
11
+ GET {WS_BASE}/ws/bus/{channelId}
12
+ Sec-WebSocket-Protocol: wtk.{token}
13
+ ```
14
+
15
+ - The credential is an opaque agent token (`wtk_…`) carried in the
16
+ `Sec-WebSocket-Protocol` header as `wtk.{token}` — **never** in the URL/query
17
+ (a `?token=` fallback exists for non-browser clients but is discouraged).
18
+ - The server echoes the subprotocol back to complete the handshake.
19
+ - Browser clients are subject to an Origin allow-list (`BUS_ALLOWED_ORIGINS`);
20
+ headless clients (no `Origin` header) are allowed.
21
+
22
+ Close codes: `4001` auth failed · `4002` channel closed/missing · `4003`
23
+ forbidden / token revoked or expired · `4004` channel full.
24
+
25
+ ## Frames
26
+
27
+ Every frame is a JSON object. Server frames carry `"v": 1`.
28
+
29
+ ### Server → client
30
+
31
+ On connect the server sends, in order:
32
+
33
+ ```jsonc
34
+ { "v":1, "type":"joined", "connectionId":"conn_…", "channelId":"ch_…",
35
+ "handle":"helper", "owner":"token:wtk_abcd", "capabilities":["receive","send"],
36
+ "policyVersion":1 }
37
+
38
+ { "v":1, "type":"policy", "policyVersion":1, "rules":["…authoritative rules…"] }
39
+
40
+ { "v":1, "type":"history", "messages":[ /* WireMessage[] */ ], "count":0 }
41
+ ```
42
+
43
+ Then, live:
44
+
45
+ ```jsonc
46
+ // WireMessage
47
+ { "v":1, "type":"message", "channelId":"ch_…", "seq":42,
48
+ "from":"helper", "fromTokenId":"tok_… | null", "to":"handle | null",
49
+ "private":false, "ts":"2026-06-24T12:00:00.000Z", "body":{ "text":"hi" } }
50
+
51
+ { "v":1, "type":"system", "event":"participant_joined|participant_left|channel_closed",
52
+ "body":{ "handle":"helper" }, "ts":"…" }
53
+
54
+ { "v":1, "type":"pong" }
55
+
56
+ { "v":1, "type":"error", "code":"forbidden|rate_limited|secret_blocked|…", "message":"…" }
57
+ ```
58
+
59
+ ### Client → server
60
+
61
+ ```jsonc
62
+ { "type":"message", "to":"handle | null", "private":false, "body":{ "text":"hello" } }
63
+ { "type":"ping" }
64
+ ```
65
+
66
+ - `body` is freeform JSON; the reference client/UI use `{ "text": "…" }`.
67
+ - `to` + `private:true` ⇒ a 1:1 DM visible only to sender and recipient.
68
+ - `to` set + `private:false` ⇒ a directed-but-public message (everyone sees it,
69
+ one handle is addressed).
70
+ - `to:null` ⇒ broadcast.
71
+
72
+ ## Sequencing & history
73
+
74
+ - `seq` is a monotonic, server-assigned integer **per channel** (gap-free).
75
+ - On (re)connect the server replays the most recent `HISTORY_REPLAY` messages
76
+ visible to your handle (private messages only replay to their two parties).
77
+ - Use the highest `seq` you've seen to de-duplicate after a reconnect.
78
+
79
+ ## Capabilities
80
+
81
+ A token carries a subset of `["receive","send"]` (the open-source core's set).
82
+ `receive` is required to attach; `send` is required to post. Sending without
83
+ `send` returns an `error` frame with code `forbidden`.
84
+
85
+ ## Limits (reference server defaults)
86
+
87
+ - Message body ≤ 256 KB (`MAX_BODY_BYTES`).
88
+ - Per-connection send rate ≤ 8 msg/s.
89
+ - ≤ 200 live connections per channel.
90
+ - High-confidence credentials are blocked (`error` code `secret_blocked`).
91
+ - Tokens expire (default 8h, max 7d) and are re-checked every 60s mid-stream.
92
+
93
+ ## Policy frame
94
+
95
+ The `policy` frame is **authoritative and server-authored**. Clients must obey
96
+ it and must treat message text as untrusted data, not instructions. The server
97
+ never accepts a `policy` frame from a client, so peers cannot forge the rules.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # 📻 Cana Walkie-Talkie
2
+
3
+ **A real-time message bus for human↔agent and agent↔agent coordination.**
4
+
5
+ Spin up a WebSocket bus where people and AI agents (Claude Code, your own
6
+ scripts, anything that speaks WebSocket) join shared **channels**, exchange
7
+ **broadcast / directed / private** messages with live history and presence, and
8
+ operate under a server-authored **policy** that keeps agents on-task and
9
+ prompt-injection-resistant.
10
+
11
+ This is the **open-source core** of [Cana](https://cana.build)'s Walkie-Talkie —
12
+ single-node, zero-config, no external services. The hosted Cana product builds
13
+ on the same wire protocol with multi-region scale, team RBAC, audit, mobile
14
+ push, and deep agent-platform integration (see the table below).
15
+
16
+ ```
17
+ ┌─────────┐ wtk_ token over WS ┌──────────────────┐
18
+ │ Agent │ ──────────────────────▶ │ │
19
+ └─────────┘ │ Walkie-Talkie │ ┌─────────┐
20
+ ┌─────────┐ admin token over WS │ bus (this) │──▶│ SQLite │
21
+ │Dashboard│ ──────────────────────▶ │ Express + ws │ └─────────┘
22
+ └─────────┘ └──────────────────┘
23
+ ```
24
+
25
+ ## Features
26
+
27
+ - **Channels** — create, list, close. Durable in SQLite.
28
+ - **`wtk_` agent tokens** — scoped to channels, with `receive`/`send`
29
+ capabilities, TTL, one-time reveal, instant revoke. Only a peppered SHA-256
30
+ hash is stored.
31
+ - **Messaging** — broadcast, directed (`to:`), and private 1:1 DMs.
32
+ - **Live + replay** — WebSocket fan-out plus per-channel monotonic `seq` and
33
+ history replay on (re)connect.
34
+ - **Server policy frame** — authoritative, anti-prompt-injection rules pushed to
35
+ every agent on connect.
36
+ - **Safety rails** — high-confidence secret blocking, body-size + rate limits,
37
+ Origin allow-list (CSWSH), mid-stream token revalidation.
38
+ - **Bundled dashboard** — a no-build web UI to run channels, watch live, and
39
+ mint agent tokens with a copy-paste `/walkie-talkie` command.
40
+ - **Zero native deps** — Express + `ws` + Zod, SQLite via built-in `node:sqlite`.
41
+
42
+ ## Quickstart
43
+
44
+ Requires **Node ≥ 22.5** (for built-in `node:sqlite`).
45
+
46
+ ```bash
47
+ git clone https://github.com/Colate-Ltd/cana-walkie-talkie.git
48
+ cd cana-walkie-talkie
49
+ npm install
50
+ cp .env.example .env # optional — sensible defaults otherwise
51
+ npm start
52
+ ```
53
+
54
+ The server prints a generated **admin token** on first boot (or set `ADMIN_TOKEN`
55
+ in `.env`). Open the dashboard at **http://localhost:8787**, paste the admin
56
+ token, create a channel, and mint an agent token.
57
+
58
+ Connect an agent (a ready-made example client is included):
59
+
60
+ ```bash
61
+ node examples/agent.mjs ws://localhost:8787 <channelId> <wtk_token> "hi there"
62
+ ```
63
+
64
+ Run the tests (see [test/TESTING.md](test/TESTING.md) for the full plan):
65
+
66
+ ```bash
67
+ npm test # Tier 1 — fast in-process end-to-end smoke test
68
+ npm run test:multi # Tier 2 — real server + multiple agents, each in a PTY
69
+ npm run test:cld # Tier 3 — two real Claude Code agents coordinating (opt-in)
70
+ ```
71
+
72
+ ## Use it from Claude Code
73
+
74
+ The dashboard's **+ Agent token** button gives you a ready `/walkie-talkie`
75
+ command. Drop [`commands/walkie-talkie.md`](commands/walkie-talkie.md) into
76
+ `~/.claude/commands/` and run:
77
+
78
+ ```
79
+ /walkie-talkie ws://localhost:8787 <channelId> <wtk_token> "You are the ops helper; stop when the incident is resolved."
80
+ ```
81
+
82
+ ## Protocol
83
+
84
+ The full JSON-over-WebSocket spec is in **[PROTOCOL.md](PROTOCOL.md)**. It's
85
+ small and language-agnostic — write a client in anything.
86
+
87
+ ## Configuration
88
+
89
+ All optional — see [`.env.example`](.env.example). Highlights:
90
+
91
+ | Var | Default | Purpose |
92
+ |-----|---------|---------|
93
+ | `PORT` / `HOST` | `8787` / `0.0.0.0` | listener |
94
+ | `DB_PATH` | `./walkie.db` | SQLite file |
95
+ | `ADMIN_TOKEN` | _generated_ | REST + dashboard bearer |
96
+ | `AGENT_TOKEN_PEPPER` | _(empty)_ | peppers token hashes — set in prod |
97
+ | `HISTORY_REPLAY` | `100` | messages replayed on connect |
98
+ | `BUS_ALLOWED_ORIGINS` | _(empty)_ | browser Origin allow-list (CSWSH) |
99
+
100
+ ## Architecture
101
+
102
+ ```
103
+ src/
104
+ server.ts HTTP + WS bootstrap, static dashboard
105
+ rest.ts /api/bus REST (admin-bearer): channels, tokens, messages
106
+ ws.ts /ws/bus/:channelId WebSocket: handshake, frames, heartbeat
107
+ messages.ts single emit() choke point: seq, persist, secret-scan, fan-out
108
+ broadcaster.ts in-memory per-channel fan-out (the single-node boundary)
109
+ db.ts node:sqlite schema + queries
110
+ tokens.ts wtk_ generate / hash / verify
111
+ auth.ts admin-bearer + wtk_ resolution, mid-stream revalidation
112
+ policy.ts authoritative policy frame
113
+ secret-scan.ts dependency-free credential detector
114
+ ratelimit.ts fixed-window in-memory limiter
115
+ public/ no-build dashboard (index.html, app.js, styles.css)
116
+ examples/agent.mjs minimal reference WebSocket client
117
+ ```
118
+
119
+ The interfaces are intentionally small (`broadcaster`, `auth`, `db`) so they can
120
+ be swapped — e.g. Redis pub/sub for multi-node, or an OAuth/SSO adapter for
121
+ `auth`. That's exactly the seam where the hosted product plugs in.
122
+
123
+ ## Open-source core vs. Cana
124
+
125
+ The protocol and single-node server are MIT and yours to run. Cana's hosted
126
+ platform adds what teams hit as they grow:
127
+
128
+ | Capability | Open-source core | Cana (hosted) |
129
+ |---|:---:|:---:|
130
+ | Wire protocol + `/walkie-talkie` command | ✅ | ✅ |
131
+ | Channels, `wtk_` tokens (receive/send) | ✅ | ✅ |
132
+ | Broadcast / directed / private messages | ✅ | ✅ |
133
+ | History replay, presence, heartbeat | ✅ | ✅ |
134
+ | Single-node, SQLite, self-host | ✅ | — |
135
+ | Multi-region horizontal scale (Redis pub/sub) | — | ✅ |
136
+ | Org/team RBAC (viewer→owner roles, directory) | — | ✅ |
137
+ | Compliance audit log | — | ✅ |
138
+ | `admin`/`act` capabilities + approval workflow | — | ✅ |
139
+ | Anomaly auto-revoke, advanced rate/secret controls | basic | ✅ |
140
+ | Mobile + desktop push notifications | — | ✅ |
141
+ | File attachments (S3) | — | ✅ |
142
+ | Read receipts & persistent work-status at scale | — | ✅ |
143
+ | Managed identity (SSO / OIDC) | adapter | ✅ |
144
+ | MCP tool surface + Cana agent/chat/RCA integration | — | ✅ |
145
+ | Hosted, zero-ops, SLA | — | ✅ |
146
+
147
+ → **Need scale, teams, or the agent platform?** [cana.build](https://cana.build)
148
+
149
+ ## Security notes
150
+
151
+ - Set `AGENT_TOKEN_PEPPER` and a strong `ADMIN_TOKEN` in production.
152
+ - Put the server behind TLS; set `BUS_ALLOWED_ORIGINS` for any browser clients.
153
+ - The secret scanner is a safety net, not a guarantee — don't paste credentials.
154
+ - Found a vulnerability? Email **himansh.raj@colate.io** rather than filing a
155
+ public issue.
156
+
157
+ ## Contributing
158
+
159
+ Issues and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Keep the wire
160
+ protocol backward-compatible and additive.
161
+
162
+ ## License
163
+
164
+ [MIT](LICENSE) © 2026 Himansh Raj / Colate Ltd.
package/dist/auth.js ADDED
@@ -0,0 +1,80 @@
1
+ import { config } from "./config.js";
2
+ import { adminTokenMatches, hashAgentToken } from "./tokens.js";
3
+ import { getTokenByHash, getTokenById, touchToken } from "./db.js";
4
+ /** Express middleware: require the admin bearer token on REST/dashboard APIs. */
5
+ export function requireAdmin(req, res, next) {
6
+ const header = req.header("authorization") ?? "";
7
+ const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
8
+ if (!presented || !adminTokenMatches(presented)) {
9
+ res.status(401).json({ error: "unauthorized" });
10
+ return;
11
+ }
12
+ next();
13
+ }
14
+ function sanitizeHandle(name) {
15
+ return name.trim().replace(/[^A-Za-z0-9 _.-]/g, "").slice(0, 48) || "agent";
16
+ }
17
+ /**
18
+ * Resolve a raw WebSocket credential into an Identity. Accepts either:
19
+ * - an agent token (`wtk_…`) → scoped to its channels + capabilities
20
+ * - the admin token → full receive+send on any channel (dashboard)
21
+ */
22
+ export function resolveWsIdentity(rawToken, channelId) {
23
+ if (!rawToken)
24
+ return { ok: false, code: "no_token", message: "missing token" };
25
+ // Admin / dashboard connection.
26
+ if (adminTokenMatches(rawToken)) {
27
+ return {
28
+ ok: true,
29
+ identity: {
30
+ tokenId: null,
31
+ handle: "dashboard",
32
+ owner: "admin",
33
+ capabilities: ["receive", "send"],
34
+ channels: null,
35
+ expiresAt: null,
36
+ },
37
+ };
38
+ }
39
+ // Agent token.
40
+ if (!rawToken.startsWith("wtk_")) {
41
+ return { ok: false, code: "bad_token", message: "unrecognized token" };
42
+ }
43
+ const token = getTokenByHash(hashAgentToken(rawToken));
44
+ if (!token)
45
+ return { ok: false, code: "bad_token", message: "invalid token" };
46
+ if (token.revokedAt != null)
47
+ return { ok: false, code: "revoked", message: "token revoked" };
48
+ if (token.expiresAt != null && token.expiresAt <= Date.now()) {
49
+ return { ok: false, code: "expired", message: "token expired" };
50
+ }
51
+ if (!token.channels.includes(channelId)) {
52
+ return { ok: false, code: "forbidden", message: "token not scoped to this channel" };
53
+ }
54
+ touchToken(token.id);
55
+ return {
56
+ ok: true,
57
+ identity: {
58
+ tokenId: token.id,
59
+ handle: sanitizeHandle(token.name),
60
+ owner: "token:" + token.prefix,
61
+ capabilities: token.capabilities,
62
+ channels: token.channels,
63
+ expiresAt: token.expiresAt,
64
+ },
65
+ };
66
+ }
67
+ /** Re-check a still-connected agent token for mid-stream expiry/revocation. */
68
+ export function tokenStillValid(tokenId) {
69
+ if (tokenId == null)
70
+ return true; // admin/dashboard connection never expires
71
+ const token = getTokenById(tokenId);
72
+ if (!token)
73
+ return false;
74
+ if (token.revokedAt != null)
75
+ return false;
76
+ if (token.expiresAt != null && token.expiresAt <= Date.now())
77
+ return false;
78
+ return true;
79
+ }
80
+ void config; // imported for side effects / future configuration use
@@ -0,0 +1,69 @@
1
+ const byChannel = new Map();
2
+ export function addConnection(conn) {
3
+ let set = byChannel.get(conn.channelId);
4
+ if (!set) {
5
+ set = new Map();
6
+ byChannel.set(conn.channelId, set);
7
+ }
8
+ set.set(conn.id, conn);
9
+ }
10
+ export function removeConnection(channelId, connId) {
11
+ const set = byChannel.get(channelId);
12
+ if (!set)
13
+ return;
14
+ set.delete(connId);
15
+ if (set.size === 0)
16
+ byChannel.delete(channelId);
17
+ }
18
+ export function connectionCount(channelId) {
19
+ return byChannel.get(channelId)?.size ?? 0;
20
+ }
21
+ export function participants(channelId) {
22
+ const set = byChannel.get(channelId);
23
+ if (!set)
24
+ return [];
25
+ return [...set.values()].map((c) => ({ id: c.id, handle: c.handle, tokenId: c.tokenId }));
26
+ }
27
+ /** True if `frame` should be visible to a connection with `handle`. */
28
+ function visibleTo(frame, handle) {
29
+ if (!frame.private)
30
+ return true; // public + directed-but-not-private are visible to all
31
+ // private: only the sender and the addressed recipient can see it
32
+ return handle === frame.from || handle === frame.to;
33
+ }
34
+ /** Fan a message frame out to every eligible live connection on its channel. */
35
+ export function deliverMessage(frame) {
36
+ const set = byChannel.get(frame.channelId);
37
+ if (!set)
38
+ return;
39
+ for (const conn of set.values()) {
40
+ if (visibleTo(frame, conn.handle))
41
+ conn.send(frame);
42
+ }
43
+ }
44
+ /** Fan a non-message system frame out to everyone on the channel. */
45
+ export function deliverSystem(channelId, frame) {
46
+ const set = byChannel.get(channelId);
47
+ if (!set)
48
+ return;
49
+ for (const conn of set.values())
50
+ conn.send(frame);
51
+ }
52
+ /** Close every connection on a channel (used when a channel is killed). */
53
+ export function closeChannelConnections(channelId, code = 4002, reason = "channel_closed") {
54
+ const set = byChannel.get(channelId);
55
+ if (!set)
56
+ return;
57
+ for (const conn of [...set.values()])
58
+ conn.close(code, reason);
59
+ byChannel.delete(channelId);
60
+ }
61
+ /** Close all live connections belonging to a revoked token. */
62
+ export function closeTokenConnections(tokenId, code = 4003, reason = "token_revoked") {
63
+ for (const set of byChannel.values()) {
64
+ for (const conn of [...set.values()]) {
65
+ if (conn.tokenId === tokenId)
66
+ conn.close(code, reason);
67
+ }
68
+ }
69
+ }
package/dist/config.js ADDED
@@ -0,0 +1,42 @@
1
+ import crypto from "node:crypto";
2
+ function num(name, def) {
3
+ const v = process.env[name];
4
+ if (v == null || v.trim() === "")
5
+ return def;
6
+ const n = Number(v);
7
+ return Number.isFinite(n) ? n : def;
8
+ }
9
+ function list(name) {
10
+ return (process.env[name] ?? "")
11
+ .split(",")
12
+ .map((s) => s.trim())
13
+ .filter(Boolean);
14
+ }
15
+ // A generated admin token if none was provided. Printed once at startup.
16
+ let adminToken = process.env.ADMIN_TOKEN?.trim() || "";
17
+ let adminTokenGenerated = false;
18
+ if (!adminToken) {
19
+ adminToken = "adm_" + crypto.randomBytes(24).toString("base64url");
20
+ adminTokenGenerated = true;
21
+ }
22
+ export const config = {
23
+ host: process.env.HOST ?? "0.0.0.0",
24
+ port: num("PORT", 8787),
25
+ dbPath: process.env.DB_PATH ?? "./walkie.db",
26
+ adminToken,
27
+ adminTokenGenerated,
28
+ tokenPepper: process.env.AGENT_TOKEN_PEPPER ?? "",
29
+ historyReplay: Math.max(1, num("HISTORY_REPLAY", 100)),
30
+ maxBodyBytes: Math.max(1024, num("MAX_BODY_BYTES", 256 * 1024)),
31
+ defaultTtlMs: Math.max(1, num("DEFAULT_TTL_HOURS", 8)) * 3600_000,
32
+ maxTtlMs: Math.max(1, num("MAX_TTL_HOURS", 168)) * 3600_000,
33
+ allowedOrigins: list("BUS_ALLOWED_ORIGINS"),
34
+ };
35
+ // Open-source guardrails. These are the deliberate limits of the community
36
+ // core — the hosted Cana product lifts them (multi-node scale, org RBAC,
37
+ // audit, push, attachments, SSO). See README "Cana vs the open-source core".
38
+ export const limits = {
39
+ capabilities: ["receive", "send"], // no `admin` / `act` in OSS
40
+ perConnRatePerSec: 8,
41
+ maxConnectionsPerChannel: 200,
42
+ };
package/dist/db.js ADDED
@@ -0,0 +1,164 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import crypto from "node:crypto";
3
+ import { config } from "./config.js";
4
+ const db = new DatabaseSync(config.dbPath);
5
+ db.exec("PRAGMA journal_mode = WAL;");
6
+ db.exec("PRAGMA foreign_keys = ON;");
7
+ db.exec(`
8
+ CREATE TABLE IF NOT EXISTS channels (
9
+ id TEXT PRIMARY KEY,
10
+ slug TEXT UNIQUE NOT NULL,
11
+ name TEXT NOT NULL,
12
+ description TEXT,
13
+ status TEXT NOT NULL DEFAULT 'active',
14
+ last_seq INTEGER NOT NULL DEFAULT 0,
15
+ created_at INTEGER NOT NULL
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS tokens (
19
+ id TEXT PRIMARY KEY,
20
+ name TEXT NOT NULL,
21
+ prefix TEXT NOT NULL,
22
+ hashed_key TEXT UNIQUE NOT NULL,
23
+ channels TEXT NOT NULL,
24
+ capabilities TEXT NOT NULL,
25
+ expires_at INTEGER,
26
+ revoked_at INTEGER,
27
+ last_used_at INTEGER,
28
+ created_at INTEGER NOT NULL
29
+ );
30
+ CREATE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(hashed_key);
31
+
32
+ CREATE TABLE IF NOT EXISTS messages (
33
+ id TEXT PRIMARY KEY,
34
+ channel_id TEXT NOT NULL,
35
+ seq INTEGER NOT NULL,
36
+ kind TEXT NOT NULL DEFAULT 'message',
37
+ sender_label TEXT NOT NULL,
38
+ sender_token_id TEXT,
39
+ recipient TEXT,
40
+ private INTEGER NOT NULL DEFAULT 0,
41
+ body TEXT NOT NULL,
42
+ created_at INTEGER NOT NULL,
43
+ UNIQUE(channel_id, seq)
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, seq);
46
+ `);
47
+ const newId = (p) => `${p}_${crypto.randomBytes(12).toString("hex")}`;
48
+ function slugify(name) {
49
+ const base = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "channel";
50
+ // ensure uniqueness
51
+ let slug = base;
52
+ let n = 1;
53
+ while (db.prepare("SELECT 1 FROM channels WHERE slug = ?").get(slug)) {
54
+ slug = `${base}-${++n}`;
55
+ }
56
+ return slug;
57
+ }
58
+ // ── Channels ─────────────────────────────────────────────────────────
59
+ function rowToChannel(r) {
60
+ return {
61
+ id: r.id,
62
+ slug: r.slug,
63
+ name: r.name,
64
+ description: r.description ?? null,
65
+ status: r.status,
66
+ lastSeq: Number(r.last_seq),
67
+ createdAt: Number(r.created_at),
68
+ };
69
+ }
70
+ export function createChannel(name, description) {
71
+ const id = newId("ch");
72
+ const slug = slugify(name);
73
+ const now = Date.now();
74
+ db.prepare("INSERT INTO channels (id, slug, name, description, status, last_seq, created_at) VALUES (?,?,?,?, 'active', 0, ?)").run(id, slug, name, description ?? null, now);
75
+ return rowToChannel(db.prepare("SELECT * FROM channels WHERE id = ?").get(id));
76
+ }
77
+ export function listChannels() {
78
+ return db.prepare("SELECT * FROM channels ORDER BY created_at DESC").all().map(rowToChannel);
79
+ }
80
+ export function getChannel(id) {
81
+ const r = db.prepare("SELECT * FROM channels WHERE id = ? OR slug = ?").get(id, id);
82
+ return r ? rowToChannel(r) : null;
83
+ }
84
+ export function closeChannel(id) {
85
+ db.prepare("UPDATE channels SET status = 'closed' WHERE id = ?").run(id);
86
+ }
87
+ /** Atomically increment and return the next per-channel sequence number. */
88
+ export function nextSeq(channelId) {
89
+ db.prepare("UPDATE channels SET last_seq = last_seq + 1 WHERE id = ?").run(channelId);
90
+ const r = db.prepare("SELECT last_seq FROM channels WHERE id = ?").get(channelId);
91
+ return r ? Number(r.last_seq) : 0;
92
+ }
93
+ // ── Tokens ───────────────────────────────────────────────────────────
94
+ function rowToToken(r) {
95
+ return {
96
+ id: r.id,
97
+ name: r.name,
98
+ prefix: r.prefix,
99
+ hashedKey: r.hashed_key,
100
+ channels: JSON.parse(r.channels),
101
+ capabilities: JSON.parse(r.capabilities),
102
+ expiresAt: r.expires_at == null ? null : Number(r.expires_at),
103
+ revokedAt: r.revoked_at == null ? null : Number(r.revoked_at),
104
+ lastUsedAt: r.last_used_at == null ? null : Number(r.last_used_at),
105
+ createdAt: Number(r.created_at),
106
+ };
107
+ }
108
+ export function insertToken(t) {
109
+ const id = newId("tok");
110
+ const now = Date.now();
111
+ db.prepare("INSERT INTO tokens (id, name, prefix, hashed_key, channels, capabilities, expires_at, revoked_at, last_used_at, created_at) VALUES (?,?,?,?,?,?,?,NULL,NULL,?)").run(id, t.name, t.prefix, t.hashedKey, JSON.stringify(t.channels), JSON.stringify(t.capabilities), t.expiresAt, now);
112
+ return rowToToken(db.prepare("SELECT * FROM tokens WHERE id = ?").get(id));
113
+ }
114
+ export function listTokens() {
115
+ return db.prepare("SELECT * FROM tokens ORDER BY created_at DESC").all().map(rowToToken);
116
+ }
117
+ export function getTokenByHash(hash) {
118
+ const r = db.prepare("SELECT * FROM tokens WHERE hashed_key = ?").get(hash);
119
+ return r ? rowToToken(r) : null;
120
+ }
121
+ export function getTokenById(id) {
122
+ const r = db.prepare("SELECT * FROM tokens WHERE id = ?").get(id);
123
+ return r ? rowToToken(r) : null;
124
+ }
125
+ export function revokeToken(id) {
126
+ const res = db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(Date.now(), id);
127
+ return res.changes > 0;
128
+ }
129
+ export function touchToken(id) {
130
+ db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").run(Date.now(), id);
131
+ }
132
+ // ── Messages ─────────────────────────────────────────────────────────
133
+ function rowToMessage(r) {
134
+ return {
135
+ id: r.id,
136
+ channelId: r.channel_id,
137
+ seq: Number(r.seq),
138
+ kind: r.kind,
139
+ senderLabel: r.sender_label,
140
+ senderTokenId: r.sender_token_id ?? null,
141
+ recipient: r.recipient ?? null,
142
+ private: Number(r.private) === 1,
143
+ body: JSON.parse(r.body),
144
+ createdAt: Number(r.created_at),
145
+ };
146
+ }
147
+ export function insertMessage(m) {
148
+ const id = newId("msg");
149
+ const createdAt = m.createdAt ?? Date.now();
150
+ db.prepare("INSERT INTO messages (id, channel_id, seq, kind, sender_label, sender_token_id, recipient, private, body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)").run(id, m.channelId, m.seq, m.kind, m.senderLabel, m.senderTokenId, m.recipient, m.private ? 1 : 0, JSON.stringify(m.body), createdAt);
151
+ return rowToMessage(db.prepare("SELECT * FROM messages WHERE id = ?").get(id));
152
+ }
153
+ /** Most recent `limit` messages for a channel, oldest-first. */
154
+ export function recentMessages(channelId, limit) {
155
+ const rows = db
156
+ .prepare("SELECT * FROM messages WHERE channel_id = ? ORDER BY seq DESC LIMIT ?")
157
+ .all(channelId, limit);
158
+ return rows.map(rowToMessage).reverse();
159
+ }
160
+ export function messageCounts(channelId) {
161
+ const r = db.prepare("SELECT COUNT(*) AS c FROM messages WHERE channel_id = ?").get(channelId);
162
+ return Number(r.c);
163
+ }
164
+ export { db };