engrm 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.
Files changed (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. package/tsconfig.json +22 -0
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Purge job: permanently delete archived observations older than 12 months.
3
+ *
4
+ * Runs monthly (checked on MCP server startup via scheduler).
5
+ * Pinned observations and digests are never purged.
6
+ */
7
+
8
+ import type { MemDatabase } from "../storage/sqlite.js";
9
+
10
+ const PURGE_THRESHOLD_SECONDS = 365 * 86400; // 12 months
11
+
12
+ export interface PurgeResult {
13
+ deleted: number;
14
+ }
15
+
16
+ /**
17
+ * Delete archived observations whose archived_at_epoch is older than 12 months.
18
+ * Only affects observations with lifecycle = 'archived' and a non-null archived_at_epoch.
19
+ */
20
+ export function runPurgeJob(
21
+ db: MemDatabase,
22
+ nowEpoch?: number
23
+ ): PurgeResult {
24
+ const now = nowEpoch ?? Math.floor(Date.now() / 1000);
25
+ const cutoff = now - PURGE_THRESHOLD_SECONDS;
26
+
27
+ const result = db.db
28
+ .query(
29
+ `DELETE FROM observations
30
+ WHERE lifecycle = 'archived'
31
+ AND archived_at_epoch IS NOT NULL
32
+ AND archived_at_epoch < ?`
33
+ )
34
+ .run(cutoff);
35
+
36
+ return { deleted: result.changes };
37
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { MemDatabase } from "../storage/sqlite.js";
6
+ import { runDueJobs } from "./scheduler.js";
7
+
8
+ let db: MemDatabase;
9
+ let tmpDir: string;
10
+ let projectId: number;
11
+
12
+ const DAY = 86400;
13
+ const NOW = Math.floor(Date.now() / 1000);
14
+
15
+ function insertObs(
16
+ overrides: Partial<{
17
+ lifecycle: string;
18
+ created_at_epoch: number;
19
+ title: string;
20
+ }> = {}
21
+ ) {
22
+ db.db
23
+ .query(
24
+ `INSERT INTO observations (project_id, type, title, quality, lifecycle, sensitivity, user_id, device_id, agent, created_at, created_at_epoch)
25
+ VALUES (?, 'discovery', ?, 0.5, ?, 'shared', 'user1', 'dev1', 'claude-code', datetime('now'), ?)`
26
+ )
27
+ .run(
28
+ projectId,
29
+ overrides.title ?? "Test observation",
30
+ overrides.lifecycle ?? "active",
31
+ overrides.created_at_epoch ?? NOW - 60 * DAY
32
+ );
33
+ }
34
+
35
+ beforeEach(() => {
36
+ tmpDir = mkdtempSync(join(tmpdir(), "candengo-scheduler-test-"));
37
+ db = new MemDatabase(join(tmpDir, "test.db"));
38
+ const project = db.upsertProject({
39
+ canonical_id: "github.com/test/repo",
40
+ name: "repo",
41
+ });
42
+ projectId = project.id;
43
+ });
44
+
45
+ afterEach(() => {
46
+ db.close();
47
+ rmSync(tmpDir, { recursive: true, force: true });
48
+ });
49
+
50
+ describe("runDueJobs", () => {
51
+ test("runs aging when never run before", () => {
52
+ insertObs({ created_at_epoch: NOW - 31 * DAY });
53
+ const result = runDueJobs(db, NOW);
54
+ expect(result.agingRan).toBe(true);
55
+ expect(result.aging!.transitioned).toBe(1);
56
+ });
57
+
58
+ test("runs aging when last run > 24h ago", () => {
59
+ db.setSyncState("lifecycle_aging_last_run", String(NOW - 25 * 3600));
60
+ insertObs({ created_at_epoch: NOW - 31 * DAY });
61
+ const result = runDueJobs(db, NOW);
62
+ expect(result.agingRan).toBe(true);
63
+ });
64
+
65
+ test("skips aging when last run < 24h ago", () => {
66
+ db.setSyncState("lifecycle_aging_last_run", String(NOW - 23 * 3600));
67
+ const result = runDueJobs(db, NOW);
68
+ expect(result.agingRan).toBe(false);
69
+ });
70
+
71
+ test("runs compaction when never run before", () => {
72
+ const result = runDueJobs(db, NOW);
73
+ expect(result.compactionRan).toBe(true);
74
+ });
75
+
76
+ test("runs compaction when last run > 7 days ago", () => {
77
+ db.setSyncState(
78
+ "lifecycle_compaction_last_run",
79
+ String(NOW - 8 * DAY)
80
+ );
81
+ const result = runDueJobs(db, NOW);
82
+ expect(result.compactionRan).toBe(true);
83
+ });
84
+
85
+ test("skips compaction when last run < 7 days ago", () => {
86
+ db.setSyncState(
87
+ "lifecycle_compaction_last_run",
88
+ String(NOW - 6 * DAY)
89
+ );
90
+ const result = runDueJobs(db, NOW);
91
+ expect(result.compactionRan).toBe(false);
92
+ });
93
+
94
+ test("runs purge when last run > 30 days ago", () => {
95
+ db.setSyncState("lifecycle_purge_last_run", String(NOW - 31 * DAY));
96
+ const result = runDueJobs(db, NOW);
97
+ expect(result.purgeRan).toBe(true);
98
+ });
99
+
100
+ test("skips purge when last run < 30 days ago", () => {
101
+ db.setSyncState("lifecycle_purge_last_run", String(NOW - 29 * DAY));
102
+ const result = runDueJobs(db, NOW);
103
+ expect(result.purgeRan).toBe(false);
104
+ });
105
+
106
+ test("updates sync_state timestamps after each job", () => {
107
+ runDueJobs(db, NOW);
108
+ expect(db.getSyncState("lifecycle_aging_last_run")).toBe(String(NOW));
109
+ expect(db.getSyncState("lifecycle_compaction_last_run")).toBe(String(NOW));
110
+ expect(db.getSyncState("lifecycle_purge_last_run")).toBe(String(NOW));
111
+ });
112
+
113
+ test("all jobs run independently", () => {
114
+ // All should run on first invocation (never run before)
115
+ const result = runDueJobs(db, NOW);
116
+ expect(result.agingRan).toBe(true);
117
+ expect(result.compactionRan).toBe(true);
118
+ expect(result.purgeRan).toBe(true);
119
+ });
120
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Lifecycle job scheduler.
3
+ *
4
+ * Checks on MCP server startup whether lifecycle jobs are due,
5
+ * and runs them if needed. Uses sync_state table for last-run tracking.
6
+ *
7
+ * Schedule:
8
+ * - Aging: daily (24h)
9
+ * - Compaction: weekly (7 days)
10
+ * - Purge: monthly (30 days)
11
+ */
12
+
13
+ import type { MemDatabase } from "../storage/sqlite.js";
14
+ import { runAgingJob, type AgingResult } from "./aging.js";
15
+ import { runCompactionJob, type CompactionResult } from "./compaction.js";
16
+ import { runPurgeJob, type PurgeResult } from "./purge.js";
17
+
18
+ const DAY = 86400;
19
+ const AGING_INTERVAL = 1 * DAY;
20
+ const COMPACTION_INTERVAL = 7 * DAY;
21
+ const PURGE_INTERVAL = 30 * DAY;
22
+
23
+ const KEY_AGING = "lifecycle_aging_last_run";
24
+ const KEY_COMPACTION = "lifecycle_compaction_last_run";
25
+ const KEY_PURGE = "lifecycle_purge_last_run";
26
+
27
+ export interface SchedulerResult {
28
+ agingRan: boolean;
29
+ compactionRan: boolean;
30
+ purgeRan: boolean;
31
+ aging?: AgingResult;
32
+ compaction?: CompactionResult;
33
+ purge?: PurgeResult;
34
+ }
35
+
36
+ /**
37
+ * Run any lifecycle jobs that are due.
38
+ * Each job is independent — one failure does not block others.
39
+ */
40
+ export function runDueJobs(
41
+ db: MemDatabase,
42
+ nowEpoch?: number
43
+ ): SchedulerResult {
44
+ const now = nowEpoch ?? Math.floor(Date.now() / 1000);
45
+ const result: SchedulerResult = {
46
+ agingRan: false,
47
+ compactionRan: false,
48
+ purgeRan: false,
49
+ };
50
+
51
+ // Aging: run if never run or > 24h since last run
52
+ try {
53
+ if (isDue(db, KEY_AGING, AGING_INTERVAL, now)) {
54
+ result.aging = runAgingJob(db, now);
55
+ result.agingRan = true;
56
+ db.setSyncState(KEY_AGING, String(now));
57
+ }
58
+ } catch {
59
+ // Aging job failed — continue with other jobs
60
+ }
61
+
62
+ // Compaction: run if never run or > 7 days since last run
63
+ try {
64
+ if (isDue(db, KEY_COMPACTION, COMPACTION_INTERVAL, now)) {
65
+ result.compaction = runCompactionJob(db, now);
66
+ result.compactionRan = true;
67
+ db.setSyncState(KEY_COMPACTION, String(now));
68
+ }
69
+ } catch {
70
+ // Compaction job failed — continue with other jobs
71
+ }
72
+
73
+ // Purge: run if never run or > 30 days since last run
74
+ try {
75
+ if (isDue(db, KEY_PURGE, PURGE_INTERVAL, now)) {
76
+ result.purge = runPurgeJob(db, now);
77
+ result.purgeRan = true;
78
+ db.setSyncState(KEY_PURGE, String(now));
79
+ }
80
+ } catch {
81
+ // Purge job failed — log would be nice but don't crash
82
+ }
83
+
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Check if a job is due based on its last run timestamp.
89
+ */
90
+ function isDue(
91
+ db: MemDatabase,
92
+ key: string,
93
+ interval: number,
94
+ now: number
95
+ ): boolean {
96
+ const lastRun = db.getSyncState(key);
97
+ if (!lastRun) return true; // Never run
98
+ const lastEpoch = parseInt(lastRun, 10);
99
+ if (isNaN(lastEpoch)) return true;
100
+ return now - lastEpoch >= interval;
101
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Browser-based OAuth flow for `engrm init`.
3
+ *
4
+ * 1. Start a localhost HTTP server on a random port
5
+ * 2. Open browser to candengo.com/connect/mem?redirect_uri=...
6
+ * 3. Wait for callback with authorization code
7
+ * 4. Return the code for exchange via /v1/mem/provision
8
+ */
9
+
10
+ import { randomBytes } from "node:crypto";
11
+
12
+ const CALLBACK_TIMEOUT_MS = 120_000; // 2 minutes
13
+
14
+ export interface AuthCallbackResult {
15
+ code: string;
16
+ state: string;
17
+ }
18
+
19
+ /**
20
+ * Run the browser OAuth flow.
21
+ * Returns the authorization code to exchange for credentials.
22
+ */
23
+ export async function runBrowserAuth(
24
+ candengoUrl: string
25
+ ): Promise<AuthCallbackResult> {
26
+ const state = randomBytes(16).toString("hex");
27
+
28
+ // Start callback server
29
+ const { port, waitForCallback, stop } = await startCallbackServer(state);
30
+ const redirectUri = `http://localhost:${port}/callback`;
31
+
32
+ // Build authorization URL
33
+ const authUrl = new URL("/connect/mem", candengoUrl);
34
+ authUrl.searchParams.set("redirect_uri", redirectUri);
35
+ authUrl.searchParams.set("state", state);
36
+
37
+ // Open browser
38
+ console.log(`\nOpening browser to authorize Engrm...`);
39
+ console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
40
+
41
+ const opened = await openBrowser(authUrl.toString());
42
+ if (!opened) {
43
+ console.log(
44
+ "Could not open browser. Use --token or --no-browser instead."
45
+ );
46
+ stop();
47
+ throw new Error("Browser launch failed");
48
+ }
49
+
50
+ console.log("Waiting for authorization...");
51
+
52
+ try {
53
+ const result = await waitForCallback;
54
+ return result;
55
+ } finally {
56
+ stop();
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Start a localhost HTTP server that waits for the OAuth callback.
62
+ */
63
+ async function startCallbackServer(expectedState: string): Promise<{
64
+ port: number;
65
+ waitForCallback: Promise<AuthCallbackResult>;
66
+ stop: () => void;
67
+ }> {
68
+ let resolveCallback: (result: AuthCallbackResult) => void;
69
+ let rejectCallback: (error: Error) => void;
70
+
71
+ const waitForCallback = new Promise<AuthCallbackResult>(
72
+ (resolve, reject) => {
73
+ resolveCallback = resolve;
74
+ rejectCallback = reject;
75
+ }
76
+ );
77
+
78
+ const server = Bun.serve({
79
+ port: 0, // random available port
80
+ fetch(req) {
81
+ const url = new URL(req.url);
82
+
83
+ if (url.pathname === "/callback") {
84
+ const code = url.searchParams.get("code");
85
+ const state = url.searchParams.get("state");
86
+ const error = url.searchParams.get("error");
87
+
88
+ if (error) {
89
+ const desc =
90
+ url.searchParams.get("error_description") ?? "Authorization denied";
91
+ rejectCallback!(new Error(desc));
92
+ return new Response(errorPage(desc), {
93
+ headers: { "Content-Type": "text/html" },
94
+ });
95
+ }
96
+
97
+ if (!code || !state) {
98
+ rejectCallback!(new Error("Missing code or state in callback"));
99
+ return new Response(errorPage("Missing authorization parameters"), {
100
+ headers: { "Content-Type": "text/html" },
101
+ });
102
+ }
103
+
104
+ if (state !== expectedState) {
105
+ rejectCallback!(new Error("State mismatch — possible CSRF"));
106
+ return new Response(errorPage("Security error: state mismatch"), {
107
+ headers: { "Content-Type": "text/html" },
108
+ });
109
+ }
110
+
111
+ resolveCallback!({ code, state });
112
+ return new Response(successPage(), {
113
+ headers: { "Content-Type": "text/html" },
114
+ });
115
+ }
116
+
117
+ return new Response("Not found", { status: 404 });
118
+ },
119
+ });
120
+
121
+ // Timeout
122
+ const timeout = setTimeout(() => {
123
+ rejectCallback!(
124
+ new Error("Authorization timed out. Please try again.")
125
+ );
126
+ }, CALLBACK_TIMEOUT_MS);
127
+
128
+ const stop = () => {
129
+ clearTimeout(timeout);
130
+ server.stop(true);
131
+ };
132
+
133
+ return { port: server.port!, waitForCallback, stop };
134
+ }
135
+
136
+ /**
137
+ * Open a URL in the default browser.
138
+ */
139
+ async function openBrowser(url: string): Promise<boolean> {
140
+ try {
141
+ const cmd =
142
+ process.platform === "darwin"
143
+ ? ["open", url]
144
+ : process.platform === "win32"
145
+ ? ["cmd", "/c", "start", url]
146
+ : ["xdg-open", url];
147
+
148
+ const proc = Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
149
+ await proc.exited;
150
+ return proc.exitCode === 0;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ function successPage(): string {
157
+ return `<!DOCTYPE html>
158
+ <html><head><title>Engrm</title>
159
+ <style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f8f9fa}
160
+ .card{text-align:center;padding:3rem;border-radius:12px;background:white;box-shadow:0 2px 12px rgba(0,0,0,0.1)}
161
+ h1{color:#10b981;margin-bottom:0.5rem}p{color:#6b7280}</style></head>
162
+ <body><div class="card"><h1>Connected!</h1><p>You can close this tab and return to the terminal.</p></div></body></html>`;
163
+ }
164
+
165
+ function errorPage(message: string): string {
166
+ return `<!DOCTYPE html>
167
+ <html><head><title>Engrm</title>
168
+ <style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f8f9fa}
169
+ .card{text-align:center;padding:3rem;border-radius:12px;background:white;box-shadow:0 2px 12px rgba(0,0,0,0.1)}
170
+ h1{color:#ef4444;margin-bottom:0.5rem}p{color:#6b7280}</style></head>
171
+ <body><div class="card"><h1>Authorization Failed</h1><p>${message}</p></div></body></html>`;
172
+ }
@@ -0,0 +1,198 @@
1
+ import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test";
2
+ import {
3
+ provision,
4
+ ProvisionError,
5
+ DEFAULT_CANDENGO_URL,
6
+ type ProvisionResponse,
7
+ } from "./provision.js";
8
+
9
+ // Mock server using Bun.serve
10
+ let server: ReturnType<typeof Bun.serve>;
11
+ let baseUrl: string;
12
+ let nextResponse: {
13
+ status: number;
14
+ body: unknown;
15
+ };
16
+
17
+ beforeEach(() => {
18
+ nextResponse = {
19
+ status: 200,
20
+ body: {
21
+ api_key: "cvk_test123456",
22
+ site_id: "test-site",
23
+ namespace: "test-ns",
24
+ user_id: "david",
25
+ user_email: "david@example.com",
26
+ teams: [{ id: "team_1", name: "Unimpossible", namespace: "dev-memory" }],
27
+ } satisfies ProvisionResponse,
28
+ };
29
+
30
+ server = Bun.serve({
31
+ port: 0,
32
+ fetch(req) {
33
+ const url = new URL(req.url);
34
+ if (url.pathname === "/v1/mem/provision" && req.method === "POST") {
35
+ return new Response(JSON.stringify(nextResponse.body), {
36
+ status: nextResponse.status,
37
+ headers: { "Content-Type": "application/json" },
38
+ });
39
+ }
40
+ return new Response("Not found", { status: 404 });
41
+ },
42
+ });
43
+
44
+ baseUrl = `http://localhost:${server.port}`;
45
+ });
46
+
47
+ afterEach(() => {
48
+ server.stop(true);
49
+ });
50
+
51
+ describe("provision", () => {
52
+ test("exchanges token for credentials", async () => {
53
+ const result = await provision(baseUrl, { token: "cmt_abc123" });
54
+ expect(result.api_key).toBe("cvk_test123456");
55
+ expect(result.site_id).toBe("test-site");
56
+ expect(result.namespace).toBe("test-ns");
57
+ expect(result.user_id).toBe("david");
58
+ expect(result.user_email).toBe("david@example.com");
59
+ expect(result.teams).toHaveLength(1);
60
+ expect(result.teams[0]!.id).toBe("team_1");
61
+ });
62
+
63
+ test("exchanges OAuth code for credentials", async () => {
64
+ const result = await provision(baseUrl, { code: "auth_code_123" });
65
+ expect(result.api_key).toBe("cvk_test123456");
66
+ });
67
+
68
+ test("sends device_name in request", async () => {
69
+ let capturedBody: string | null = null;
70
+ server.stop(true);
71
+ server = Bun.serve({
72
+ port: 0,
73
+ async fetch(req) {
74
+ if (req.method === "POST") {
75
+ capturedBody = await req.text();
76
+ return new Response(JSON.stringify(nextResponse.body), {
77
+ status: 200,
78
+ headers: { "Content-Type": "application/json" },
79
+ });
80
+ }
81
+ return new Response("Not found", { status: 404 });
82
+ },
83
+ });
84
+ baseUrl = `http://localhost:${server.port}`;
85
+
86
+ await provision(baseUrl, {
87
+ token: "cmt_test",
88
+ device_name: "MacBook Pro",
89
+ });
90
+ expect(capturedBody!).toContain("MacBook Pro");
91
+ });
92
+
93
+ test("throws ProvisionError on 401", async () => {
94
+ nextResponse = {
95
+ status: 401,
96
+ body: { detail: "Token expired" },
97
+ };
98
+
99
+ try {
100
+ await provision(baseUrl, { token: "cmt_expired" });
101
+ expect(true).toBe(false); // should not reach
102
+ } catch (error) {
103
+ expect(error).toBeInstanceOf(ProvisionError);
104
+ expect((error as ProvisionError).status).toBe(401);
105
+ expect((error as ProvisionError).detail).toBe(
106
+ "Invalid or expired provisioning token"
107
+ );
108
+ }
109
+ });
110
+
111
+ test("throws ProvisionError on 409 (already used)", async () => {
112
+ nextResponse = {
113
+ status: 409,
114
+ body: { detail: "Token already redeemed" },
115
+ };
116
+
117
+ try {
118
+ await provision(baseUrl, { token: "cmt_used" });
119
+ expect(true).toBe(false);
120
+ } catch (error) {
121
+ expect(error).toBeInstanceOf(ProvisionError);
122
+ expect((error as ProvisionError).status).toBe(409);
123
+ expect((error as ProvisionError).detail).toBe(
124
+ "Token has already been used"
125
+ );
126
+ }
127
+ });
128
+
129
+ test("throws ProvisionError on 500", async () => {
130
+ nextResponse = {
131
+ status: 500,
132
+ body: { detail: "Internal error" },
133
+ };
134
+
135
+ try {
136
+ await provision(baseUrl, { token: "cmt_test" });
137
+ expect(true).toBe(false);
138
+ } catch (error) {
139
+ expect(error).toBeInstanceOf(ProvisionError);
140
+ expect((error as ProvisionError).status).toBe(500);
141
+ }
142
+ });
143
+
144
+ test("validates api_key format in response", async () => {
145
+ nextResponse = {
146
+ status: 200,
147
+ body: {
148
+ api_key: "bad_key",
149
+ site_id: "s",
150
+ namespace: "n",
151
+ user_id: "u",
152
+ user_email: "e",
153
+ teams: [],
154
+ },
155
+ };
156
+
157
+ try {
158
+ await provision(baseUrl, { token: "cmt_test" });
159
+ expect(true).toBe(false);
160
+ } catch (error) {
161
+ expect(error).toBeInstanceOf(ProvisionError);
162
+ expect((error as ProvisionError).detail).toContain("invalid API key");
163
+ }
164
+ });
165
+
166
+ test("validates incomplete response", async () => {
167
+ nextResponse = {
168
+ status: 200,
169
+ body: {
170
+ api_key: "cvk_test123",
171
+ site_id: "",
172
+ namespace: "n",
173
+ user_id: "u",
174
+ user_email: "e",
175
+ teams: [],
176
+ },
177
+ };
178
+
179
+ try {
180
+ await provision(baseUrl, { token: "cmt_test" });
181
+ expect(true).toBe(false);
182
+ } catch (error) {
183
+ expect(error).toBeInstanceOf(ProvisionError);
184
+ expect((error as ProvisionError).detail).toContain("incomplete");
185
+ }
186
+ });
187
+
188
+ test("strips trailing slash from base URL", async () => {
189
+ const result = await provision(baseUrl + "/", { token: "cmt_test" });
190
+ expect(result.api_key).toBe("cvk_test123456");
191
+ });
192
+ });
193
+
194
+ describe("DEFAULT_CANDENGO_URL", () => {
195
+ test("points to production", () => {
196
+ expect(DEFAULT_CANDENGO_URL).toBe("https://www.candengo.com");
197
+ });
198
+ });