aidimag 1.0.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.
Files changed (91) hide show
  1. package/LICENSE +102 -0
  2. package/README.md +113 -0
  3. package/dist/capture/bootstrap.d.ts +25 -0
  4. package/dist/capture/bootstrap.js +188 -0
  5. package/dist/capture/commit-miner.d.ts +59 -0
  6. package/dist/capture/commit-miner.js +381 -0
  7. package/dist/capture/harvest.d.ts +50 -0
  8. package/dist/capture/harvest.js +207 -0
  9. package/dist/capture/pr-miner.d.ts +57 -0
  10. package/dist/capture/pr-miner.js +185 -0
  11. package/dist/capture/session-briefing.d.ts +36 -0
  12. package/dist/capture/session-briefing.js +150 -0
  13. package/dist/capture/session-extraction.d.ts +23 -0
  14. package/dist/capture/session-extraction.js +54 -0
  15. package/dist/capture/triage.d.ts +30 -0
  16. package/dist/capture/triage.js +108 -0
  17. package/dist/cli/commands/capture.d.ts +5 -0
  18. package/dist/cli/commands/capture.js +357 -0
  19. package/dist/cli/commands/hosts.d.ts +5 -0
  20. package/dist/cli/commands/hosts.js +98 -0
  21. package/dist/cli/commands/knowledge.d.ts +5 -0
  22. package/dist/cli/commands/knowledge.js +121 -0
  23. package/dist/cli/commands/memory.d.ts +6 -0
  24. package/dist/cli/commands/memory.js +392 -0
  25. package/dist/cli/commands/sync.d.ts +5 -0
  26. package/dist/cli/commands/sync.js +307 -0
  27. package/dist/cli/commands/tickets.d.ts +6 -0
  28. package/dist/cli/commands/tickets.js +328 -0
  29. package/dist/cli/commands/verify.d.ts +5 -0
  30. package/dist/cli/commands/verify.js +136 -0
  31. package/dist/cli/index.d.ts +19 -0
  32. package/dist/cli/index.js +46 -0
  33. package/dist/cli/shared.d.ts +37 -0
  34. package/dist/cli/shared.js +175 -0
  35. package/dist/config.d.ts +55 -0
  36. package/dist/config.js +51 -0
  37. package/dist/context/generate.d.ts +24 -0
  38. package/dist/context/generate.js +115 -0
  39. package/dist/critique/critique.d.ts +40 -0
  40. package/dist/critique/critique.js +110 -0
  41. package/dist/db/schema.d.ts +28 -0
  42. package/dist/db/schema.js +269 -0
  43. package/dist/db/store.d.ts +182 -0
  44. package/dist/db/store.js +906 -0
  45. package/dist/debug.d.ts +14 -0
  46. package/dist/debug.js +25 -0
  47. package/dist/embeddings/provider.d.ts +16 -0
  48. package/dist/embeddings/provider.js +100 -0
  49. package/dist/embeddings/search.d.ts +22 -0
  50. package/dist/embeddings/search.js +95 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.js +12 -0
  53. package/dist/knowledge/chunk.d.ts +9 -0
  54. package/dist/knowledge/chunk.js +78 -0
  55. package/dist/knowledge/extract.d.ts +34 -0
  56. package/dist/knowledge/extract.js +113 -0
  57. package/dist/knowledge/ingest.d.ts +79 -0
  58. package/dist/knowledge/ingest.js +305 -0
  59. package/dist/knowledge/llm.d.ts +21 -0
  60. package/dist/knowledge/llm.js +110 -0
  61. package/dist/mcp/server.d.ts +11 -0
  62. package/dist/mcp/server.js +532 -0
  63. package/dist/security/evidence.d.ts +11 -0
  64. package/dist/security/evidence.js +15 -0
  65. package/dist/security/seal.d.ts +3 -0
  66. package/dist/security/seal.js +28 -0
  67. package/dist/security/sync-push.d.ts +2 -0
  68. package/dist/security/sync-push.js +37 -0
  69. package/dist/security/url.d.ts +6 -0
  70. package/dist/security/url.js +67 -0
  71. package/dist/sync/client.d.ts +84 -0
  72. package/dist/sync/client.js +391 -0
  73. package/dist/sync/server.d.ts +75 -0
  74. package/dist/sync/server.js +659 -0
  75. package/dist/tickets/provider.d.ts +80 -0
  76. package/dist/tickets/provider.js +375 -0
  77. package/dist/types.d.ts +133 -0
  78. package/dist/types.js +5 -0
  79. package/dist/ui/page.d.ts +5 -0
  80. package/dist/ui/page.js +841 -0
  81. package/dist/ui/server.d.ts +10 -0
  82. package/dist/ui/server.js +437 -0
  83. package/dist/verify/check.d.ts +38 -0
  84. package/dist/verify/check.js +121 -0
  85. package/dist/verify/engine.d.ts +39 -0
  86. package/dist/verify/engine.js +178 -0
  87. package/dist/verify/hooks.d.ts +24 -0
  88. package/dist/verify/hooks.js +125 -0
  89. package/dist/verify/runners.d.ts +26 -0
  90. package/dist/verify/runners.js +193 -0
  91. package/package.json +78 -0
@@ -0,0 +1,659 @@
1
+ /**
2
+ * aidimag sync server — `dim serve` (Phase 6, self-hostable team mode).
3
+ *
4
+ * Zero extra dependencies: node:http + better-sqlite3. The hosted SaaS later
5
+ * wraps this same protocol with OAuth/billing; teams can always self-host.
6
+ *
7
+ * Protocol (all JSON, Bearer token auth):
8
+ * POST /v1/push?brain=<id> { items: SyncItem[] } → { accepted, seq }
9
+ * GET /v1/pull?brain=<id>&since=<seq> → { items, seq }
10
+ * POST /v1/events?brain=<id> { events: EventItem[] } → { accepted }
11
+ * GET /v1/consensus?brain=<id>[&memory=<id>] → { consensus }
12
+ * GET /v1/snapshot?brain=<id>[&id=][&list=0][&limit=] → { seq, counts, items? }
13
+ * GET /v1/health → { ok, brains }
14
+ *
15
+ * Device auth (SaaS groundwork — RFC 8628-style device flow, no external IdP):
16
+ * POST /v1/auth/device → { device_code, user_code, verification_uri, interval, expires_in }
17
+ * GET /v1/auth/approve → browser approval page
18
+ * POST /v1/auth/approve (form) → approve a user code with an existing credential
19
+ * POST /v1/auth/token { device_code } → pending | { token, brain }
20
+ * The minted account token (aidimag_at_…) inherits the scope of the approving
21
+ * credential: admin token → all brains; member key → that key's brain. The
22
+ * hosted SaaS swaps the approval page for GitHub OAuth — same endpoints.
23
+ *
24
+ * SyncItem = { tbl: "memories"|"proposals", id, updatedAt, deleted, payload }
25
+ * The server is a dumb ordered log + latest-state index per brain. All merge
26
+ * logic (LWW, verification arbitration) lives in clients. Events are an
27
+ * append-only ingest used for cross-machine verification consensus.
28
+ */
29
+ import { createServer } from "node:http";
30
+ import Database from "better-sqlite3";
31
+ import { mkdirSync } from "node:fs";
32
+ import path from "node:path";
33
+ import { timingSafeEqual, randomBytes, createHash } from "node:crypto";
34
+ import { buildDirectProvider } from "../tickets/provider.js";
35
+ import { validateSyncPushItems } from "../security/sync-push.js";
36
+ import { sealDeviceToken, unsealDeviceToken } from "../security/seal.js";
37
+ import { isAllowedTicketBaseUrl } from "../security/url.js";
38
+ const DEVICE_CODE_TTL_MS = 15 * 60 * 1000;
39
+ const DEVICE_POLL_INTERVAL_S = 5;
40
+ const SERVER_SCHEMA = `
41
+ PRAGMA journal_mode = WAL;
42
+ CREATE TABLE IF NOT EXISTS items (
43
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ brain TEXT NOT NULL,
45
+ tbl TEXT NOT NULL,
46
+ id TEXT NOT NULL,
47
+ updated_at TEXT NOT NULL,
48
+ deleted INTEGER NOT NULL DEFAULT 0,
49
+ payload TEXT
50
+ );
51
+ -- latest-state index: one row per (brain, tbl, id)
52
+ CREATE TABLE IF NOT EXISTS latest (
53
+ brain TEXT NOT NULL,
54
+ tbl TEXT NOT NULL,
55
+ id TEXT NOT NULL,
56
+ seq INTEGER NOT NULL,
57
+ updated_at TEXT NOT NULL,
58
+ PRIMARY KEY (brain, tbl, id)
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_items_brain_seq ON items(brain, seq);
61
+ -- multi-tenant: brain-scoped API keys minted by the admin token
62
+ CREATE TABLE IF NOT EXISTS api_keys (
63
+ key TEXT PRIMARY KEY, -- aidimag_sk_...
64
+ brain TEXT NOT NULL,
65
+ label TEXT,
66
+ created_at TEXT NOT NULL,
67
+ revoked_at TEXT
68
+ );
69
+ -- SaaS groundwork: device-flow login (dim login)
70
+ CREATE TABLE IF NOT EXISTS device_codes (
71
+ device_code TEXT PRIMARY KEY, -- secret, polled by the CLI
72
+ user_code TEXT NOT NULL UNIQUE, -- short code typed/shown in the browser
73
+ status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','APPROVED','DENIED')),
74
+ token TEXT, -- account token once approved
75
+ created_at TEXT NOT NULL,
76
+ expires_at TEXT NOT NULL
77
+ );
78
+ -- account tokens minted via device flow (aidimag_at_...)
79
+ CREATE TABLE IF NOT EXISTS account_tokens (
80
+ token TEXT PRIMARY KEY,
81
+ brain TEXT, -- NULL = all brains (approved by admin)
82
+ label TEXT,
83
+ created_at TEXT NOT NULL,
84
+ revoked_at TEXT
85
+ );
86
+ -- append-only event ingest (CLOUD_DESIGN sync model; consensus aggregation)
87
+ CREATE TABLE IF NOT EXISTS events (
88
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ brain TEXT NOT NULL,
90
+ id TEXT NOT NULL UNIQUE, -- client uuid → idempotent ingest
91
+ type TEXT NOT NULL,
92
+ memory_id TEXT,
93
+ payload TEXT NOT NULL DEFAULT '{}',
94
+ machine TEXT NOT NULL,
95
+ schema_version INTEGER NOT NULL,
96
+ created_at TEXT NOT NULL
97
+ );
98
+ CREATE INDEX IF NOT EXISTS idx_events_brain ON events(brain, seq);
99
+ CREATE INDEX IF NOT EXISTS idx_events_memory ON events(brain, memory_id, type);
100
+ -- T3 tickets: team-shared ticketing credentials — members never hold them
101
+ CREATE TABLE IF NOT EXISTS ticket_configs (
102
+ brain TEXT PRIMARY KEY,
103
+ provider TEXT NOT NULL, -- jira | github | linear | http
104
+ base_url TEXT NOT NULL DEFAULT '',
105
+ credential TEXT, -- team token, server-side only
106
+ updated_at TEXT NOT NULL
107
+ );
108
+ CREATE TABLE IF NOT EXISTS ticket_cache (
109
+ brain TEXT NOT NULL,
110
+ id TEXT NOT NULL,
111
+ payload TEXT NOT NULL, -- normalized Ticket JSON ('' = miss)
112
+ fetched_at TEXT NOT NULL,
113
+ PRIMARY KEY (brain, id)
114
+ );
115
+ -- server-side flags (e.g. one-time credential-hash migration)
116
+ CREATE TABLE IF NOT EXISTS server_meta (
117
+ key TEXT PRIMARY KEY,
118
+ value TEXT NOT NULL
119
+ );
120
+ `;
121
+ const TICKET_CACHE_TTL_MS = 10 * 60 * 1000;
122
+ /**
123
+ * Credentials at rest: api_keys.key, account_tokens.token, and pending
124
+ * device_codes.token store SHA-256 hashes, never plaintext — a leaked server
125
+ * DB no longer leaks live credentials. Lookups hash the presented value.
126
+ * (ticket_configs.credential must stay recoverable to call provider APIs.)
127
+ */
128
+ function hashCred(s) {
129
+ return createHash("sha256").update(s).digest("hex");
130
+ }
131
+ /** Fixed-window per-IP rate limiter for the unauthenticated auth endpoints. */
132
+ function makeRateLimiter(maxPerWindow, windowMs) {
133
+ const hits = new Map();
134
+ return (ip) => {
135
+ const now = Date.now();
136
+ const cur = hits.get(ip);
137
+ if (!cur || now - cur.windowStart > windowMs) {
138
+ hits.set(ip, { count: 1, windowStart: now });
139
+ if (hits.size > 10_000)
140
+ hits.clear(); // memory bound
141
+ return true;
142
+ }
143
+ cur.count++;
144
+ return cur.count <= maxPerWindow;
145
+ };
146
+ }
147
+ function safeEqual(a, b) {
148
+ const ba = Buffer.from(a), bb = Buffer.from(b);
149
+ return ba.length === bb.length && timingSafeEqual(ba, bb);
150
+ }
151
+ /** Escape untrusted text before interpolating into the HTML approval page (XSS guard). */
152
+ export function escapeHtml(s) {
153
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
154
+ }
155
+ /** Cap inbound request bodies to bound memory use (DoS guard on an internet-exposed server). */
156
+ const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB
157
+ function readBody(req) {
158
+ return new Promise((resolve, reject) => {
159
+ let body = "";
160
+ let size = 0;
161
+ req.on("data", (d) => {
162
+ size += d.length;
163
+ if (size > MAX_BODY_BYTES) {
164
+ reject(new Error("request body too large"));
165
+ req.destroy();
166
+ return;
167
+ }
168
+ body += d;
169
+ });
170
+ req.on("end", () => resolve(body));
171
+ req.on("error", reject);
172
+ });
173
+ }
174
+ function userCode() {
175
+ // unambiguous alphabet, XXXX-XXXX
176
+ const alpha = "BCDFGHJKLMNPQRSTVWXZ23456789";
177
+ const pick = () => alpha[randomBytes(1)[0] % alpha.length];
178
+ return `${pick()}${pick()}${pick()}${pick()}-${pick()}${pick()}${pick()}${pick()}`;
179
+ }
180
+ const AUTH_PAGE_STYLES = `<style>
181
+ @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
182
+ :root{--bg:hsl(222,47%,6%);--card:hsl(222,47%,8%);--border:hsl(217,33%,16%);--text:hsl(210,40%,98%);--dim:hsl(215,20%,65%);--primary:linear-gradient(120deg,#2563eb,#0ea5e9);--radius:12px}
183
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px 16px;font:16px/1.5 "Inter",ui-sans-serif,system-ui,sans-serif;color:var(--text);background:var(--bg);background-image:radial-gradient(at 0% 0%,rgba(37,99,235,.18) 0,transparent 50%),radial-gradient(at 100% 0%,rgba(14,165,233,.12) 0,transparent 50%)}
184
+ .card{width:100%;max-width:28rem;padding:28px;border:1px solid color-mix(in srgb,var(--border) 60%,transparent);border-radius:calc(var(--radius)+4px);background:color-mix(in srgb,var(--card) 90%,transparent);box-shadow:0 0 0 1px rgba(96,165,250,.12),0 8px 32px rgba(0,0,0,.35)}
185
+ h1{margin:0 0 4px;font-size:1.5rem;font-weight:700;letter-spacing:-.02em}h2{margin:0 0 20px;font-size:.95rem;font-weight:500;color:var(--dim)}
186
+ label{display:block;margin:14px 0 6px;font-size:.8rem;font-weight:600;color:var(--dim);text-transform:uppercase;letter-spacing:.06em}
187
+ input{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:inherit}
188
+ input:focus{outline:2px solid hsl(199,89%,48%);outline-offset:1px;border-color:transparent}
189
+ button{margin-top:18px;width:100%;padding:11px 16px;border:none;border-radius:8px;background:var(--primary);color:#fff;font:inherit;font-weight:600;cursor:pointer;box-shadow:0 8px 24px rgba(37,99,235,.3)}
190
+ .msg{padding:12px 14px;border-radius:8px;background:hsl(213,94%,68%,.12);border:1px solid hsl(213,94%,68%,.25);margin-bottom:16px;font-size:.9rem}
191
+ .success-icon{font-size:2.5rem;margin-bottom:12px}
192
+ code{background:hsl(217,33%,14%);padding:2px 6px;border-radius:4px;font-size:.875em}
193
+ </style>`;
194
+ const APPROVE_PAGE = (msg = "", code = "") => `<!doctype html>
195
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>aidimag — approve device</title>
196
+ ${AUTH_PAGE_STYLES}</head>
197
+ <body><div class="card"><h1>🧠 aidimag</h1><h2>Approve a device login</h2>
198
+ ${msg ? `<div class="msg">${escapeHtml(msg)}</div>` : ""}
199
+ <form method="POST" action="/v1/auth/approve">
200
+ <label for="user_code">Code shown in your terminal</label>
201
+ <input id="user_code" name="user_code" placeholder="XXXX-XXXX" value="${escapeHtml(code)}" required autocomplete="off">
202
+ <label for="credential">Your credential (admin token or aidimag_sk_… member key)</label>
203
+ <input id="credential" name="credential" type="password" required autocomplete="off">
204
+ <label for="label">Device label (optional)</label>
205
+ <input id="label" name="label" placeholder="alice-laptop">
206
+ <button type="submit">Approve device</button>
207
+ </form></div></body></html>`;
208
+ export function startSyncServer(opts) {
209
+ mkdirSync(path.dirname(path.resolve(opts.dbPath)), { recursive: true });
210
+ const db = new Database(opts.dbPath);
211
+ db.exec(SERVER_SCHEMA);
212
+ // One-time migration: hash any plaintext credentials from pre-hardening DBs.
213
+ const migrated = db.prepare("SELECT value FROM server_meta WHERE key = 'creds_hashed'").get();
214
+ if (!migrated) {
215
+ const tx = db.transaction(() => {
216
+ const isHex64 = (s) => /^[0-9a-f]{64}$/.test(s);
217
+ for (const r of db.prepare("SELECT key FROM api_keys").all()) {
218
+ if (!isHex64(r.key))
219
+ db.prepare("UPDATE api_keys SET key = ? WHERE key = ?").run(hashCred(r.key), r.key);
220
+ }
221
+ for (const r of db.prepare("SELECT token FROM account_tokens").all()) {
222
+ if (!isHex64(r.token))
223
+ db.prepare("UPDATE account_tokens SET token = ? WHERE token = ?").run(hashCred(r.token), r.token);
224
+ }
225
+ db.prepare("INSERT INTO server_meta (key, value) VALUES ('creds_hashed', ?)").run(new Date().toISOString());
226
+ });
227
+ tx();
228
+ }
229
+ const authLimiter = makeRateLimiter(20, 60_000); // 20 req/min/IP on /v1/auth/*
230
+ const syncLimiter = makeRateLimiter(120, 60_000); // 120 req/min/IP on authenticated sync
231
+ const insertItem = db.prepare("INSERT INTO items (brain, tbl, id, updated_at, deleted, payload) VALUES (?, ?, ?, ?, ?, ?)");
232
+ const getLatest = db.prepare("SELECT updated_at FROM latest WHERE brain = ? AND tbl = ? AND id = ?");
233
+ const setLatest = db.prepare(`INSERT INTO latest (brain, tbl, id, seq, updated_at) VALUES (?, ?, ?, ?, ?)
234
+ ON CONFLICT(brain, tbl, id) DO UPDATE SET seq = excluded.seq, updated_at = excluded.updated_at`);
235
+ const insertEvent = db.prepare(`INSERT OR IGNORE INTO events (brain, id, type, memory_id, payload, machine, schema_version, created_at)
236
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
237
+ const port = opts.port ?? 8787;
238
+ const host = opts.host ?? "127.0.0.1";
239
+ /** Resolve the brain scope of a presented credential: '*' admin, brain name, or null (invalid). */
240
+ function credentialScope(presented) {
241
+ if (safeEqual(presented, opts.token))
242
+ return "*";
243
+ const hashed = hashCred(presented);
244
+ const key = db
245
+ .prepare("SELECT brain FROM api_keys WHERE key = ? AND revoked_at IS NULL")
246
+ .get(hashed);
247
+ if (key)
248
+ return key.brain;
249
+ const at = db
250
+ .prepare("SELECT brain FROM account_tokens WHERE token = ? AND revoked_at IS NULL")
251
+ .get(hashed);
252
+ if (at?.brain)
253
+ return at.brain;
254
+ return null;
255
+ }
256
+ const server = createServer(async (req, res) => {
257
+ const respond = (code, body) => {
258
+ res.writeHead(code, { "Content-Type": "application/json" });
259
+ res.end(JSON.stringify(body));
260
+ };
261
+ const respondHtml = (code, html) => {
262
+ res.writeHead(code, { "Content-Type": "text/html; charset=utf-8" });
263
+ res.end(html);
264
+ };
265
+ try {
266
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
267
+ if (req.method === "GET" && url.pathname === "/v1/health") {
268
+ const brains = db.prepare("SELECT COUNT(DISTINCT brain) AS n FROM items").get().n;
269
+ return respond(200, { ok: true, brains });
270
+ }
271
+ // ---- device auth (no Bearer auth on these endpoints) ------------------
272
+ // Unauthenticated → rate-limited per IP (user codes are short; without a
273
+ // limit they'd be brute-forceable within the 15-minute TTL).
274
+ if (url.pathname.startsWith("/v1/auth/")) {
275
+ const ip = req.socket.remoteAddress ?? "unknown";
276
+ if (!authLimiter(ip))
277
+ return respond(429, { error: "rate limited — try again in a minute" });
278
+ }
279
+ if (req.method === "POST" && url.pathname === "/v1/auth/device") {
280
+ const deviceCode = `aidimag_dc_${randomBytes(24).toString("base64url")}`;
281
+ const code = userCode();
282
+ const now = Date.now();
283
+ db.prepare("INSERT INTO device_codes (device_code, user_code, created_at, expires_at) VALUES (?, ?, ?, ?)").run(deviceCode, code, new Date(now).toISOString(), new Date(now + DEVICE_CODE_TTL_MS).toISOString());
284
+ return respond(200, {
285
+ device_code: deviceCode,
286
+ user_code: code,
287
+ verification_uri: `${url.origin}/v1/auth/approve`,
288
+ interval: DEVICE_POLL_INTERVAL_S,
289
+ expires_in: DEVICE_CODE_TTL_MS / 1000,
290
+ });
291
+ }
292
+ if (req.method === "GET" && url.pathname === "/v1/auth/approve") {
293
+ return respondHtml(200, APPROVE_PAGE("", url.searchParams.get("code") ?? ""));
294
+ }
295
+ if (req.method === "POST" && url.pathname === "/v1/auth/approve") {
296
+ const body = await readBody(req);
297
+ const form = new URLSearchParams(body);
298
+ const code = (form.get("user_code") ?? "").trim().toUpperCase();
299
+ const credential = (form.get("credential") ?? "").trim();
300
+ const label = (form.get("label") ?? "").trim() || null;
301
+ const row = db
302
+ .prepare("SELECT device_code, status, expires_at FROM device_codes WHERE user_code = ?")
303
+ .get(code);
304
+ if (!row || row.status !== "PENDING")
305
+ return respondHtml(400, APPROVE_PAGE("❌ Unknown or already-used code."));
306
+ if (row.expires_at < new Date().toISOString()) {
307
+ return respondHtml(400, APPROVE_PAGE("❌ Code expired — run `dim login` again."));
308
+ }
309
+ const scope = credentialScope(credential);
310
+ if (scope === null)
311
+ return respondHtml(403, APPROVE_PAGE("❌ Invalid credential.", code));
312
+ const token = `aidimag_at_${randomBytes(24).toString("base64url")}`;
313
+ const tx = db.transaction(() => {
314
+ // account_tokens stores the HASH; the plaintext lives only in
315
+ // device_codes for the single-use poll handoff, then is deleted.
316
+ db.prepare("INSERT INTO account_tokens (token, brain, label, created_at) VALUES (?, ?, ?, ?)").run(hashCred(token), scope === "*" ? null : scope, label, new Date().toISOString());
317
+ db.prepare("UPDATE device_codes SET status = 'APPROVED', token = ? WHERE device_code = ?").run(sealDeviceToken(token, opts.token), row.device_code);
318
+ });
319
+ tx();
320
+ return respondHtml(200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>aidimag — device approved</title>${AUTH_PAGE_STYLES}</head>
321
+ <body><div class="card"><div class="success-icon">✅</div><h1>Device approved</h1>
322
+ <p style="color:var(--dim);margin:12px 0 0">Scope: <strong style="color:var(--text)">${escapeHtml(scope === "*" ? "all brains" : scope)}</strong>.
323
+ Return to your terminal — <code>dim login</code> finishes automatically.</p></div></body></html>`);
324
+ }
325
+ if (req.method === "POST" && url.pathname === "/v1/auth/token") {
326
+ const body = await readBody(req);
327
+ let deviceCode = "";
328
+ try {
329
+ deviceCode = JSON.parse(body || "{}").device_code ?? "";
330
+ }
331
+ catch {
332
+ return respond(400, { error: "bad json" });
333
+ }
334
+ const row = db
335
+ .prepare("SELECT status, token, expires_at FROM device_codes WHERE device_code = ?")
336
+ .get(deviceCode);
337
+ if (!row)
338
+ return respond(404, { error: "unknown device_code" });
339
+ if (row.expires_at < new Date().toISOString())
340
+ return respond(410, { error: "expired" });
341
+ if (row.status === "DENIED")
342
+ return respond(410, { error: "denied" });
343
+ if (row.status !== "APPROVED" || !row.token)
344
+ return respond(428, { error: "authorization_pending" });
345
+ const plainToken = unsealDeviceToken(row.token, opts.token);
346
+ if (!plainToken)
347
+ return respond(500, { error: "token handoff failed" });
348
+ const scope = db
349
+ .prepare("SELECT brain FROM account_tokens WHERE token = ?")
350
+ .get(hashCred(plainToken));
351
+ // device codes are single-use: scrub after handoff
352
+ db.prepare("DELETE FROM device_codes WHERE device_code = ?").run(deviceCode);
353
+ return respond(200, { token: plainToken, brain: scope?.brain ?? null });
354
+ }
355
+ // ---- everything below requires Bearer auth ----------------------------
356
+ const auth = req.headers.authorization ?? "";
357
+ if (!auth.startsWith("Bearer "))
358
+ return respond(401, { error: "unauthorized" });
359
+ const presented = auth.slice(7);
360
+ const isAdmin = safeEqual(presented, opts.token);
361
+ // ---- key management (admin only) -------------------------------------
362
+ if (url.pathname === "/v1/keys") {
363
+ if (!isAdmin)
364
+ return respond(403, { error: "admin token required" });
365
+ if (req.method === "POST") {
366
+ try {
367
+ const body = await readBody(req);
368
+ const { brain: keyBrain, label } = JSON.parse(body || "{}");
369
+ if (!keyBrain)
370
+ return respond(400, { error: "missing brain" });
371
+ const key = `aidimag_sk_${randomBytes(24).toString("base64url")}`;
372
+ // stored hashed — shown in full exactly once, right here
373
+ db.prepare("INSERT INTO api_keys (key, brain, label, created_at) VALUES (?, ?, ?, ?)").run(hashCred(key), keyBrain, label ?? null, new Date().toISOString());
374
+ return respond(201, { key, brain: keyBrain, label: label ?? null });
375
+ }
376
+ catch (err) {
377
+ return respond(400, { error: err instanceof Error ? err.message : String(err) });
378
+ }
379
+ }
380
+ if (req.method === "GET") {
381
+ const keys = db
382
+ .prepare("SELECT key, brain, label, created_at, revoked_at FROM api_keys ORDER BY created_at")
383
+ .all();
384
+ // keys are stored hashed; show a short fingerprint for identification
385
+ return respond(200, {
386
+ keys: keys.map((k) => ({ ...k, key: `sha256:${k.key.slice(0, 12)}…` })),
387
+ });
388
+ }
389
+ if (req.method === "DELETE") {
390
+ const target = url.searchParams.get("key");
391
+ if (!target)
392
+ return respond(400, { error: "missing ?key=" });
393
+ // the admin pastes the plaintext credential; match on its hash
394
+ // (a raw stored hash also works, for revoking from a leaked-DB audit)
395
+ const hashes = [hashCred(target), target];
396
+ let changes = 0;
397
+ for (const h of hashes) {
398
+ changes += db
399
+ .prepare("UPDATE api_keys SET revoked_at = ? WHERE key = ? AND revoked_at IS NULL")
400
+ .run(new Date().toISOString(), h).changes;
401
+ changes += db
402
+ .prepare("UPDATE account_tokens SET revoked_at = ? WHERE token = ? AND revoked_at IS NULL")
403
+ .run(new Date().toISOString(), h).changes;
404
+ }
405
+ return respond(200, { revoked: changes > 0 });
406
+ }
407
+ return respond(405, { error: "method not allowed" });
408
+ }
409
+ // ---- sync endpoints: admin token OR a live credential scoped to brain -
410
+ const brain = url.searchParams.get("brain");
411
+ if (!brain)
412
+ return respond(400, { error: "missing ?brain=" });
413
+ if (!isAdmin) {
414
+ const syncIp = req.socket.remoteAddress ?? "unknown";
415
+ if (!syncLimiter(syncIp))
416
+ return respond(429, { error: "rate limited — try again in a minute" });
417
+ const scope = credentialScope(presented);
418
+ if (scope === null || scope !== brain) {
419
+ return respond(401, { error: "unauthorized for this brain" });
420
+ }
421
+ }
422
+ if (req.method === "POST" && url.pathname === "/v1/push") {
423
+ const body = await readBody(req);
424
+ const parsed = JSON.parse(body);
425
+ const items = validateSyncPushItems(parsed.items ?? []);
426
+ let accepted = 0;
427
+ let memoriesAccepted = 0;
428
+ const tx = db.transaction(() => {
429
+ for (const it of items) {
430
+ if (!it.id || !it.tbl || !it.updatedAt)
431
+ continue;
432
+ // server-side LWW guard: ignore pushes older than what we have
433
+ const cur = getLatest.get(brain, it.tbl, it.id);
434
+ if (cur && cur.updated_at >= it.updatedAt)
435
+ continue;
436
+ const info = insertItem.run(brain, it.tbl, it.id, it.updatedAt, it.deleted ? 1 : 0, it.payload === null ? null : JSON.stringify(it.payload));
437
+ setLatest.run(brain, it.tbl, it.id, info.lastInsertRowid, it.updatedAt);
438
+ accepted++;
439
+ if (it.tbl === "memories" && !it.deleted)
440
+ memoriesAccepted++;
441
+ }
442
+ });
443
+ tx();
444
+ const seq = db.prepare("SELECT COALESCE(MAX(seq),0) AS s FROM items WHERE brain = ?").get(brain).s;
445
+ return respond(200, { accepted, memoriesAccepted, seq });
446
+ }
447
+ if (req.method === "GET" && url.pathname === "/v1/pull") {
448
+ const since = parseInt(url.searchParams.get("since") ?? "0", 10);
449
+ // only the latest version of each row, newer than the cursor
450
+ const rows = db
451
+ .prepare(`SELECT i.tbl, i.id, i.updated_at, i.deleted, i.payload, i.seq
452
+ FROM items i JOIN latest l ON l.brain = i.brain AND l.tbl = i.tbl AND l.id = i.id AND l.seq = i.seq
453
+ WHERE i.brain = ? AND i.seq > ? ORDER BY i.seq LIMIT 1000`)
454
+ .all(brain, since);
455
+ const items = rows.map((r) => ({
456
+ tbl: r.tbl,
457
+ id: r.id,
458
+ updatedAt: r.updated_at,
459
+ deleted: !!r.deleted,
460
+ payload: r.payload ? JSON.parse(r.payload) : null,
461
+ }));
462
+ const seq = rows.length ? rows[rows.length - 1].seq : since;
463
+ return respond(200, { items, seq });
464
+ }
465
+ // ---- event ingest (append-only; idempotent on event id) ---------------
466
+ if (req.method === "POST" && url.pathname === "/v1/events") {
467
+ const body = await readBody(req);
468
+ const { events } = JSON.parse(body);
469
+ let accepted = 0;
470
+ const tx = db.transaction(() => {
471
+ for (const e of events) {
472
+ if (!e.id || !e.type || !e.machine || !e.createdAt)
473
+ continue;
474
+ const info = insertEvent.run(brain, e.id, e.type, e.memoryId ?? null, JSON.stringify(e.payload ?? {}), e.machine, e.schemaVersion ?? 0, e.createdAt);
475
+ if (info.changes > 0)
476
+ accepted++;
477
+ }
478
+ });
479
+ tx();
480
+ return respond(200, { accepted });
481
+ }
482
+ // ---- snapshot: read-only latest state (no sync cursor) ----------------
483
+ if (req.method === "GET" && url.pathname === "/v1/snapshot") {
484
+ const idFilter = url.searchParams.get("id");
485
+ const tblFilter = url.searchParams.get("tbl") === "proposals" ? "proposals" : "memories";
486
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get("limit") ?? "20", 10), 1), 200);
487
+ const list = !(url.searchParams.get("list") === "0" || url.searchParams.get("list") === "false");
488
+ const full = url.searchParams.get("full") === "1" || url.searchParams.get("full") === "true";
489
+ const includeAllProposals = url.searchParams.get("all") === "1" || url.searchParams.get("all") === "true";
490
+ const pendingProposalSql = `(json_extract(i.payload, '$.status') IS NULL OR json_extract(i.payload, '$.status') = 'PENDING')`;
491
+ const seq = db.prepare("SELECT COALESCE(MAX(seq),0) AS s FROM items WHERE brain = ?").get(brain).s;
492
+ const countRows = db
493
+ .prepare(`SELECT i.tbl, i.deleted, COUNT(*) AS n
494
+ FROM latest l JOIN items i ON l.brain = i.brain AND l.tbl = i.tbl AND l.id = i.id AND l.seq = i.seq
495
+ WHERE l.brain = ? GROUP BY i.tbl, i.deleted`)
496
+ .all(brain);
497
+ const pendingRow = db
498
+ .prepare(`SELECT COUNT(*) AS n
499
+ FROM latest l JOIN items i ON l.brain = i.brain AND l.tbl = i.tbl AND l.id = i.id AND l.seq = i.seq
500
+ WHERE l.brain = ? AND i.tbl = 'proposals' AND i.deleted = 0 AND ${pendingProposalSql}`)
501
+ .get(brain);
502
+ let memories = 0;
503
+ let tombstones = 0;
504
+ for (const row of countRows) {
505
+ if (row.deleted)
506
+ tombstones += row.n;
507
+ else if (row.tbl === "memories")
508
+ memories += row.n;
509
+ }
510
+ const proposals = pendingRow.n;
511
+ const summarize = (tbl, payload, deleted) => {
512
+ if (deleted || !payload)
513
+ return {};
514
+ const row = JSON.parse(payload);
515
+ if (tbl === "memories") {
516
+ return {
517
+ claim: typeof row.claim === "string" ? row.claim : undefined,
518
+ status: typeof row.status === "string" ? row.status : undefined,
519
+ kind: typeof row.kind === "string" ? row.kind : undefined,
520
+ };
521
+ }
522
+ return {
523
+ claim: typeof row.claim === "string" ? row.claim : typeof row.summary === "string" ? row.summary : undefined,
524
+ status: typeof row.status === "string" ? row.status : undefined,
525
+ };
526
+ };
527
+ const mapRow = (r) => ({
528
+ id: r.id,
529
+ tbl: r.tbl,
530
+ updatedAt: r.updated_at,
531
+ deleted: !!r.deleted,
532
+ ...summarize(r.tbl, r.payload, !!r.deleted),
533
+ ...(full && r.payload && !r.deleted ? { payload: JSON.parse(r.payload) } : {}),
534
+ });
535
+ if (idFilter) {
536
+ const row = db
537
+ .prepare(`SELECT i.tbl, i.id, i.updated_at, i.deleted, i.payload
538
+ FROM latest l JOIN items i ON l.brain = i.brain AND l.tbl = i.tbl AND l.id = i.id AND l.seq = i.seq
539
+ WHERE l.brain = ? AND i.id = ?`)
540
+ .get(brain, idFilter);
541
+ return respond(200, {
542
+ brain,
543
+ seq,
544
+ counts: { memories, proposals, tombstones },
545
+ ...(row ? { item: mapRow(row) } : {}),
546
+ });
547
+ }
548
+ const items = list
549
+ ? db
550
+ .prepare(tblFilter === "proposals" && !includeAllProposals
551
+ ? `SELECT i.tbl, i.id, i.updated_at, i.deleted, i.payload
552
+ FROM latest l JOIN items i ON l.brain = i.brain AND l.tbl = i.tbl AND l.id = i.id AND l.seq = i.seq
553
+ WHERE l.brain = ? AND i.tbl = ? AND i.deleted = 0 AND ${pendingProposalSql}
554
+ ORDER BY i.updated_at DESC LIMIT ?`
555
+ : `SELECT i.tbl, i.id, i.updated_at, i.deleted, i.payload
556
+ FROM latest l JOIN items i ON l.brain = i.brain AND l.tbl = i.tbl AND l.id = i.id AND l.seq = i.seq
557
+ WHERE l.brain = ? AND i.tbl = ? AND i.deleted = 0
558
+ ORDER BY i.updated_at DESC LIMIT ?`)
559
+ .all(brain, tblFilter, limit).map(mapRow)
560
+ : undefined;
561
+ return respond(200, { brain, seq, counts: { memories, proposals, tombstones }, ...(items ? { items } : {}) });
562
+ }
563
+ // ---- consensus: latest verification_report per (memory, machine) ------
564
+ if (req.method === "GET" && url.pathname === "/v1/consensus") {
565
+ const memoryFilter = url.searchParams.get("memory");
566
+ const rows = db
567
+ .prepare(`SELECT e.memory_id, e.machine, e.payload, e.created_at FROM events e
568
+ JOIN (
569
+ SELECT memory_id, machine, MAX(seq) AS seq FROM events
570
+ WHERE brain = ? AND type = 'verification_report' ${memoryFilter ? "AND memory_id = ?" : ""}
571
+ GROUP BY memory_id, machine
572
+ ) latest ON latest.seq = e.seq`)
573
+ .all(...(memoryFilter ? [brain, memoryFilter] : [brain]));
574
+ const byMemory = new Map();
575
+ for (const r of rows) {
576
+ const p = JSON.parse(r.payload);
577
+ const list = byMemory.get(r.memory_id) ?? [];
578
+ list.push({ machine: r.machine, pass: Boolean(p.pass), head: p.head ?? null, at: r.created_at });
579
+ byMemory.set(r.memory_id, list);
580
+ }
581
+ const consensus = [...byMemory.entries()].map(([memoryId, reports]) => ({
582
+ memoryId,
583
+ machines: reports.length,
584
+ passing: reports.filter((r) => r.pass).length,
585
+ reports,
586
+ }));
587
+ return respond(200, { consensus });
588
+ }
589
+ // ---- T3 tickets: team-shared credentials, server-side fetch + cache ---
590
+ if (url.pathname === "/v1/ticket-config") {
591
+ if (!isAdmin)
592
+ return respond(403, { error: "admin token required" });
593
+ if (req.method === "PUT" || req.method === "POST") {
594
+ const body = await readBody(req);
595
+ const cfg = JSON.parse(body || "{}");
596
+ if (!cfg.provider)
597
+ return respond(400, { error: "missing provider" });
598
+ if (cfg.provider === "http" && cfg.baseUrl && !isAllowedTicketBaseUrl(cfg.baseUrl)) {
599
+ return respond(400, { error: "ticket baseUrl not allowed" });
600
+ }
601
+ db.prepare(`INSERT INTO ticket_configs (brain, provider, base_url, credential, updated_at) VALUES (?, ?, ?, ?, ?)
602
+ ON CONFLICT(brain) DO UPDATE SET provider=excluded.provider, base_url=excluded.base_url,
603
+ credential=COALESCE(excluded.credential, ticket_configs.credential), updated_at=excluded.updated_at`).run(brain, cfg.provider, cfg.baseUrl ?? "", cfg.credential ?? null, new Date().toISOString());
604
+ db.prepare("DELETE FROM ticket_cache WHERE brain = ?").run(brain);
605
+ return respond(200, { ok: true, brain, provider: cfg.provider });
606
+ }
607
+ if (req.method === "GET") {
608
+ const row = db
609
+ .prepare("SELECT provider, base_url, credential IS NOT NULL AS has_credential, updated_at FROM ticket_configs WHERE brain = ?")
610
+ .get(brain);
611
+ return respond(200, { config: row ?? null });
612
+ }
613
+ if (req.method === "DELETE") {
614
+ const r = db.prepare("DELETE FROM ticket_configs WHERE brain = ?").run(brain);
615
+ db.prepare("DELETE FROM ticket_cache WHERE brain = ?").run(brain);
616
+ return respond(200, { removed: r.changes > 0 });
617
+ }
618
+ return respond(405, { error: "method not allowed" });
619
+ }
620
+ if (req.method === "GET" && url.pathname === "/v1/ticket") {
621
+ const ticketId = url.searchParams.get("id");
622
+ if (!ticketId)
623
+ return respond(400, { error: "missing ?id=" });
624
+ const cached = db
625
+ .prepare("SELECT payload, fetched_at FROM ticket_cache WHERE brain = ? AND id = ?")
626
+ .get(brain, ticketId);
627
+ if (cached && Date.now() - new Date(cached.fetched_at).getTime() < TICKET_CACHE_TTL_MS) {
628
+ if (!cached.payload)
629
+ return respond(404, { error: "ticket not found" });
630
+ return respond(200, JSON.parse(cached.payload));
631
+ }
632
+ const cfgRow = db
633
+ .prepare("SELECT provider, base_url, credential FROM ticket_configs WHERE brain = ?")
634
+ .get(brain);
635
+ if (!cfgRow)
636
+ return respond(404, { error: "no ticket provider configured for this brain (admin: dim ticket share)" });
637
+ const provider = buildDirectProvider(cfgRow.provider, cfgRow.base_url, cfgRow.credential);
638
+ if (!provider)
639
+ return respond(502, { error: "ticket provider misconfigured (missing credential?)" });
640
+ const ticket = await provider.getTicket(ticketId);
641
+ db.prepare("INSERT OR REPLACE INTO ticket_cache (brain, id, payload, fetched_at) VALUES (?, ?, ?, ?)").run(brain, ticketId, ticket ? JSON.stringify(ticket) : "", new Date().toISOString());
642
+ if (!ticket)
643
+ return respond(404, { error: "ticket not found" });
644
+ return respond(200, ticket);
645
+ }
646
+ respond(404, { error: "not found" });
647
+ }
648
+ catch (err) {
649
+ // never echo internals to clients (info-leak guard); log server-side
650
+ console.error(`[aidimag serve] ${req.method} ${req.url}:`, err instanceof Error ? err.message : err);
651
+ respond(400, { error: "bad request" });
652
+ }
653
+ });
654
+ return new Promise((resolve, reject) => {
655
+ server.on("error", reject);
656
+ server.listen(port, host, () => resolve(`http://${host === "0.0.0.0" ? "localhost" : host}:${port}`));
657
+ });
658
+ }
659
+ //# sourceMappingURL=server.js.map