kojee-mcp 0.5.4 → 0.5.6

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 (29) hide show
  1. package/README.md +24 -5
  2. package/dist/{chunk-62KH6VNQ.js → chunk-2BDAM3TH.js} +61 -160
  3. package/dist/{chunk-36L3GCU3.js → chunk-3XDJOHMZ.js} +12 -2
  4. package/dist/chunk-6SK6ITFE.js +142 -0
  5. package/dist/{control-token-TYDAL477.js → chunk-GI2CKKBL.js} +13 -2
  6. package/dist/{resubscribe-SLZNA76S.js → chunk-OT2GILXC.js} +1 -0
  7. package/dist/{chunk-YVUXQ4Z2.js → chunk-UEGQGXPY.js} +53 -8
  8. package/dist/{chunk-OSKHA5DS.js → chunk-V5VZPYMZ.js} +2 -2
  9. package/dist/cli.js +19 -24
  10. package/dist/control-token-4BUCTYQB.js +13 -0
  11. package/dist/{doctor-TXWMMSRC.js → doctor-QCQDFLEH.js} +29 -16
  12. package/dist/{doctor-codex-3A7KYOVX.js → doctor-codex-NZ53ROQA.js} +3 -3
  13. package/dist/ensure-join-7AEDJMPE.js +96 -0
  14. package/dist/gateway-client-93P1E0CZ.d.ts +92 -0
  15. package/dist/{hook-server-NDJSV22J.js → hook-server-37E2LUKJ.js} +6 -0
  16. package/dist/index.d.ts +18 -15
  17. package/dist/index.js +7 -4
  18. package/dist/lib.d.ts +427 -0
  19. package/dist/lib.js +44 -0
  20. package/dist/reconnect-scheduler-JSXCJKQP.js +26 -0
  21. package/dist/resubscribe-G5OGDZJD.js +6 -0
  22. package/dist/{send-cli-7QJ36YY7.js → send-cli-C2F4WTBN.js} +1 -1
  23. package/dist/{stop-hook-GO363SMD.js → stop-hook-TRAMQYNE.js} +15 -7
  24. package/dist/{tail-stream-U436QL2X.js → tail-stream-VUZBYKXS.js} +4 -4
  25. package/dist/{user-prompt-submit-hook-ARPEO6FF.js → user-prompt-submit-hook-ZD2XKN7U.js} +7 -1
  26. package/dist/{webhook-config-UKUSI2FE.js → webhook-config-O4WMQ532.js} +1 -1
  27. package/dist/{webhook-sink-GCLL2S6S.js → webhook-sink-NWGCUDGY.js} +17 -3
  28. package/dist/{wizard-Z5JA3YPV.js → wizard-OSOAY4GO.js} +4 -4
  29. package/package.json +11 -2
package/README.md CHANGED
@@ -218,8 +218,8 @@ in it is Hermes-specific — and it is **OFF by default**.
218
218
  |---|---|
219
219
  | `KOJEE_WEBHOOK_URL` | Receiver endpoint (http/https). **Unset ⇒ sink OFF** (zero behavior change). |
220
220
  | `KOJEE_WEBHOOK_SECRET` | HMAC-SHA256 key for the signature header. URL set but secret unset ⇒ sink **DISABLED with an error** (the proxy NEVER sends unsigned webhooks). |
221
- | `KOJEE_WEBHOOK_TIMEOUT_MS` | Per-attempt request timeout (default `5000`). |
222
- | `KOJEE_WEBHOOK_MAX_RETRIES` | Retries on a retryable failure — network / 5xx / 408 / 429 (default `4`). |
221
+ | `KOJEE_WEBHOOK_TIMEOUT_MS` | Per-attempt request timeout (default `30000`). A timed-out attempt is **never retried** — see the retry policy below. |
222
+ | `KOJEE_WEBHOOK_MAX_RETRIES` | Retries on a **retryable** failure — connection errors (refused / reset / DNS) / 5xx / 408 / 429 (default `2`). Timeouts are **not** in this class. |
223
223
  | `KOJEE_WEBHOOK_SIGNATURE_HEADER` | Header name carrying the signature (default `X-Kojee-Signature`). |
224
224
  | `KOJEE_WEBHOOK_SIGNATURE_PREFIX` | Literal string prepended to the hex digest (default empty — bare hex). |
225
225
  | `KOJEE_WEBHOOK_SIGNATURE_FORMAT` | Optional preset. `github` ⇒ header `X-Hub-Signature-256`, prefix `sha256=` (the GitHub-webhook convention). Explicit `_HEADER`/`_PREFIX` vars override the preset's corresponding value. Unknown values are **warned about once and ignored** — never fatal. |
@@ -275,10 +275,29 @@ constant beside it):
275
275
  cursor on restart, so the same event may arrive more than once. There is **no
276
276
  exactly-once** promise; the receiver's dedupe is what makes redelivery safe.
277
277
 
278
+ **Retry policy (0.5.6 — anti-storm).** A delivery attempt that **times out is
279
+ never retried**: the receiver may well have processed the event and just
280
+ answered slowly (the canonical receiver spawns an agent session *before*
281
+ responding), so a re-POST risks duplicate side effects by design. The sink logs
282
+ it as `delivered-unconfirmed (receiver slow)` and moves on. Retries happen
283
+ **only on genuine non-delivery**: connection errors (refused / reset / DNS —
284
+ the request never reached a receiver) and 5xx / 408 / 429 responses (the
285
+ receiver answered that it did *not* process the event), up to
286
+ `KOJEE_WEBHOOK_MAX_RETRIES` (default `2`). For those retried cases delivery is
287
+ still **at-least-once** and every redelivery carries the same
288
+ `X-Kojee-Delivery` id and identical body bytes — **dedupe by event id remains
289
+ the receiver's responsibility** (the `recipe.ts` contract). This fix removes
290
+ the timeout-driven storm, not the at-least-once semantics. If your receiver
291
+ does slow work, the robust pattern is still: verify the signature, dedupe,
292
+ **respond `202` immediately**, then process.
293
+
278
294
  The sink is isolated and fire-and-forget: a slow, hanging, or failing webhook can
279
- never delay or break the Monitor (event-log) or Channel wake paths. The status
280
- log redacts the secret and strips any basic-auth credentials embedded in
281
- `KOJEE_WEBHOOK_URL`.
295
+ never delay or break the Monitor (event-log) or Channel wake paths (those run
296
+ before the webhook push). A slow delivery only delays *later webhook events*
297
+ behind it in the sink's own FIFO, and that backlog is bounded: the queue caps at
298
+ 1000 (overflow logs + drops the newest; the resubscribe-replay redelivers after
299
+ a restart). The status log redacts the secret and strips any basic-auth
300
+ credentials embedded in `KOJEE_WEBHOOK_URL`.
282
301
 
283
302
  ## Wake Continuity (0.5.4)
284
303
 
@@ -1,21 +1,21 @@
1
1
  import {
2
- startEventStream
3
- } from "./chunk-YVUXQ4Z2.js";
2
+ buildCatchUpNote,
3
+ buildMonitorSpawn,
4
+ buildReplyRecipe
5
+ } from "./chunk-X672ZN7V.js";
4
6
  import {
5
7
  deriveDiscoveryKey,
6
8
  findClaudeAncestorPid
7
9
  } from "./chunk-BJMASMKX.js";
8
10
  import {
9
- buildCatchUpNote,
10
- buildMonitorSpawn,
11
- buildReplyRecipe
12
- } from "./chunk-X672ZN7V.js";
11
+ AuthModule
12
+ } from "./chunk-6SK6ITFE.js";
13
+ import {
14
+ GatewayClient
15
+ } from "./chunk-3XDJOHMZ.js";
13
16
  import {
14
- GatewayClient,
15
- generateES256KeyPair,
16
- loadKeystore,
17
- saveKeystore
18
- } from "./chunk-36L3GCU3.js";
17
+ startEventStream
18
+ } from "./chunk-UEGQGXPY.js";
19
19
  import {
20
20
  translateToolCallResult
21
21
  } from "./chunk-LDZXU3DW.js";
@@ -25,139 +25,6 @@ import fs2 from "fs";
25
25
  import os from "os";
26
26
  import path2 from "path";
27
27
 
28
- // src/auth/auth-module.ts
29
- import { calculateJwkThumbprint } from "jose";
30
- import crypto from "crypto";
31
-
32
- // src/auth/registration.ts
33
- async function registerKey(brokerUrl, token, publicJwk) {
34
- const url = `${brokerUrl}/api/v1/bots/keys/register/`;
35
- const response = await fetch(url, {
36
- method: "POST",
37
- headers: {
38
- "Content-Type": "application/json",
39
- Authorization: `Bearer ${token}`
40
- },
41
- body: JSON.stringify({ public_jwk: publicJwk })
42
- });
43
- if (!response.ok) {
44
- const body = await response.text();
45
- throw new Error(
46
- `Key registration failed (${response.status}): ${body}`
47
- );
48
- }
49
- return await response.json();
50
- }
51
- async function confirmKey(brokerUrl, token, botKeyId, challenge, signature) {
52
- const url = `${brokerUrl}/api/v1/bots/keys/confirm/`;
53
- const response = await fetch(url, {
54
- method: "POST",
55
- headers: {
56
- "Content-Type": "application/json",
57
- Authorization: `Bearer ${token}`
58
- },
59
- body: JSON.stringify({
60
- bot_key_id: botKeyId,
61
- challenge,
62
- signature
63
- })
64
- });
65
- if (!response.ok) {
66
- const body = await response.text();
67
- throw new Error(
68
- `Key confirmation failed (${response.status}): ${body}`
69
- );
70
- }
71
- return await response.json();
72
- }
73
-
74
- // src/auth/auth-module.ts
75
- async function signChallengeRaw(privateKey, data) {
76
- const signer = crypto.createSign("SHA256");
77
- signer.update(data);
78
- signer.end();
79
- const derSignature = signer.sign(
80
- privateKey
81
- );
82
- return derSignature.toString("base64url");
83
- }
84
- var AuthModule = class {
85
- constructor(token, brokerUrl, keystorePath) {
86
- this.token = token;
87
- this.brokerUrl = brokerUrl;
88
- this.keystorePath = keystorePath;
89
- }
90
- token;
91
- brokerUrl;
92
- keystorePath;
93
- privateKey = null;
94
- publicJwk = null;
95
- kid = null;
96
- /**
97
- * Ensure we have an enrolled keypair. Either loads from disk or
98
- * performs the full enrollment flow.
99
- */
100
- async ensureEnrolled() {
101
- const existing = await loadKeystore(this.keystorePath, this.brokerUrl);
102
- if (existing) {
103
- this.privateKey = existing.privateKey;
104
- this.publicJwk = existing.publicJwk;
105
- this.kid = existing.kid;
106
- console.error("[auth] Loaded existing keypair from keystore");
107
- return {
108
- privateKey: existing.privateKey,
109
- publicJwk: existing.publicJwk,
110
- kid: existing.kid
111
- };
112
- }
113
- console.error("[auth] No valid keystore found, enrolling new keypair...");
114
- const { privateKey, publicJwk } = await generateES256KeyPair();
115
- const regResult = await registerKey(this.brokerUrl, this.token, publicJwk);
116
- console.error(`[auth] Key registered: ${regResult.bot_key_id}`);
117
- const thumbprint = await calculateJwkThumbprint(publicJwk, "sha256");
118
- const challengeData = `${regResult.challenge}.${thumbprint}`;
119
- const signature = await signChallengeRaw(privateKey, challengeData);
120
- const confirmResult = await confirmKey(
121
- this.brokerUrl,
122
- this.token,
123
- regResult.bot_key_id,
124
- regResult.challenge,
125
- signature
126
- );
127
- if (!confirmResult.key_confirmed) {
128
- throw new Error("Key enrollment failed: confirmation was rejected");
129
- }
130
- console.error("[auth] Key enrollment confirmed");
131
- await saveKeystore(
132
- privateKey,
133
- publicJwk,
134
- regResult.bot_key_id,
135
- this.brokerUrl,
136
- this.keystorePath
137
- );
138
- this.privateKey = privateKey;
139
- this.publicJwk = publicJwk;
140
- this.kid = regResult.bot_key_id;
141
- return {
142
- privateKey,
143
- publicJwk,
144
- kid: regResult.bot_key_id
145
- };
146
- }
147
- getPrivateKey() {
148
- if (!this.privateKey) throw new Error("Not enrolled yet");
149
- return this.privateKey;
150
- }
151
- getPublicJwk() {
152
- if (!this.publicJwk) throw new Error("Not enrolled yet");
153
- return this.publicJwk;
154
- }
155
- getKid() {
156
- if (!this.kid) throw new Error("Not enrolled yet");
157
- return this.kid;
158
- }
159
- };
160
-
161
28
  // src/tool-registry.ts
162
29
  var ToolRegistry = class {
163
30
  constructor(gateway) {
@@ -269,7 +136,19 @@ function buildChannelInstructions(_tandemMembershipCount, eventLogPath) {
269
136
  const advice = "\n\nPrefer (2) at session start \u2014 it's the default no-allowlist wake mechanism. (1) supplements it when channels are enabled; (3) is for one-shot blocking waits.";
270
137
  return intro + monitorSection + listenSection + advice;
271
138
  }
272
- function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLogPath) {
139
+ async function executeToolCall(registry, name, args, hooks) {
140
+ const rawResult = await registry.callTool(name, args);
141
+ const result = translateToolCallResult(rawResult);
142
+ if (name === "tandem_join" && !result.isError) {
143
+ try {
144
+ hooks?.onTandemJoin?.();
145
+ } catch (err) {
146
+ console.error("[mcp] onTandemJoin hook failed:", err?.message ?? String(err));
147
+ }
148
+ }
149
+ return result;
150
+ }
151
+ function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLogPath, hooks) {
273
152
  const capabilities = { tools: {} };
274
153
  if (adapter.supportsChannels) {
275
154
  capabilities.experimental = { "claude/channel": {} };
@@ -291,8 +170,7 @@ function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLog
291
170
  });
292
171
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
293
172
  const { name, arguments: args } = request.params;
294
- const rawResult = await registry.callTool(name, args ?? {});
295
- const result = translateToolCallResult(rawResult);
173
+ const result = await executeToolCall(registry, name, args ?? {}, hooks);
296
174
  return { content: result.content, isError: result.isError };
297
175
  });
298
176
  return server;
@@ -420,6 +298,7 @@ async function listTandemIds(gateway) {
420
298
  return list.map((t) => {
421
299
  if (typeof t === "string") return t;
422
300
  const obj = t;
301
+ if (obj?.my_membership?.is_member !== true) return void 0;
423
302
  return obj?.tandem_id ?? obj?.id;
424
303
  }).filter((id) => typeof id === "string" && id.length > 0);
425
304
  } catch {
@@ -437,6 +316,19 @@ async function startProxy(config) {
437
316
  console.error(
438
317
  `[kojee-mcp] Ready \u2014 ${registry.toolCount} tools available from ${config.url}`
439
318
  );
319
+ let activeStreamHandle = null;
320
+ const { createJoinReconnectScheduler } = await import("./reconnect-scheduler-JSXCJKQP.js");
321
+ const joinReconnect = createJoinReconnectScheduler({
322
+ reconnect: () => activeStreamHandle?.reconnect()
323
+ });
324
+ const onTandemJoin = () => joinReconnect.requestReconnect();
325
+ const { ensureJoinTandems } = await import("./ensure-join-7AEDJMPE.js");
326
+ await ensureJoinTandems({
327
+ gateway,
328
+ env: process.env["KOJEE_TANDEMS"],
329
+ listTandems: () => listTandemIds(gateway),
330
+ onJoined: () => joinReconnect.requestReconnect()
331
+ });
440
332
  let tandemMembershipCount = -1;
441
333
  try {
442
334
  const bootIds = await listTandemIds(gateway);
@@ -448,16 +340,16 @@ async function startProxy(config) {
448
340
  let server;
449
341
  if (adapter.supportsChannels) {
450
342
  const { EventQueue } = await import("./event-queue-5YVJFR3E.js");
451
- const { startHookServer } = await import("./hook-server-NDJSV22J.js");
343
+ const { startHookServer } = await import("./hook-server-37E2LUKJ.js");
452
344
  const {
453
345
  writeDiscoveryByKey,
454
346
  cleanupDiscoveryByKey,
455
347
  sweepStaleDiscovery
456
348
  } = await import("./session-discovery-FNMJGFPM.js");
457
349
  const { startEventLog, sweepStaleEventLogs } = await import("./event-log-RSTM4PLL.js");
458
- const { resubscribeMemberships } = await import("./resubscribe-SLZNA76S.js");
459
- const { resolveWebhookConfig } = await import("./webhook-config-UKUSI2FE.js");
460
- const { createWebhookSink } = await import("./webhook-sink-GCLL2S6S.js");
350
+ const { resubscribeMemberships } = await import("./resubscribe-G5OGDZJD.js");
351
+ const { resolveWebhookConfig } = await import("./webhook-config-O4WMQ532.js");
352
+ const { createWebhookSink } = await import("./webhook-sink-NWGCUDGY.js");
461
353
  sweepStaleDiscovery();
462
354
  sweepStaleEventLogs();
463
355
  const ccPid = await findClaudeAncestorPid();
@@ -487,14 +379,16 @@ async function startProxy(config) {
487
379
  void eventLog.appendStatus(`status=webhook enabled ${webhookSink.configSummary()}`).catch(() => {
488
380
  });
489
381
  }
490
- server = createMcpServer(registry, adapter, tandemMembershipCount, eventLog.path);
491
- const { issueControlToken, controlTokenPath } = await import("./control-token-TYDAL477.js");
382
+ server = createMcpServer(registry, adapter, tandemMembershipCount, eventLog.path, {
383
+ onTandemJoin
384
+ });
385
+ const { issueControlToken, controlTokenPath } = await import("./control-token-4BUCTYQB.js");
492
386
  let controlToken = null;
493
387
  try {
494
388
  controlToken = issueControlToken();
495
389
  } catch (err) {
496
390
  console.error(
497
- "[kojee-mcp] control token write failed \u2014 POST /send disabled:",
391
+ "[kojee-mcp] control token write failed \u2014 POST /send disabled; GET /poll and /status left UNGATED (degrade open):",
498
392
  err.message
499
393
  );
500
394
  }
@@ -504,7 +398,10 @@ async function startProxy(config) {
504
398
  port: 0,
505
399
  queue,
506
400
  adapter,
507
- ...controlToken !== null ? { send: { gateway, authToken: controlToken } } : {},
401
+ // 0.5.4 hardening: the same bearer gates POST /send AND the data-bearing
402
+ // reads (GET /poll, GET /status). When token issuance failed both stay
403
+ // available-but-degraded: /send answers 503, the reads stay open.
404
+ ...controlToken !== null ? { controlToken, send: { gateway, authToken: controlToken } } : {},
508
405
  getStreamState: () => streamHandle ? streamHandle.getState() : {
509
406
  connected: false,
510
407
  connectedSince: null,
@@ -584,6 +481,7 @@ async function startProxy(config) {
584
481
  };
585
482
  })()
586
483
  });
484
+ activeStreamHandle = streamHandle;
587
485
  const cancelStream = streamHandle;
588
486
  process.stdin.on("end", () => {
589
487
  cancelStream();
@@ -597,9 +495,9 @@ async function startProxy(config) {
597
495
  });
598
496
  } else if (needsWebhookEventStream()) {
599
497
  const { startEventLog, sweepStaleEventLogs } = await import("./event-log-RSTM4PLL.js");
600
- const { resolveWebhookConfig } = await import("./webhook-config-UKUSI2FE.js");
601
- const { createWebhookSink } = await import("./webhook-sink-GCLL2S6S.js");
602
- const { resubscribeMemberships } = await import("./resubscribe-SLZNA76S.js");
498
+ const { resolveWebhookConfig } = await import("./webhook-config-O4WMQ532.js");
499
+ const { createWebhookSink } = await import("./webhook-sink-NWGCUDGY.js");
500
+ const { resubscribeMemberships } = await import("./resubscribe-G5OGDZJD.js");
603
501
  sweepStaleEventLogs();
604
502
  const ccPid = await findClaudeAncestorPid();
605
503
  const projectDir = process.env["CLAUDE_PROJECT_DIR"];
@@ -627,7 +525,9 @@ async function startProxy(config) {
627
525
  void eventLog.appendStatus(`status=webhook enabled ${webhookSink.configSummary()}`).catch(() => {
628
526
  });
629
527
  }
630
- server = createMcpServer(registry, adapter, tandemMembershipCount);
528
+ server = createMcpServer(registry, adapter, tandemMembershipCount, void 0, {
529
+ onTandemJoin
530
+ });
631
531
  process.on("exit", () => eventLog.cleanup());
632
532
  const streamHandle = await startEventStream({
633
533
  brokerUrl: config.url,
@@ -649,6 +549,7 @@ async function startProxy(config) {
649
549
  };
650
550
  })()
651
551
  });
552
+ activeStreamHandle = streamHandle;
652
553
  process.stdin.on("end", () => {
653
554
  streamHandle();
654
555
  void webhookSink?.stop();
@@ -698,7 +599,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
698
599
  }
699
600
 
700
601
  export {
701
- AuthModule,
702
602
  VERSION,
603
+ listTandemIds,
703
604
  startProxy
704
605
  };
@@ -14,10 +14,18 @@ import {
14
14
 
15
15
  // src/auth/keystore.ts
16
16
  import { importJWK, exportJWK, generateKeyPair } from "jose";
17
+ import crypto from "crypto";
17
18
  import fs from "fs";
18
19
  import os from "os";
19
20
  import path from "path";
20
21
  var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
22
+ function defaultPairedKeystorePath() {
23
+ return path.join(os.homedir(), ".kojee", "keypair.json");
24
+ }
25
+ function deriveKeystorePath(token) {
26
+ const hash = crypto.createHash("sha256").update(token).digest("hex").slice(0, 12);
27
+ return path.join(os.homedir(), ".kojee", `keypair-${hash}.json`);
28
+ }
21
29
  async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
22
30
  if (!fs.existsSync(keystorePath)) {
23
31
  return null;
@@ -61,7 +69,7 @@ async function generateES256KeyPair() {
61
69
  }
62
70
 
63
71
  // src/gateway-client.ts
64
- import crypto from "crypto";
72
+ import crypto2 from "crypto";
65
73
  var GatewayClient = class {
66
74
  constructor(brokerUrl, token, privateKey, kid, sessionId) {
67
75
  this.brokerUrl = brokerUrl;
@@ -97,7 +105,7 @@ var GatewayClient = class {
97
105
  * session_id = sha256(token + "proxy").slice(0, 16)
98
106
  */
99
107
  static deriveSessionId(token) {
100
- const hash = crypto.createHash("sha256").update(token + "proxy").digest("hex");
108
+ const hash = crypto2.createHash("sha256").update(token + "proxy").digest("hex");
101
109
  return hash.slice(0, 16);
102
110
  }
103
111
  /**
@@ -206,6 +214,8 @@ var GatewayClient = class {
206
214
  };
207
215
 
208
216
  export {
217
+ defaultPairedKeystorePath,
218
+ deriveKeystorePath,
209
219
  loadKeystore,
210
220
  saveKeystore,
211
221
  generateES256KeyPair,
@@ -0,0 +1,142 @@
1
+ import {
2
+ generateES256KeyPair,
3
+ loadKeystore,
4
+ saveKeystore
5
+ } from "./chunk-3XDJOHMZ.js";
6
+
7
+ // src/auth/auth-module.ts
8
+ import { calculateJwkThumbprint } from "jose";
9
+ import crypto from "crypto";
10
+
11
+ // src/auth/registration.ts
12
+ async function registerKey(brokerUrl, token, publicJwk) {
13
+ const url = `${brokerUrl}/api/v1/bots/keys/register/`;
14
+ const response = await fetch(url, {
15
+ method: "POST",
16
+ headers: {
17
+ "Content-Type": "application/json",
18
+ Authorization: `Bearer ${token}`
19
+ },
20
+ body: JSON.stringify({ public_jwk: publicJwk })
21
+ });
22
+ if (!response.ok) {
23
+ const body = await response.text();
24
+ throw new Error(
25
+ `Key registration failed (${response.status}): ${body}`
26
+ );
27
+ }
28
+ return await response.json();
29
+ }
30
+ async function confirmKey(brokerUrl, token, botKeyId, challenge, signature) {
31
+ const url = `${brokerUrl}/api/v1/bots/keys/confirm/`;
32
+ const response = await fetch(url, {
33
+ method: "POST",
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ Authorization: `Bearer ${token}`
37
+ },
38
+ body: JSON.stringify({
39
+ bot_key_id: botKeyId,
40
+ challenge,
41
+ signature
42
+ })
43
+ });
44
+ if (!response.ok) {
45
+ const body = await response.text();
46
+ throw new Error(
47
+ `Key confirmation failed (${response.status}): ${body}`
48
+ );
49
+ }
50
+ return await response.json();
51
+ }
52
+
53
+ // src/auth/auth-module.ts
54
+ async function signChallengeRaw(privateKey, data) {
55
+ const signer = crypto.createSign("SHA256");
56
+ signer.update(data);
57
+ signer.end();
58
+ const derSignature = signer.sign(
59
+ privateKey
60
+ );
61
+ return derSignature.toString("base64url");
62
+ }
63
+ var AuthModule = class {
64
+ constructor(token, brokerUrl, keystorePath) {
65
+ this.token = token;
66
+ this.brokerUrl = brokerUrl;
67
+ this.keystorePath = keystorePath;
68
+ }
69
+ token;
70
+ brokerUrl;
71
+ keystorePath;
72
+ privateKey = null;
73
+ publicJwk = null;
74
+ kid = null;
75
+ /**
76
+ * Ensure we have an enrolled keypair. Either loads from disk or
77
+ * performs the full enrollment flow.
78
+ */
79
+ async ensureEnrolled() {
80
+ const existing = await loadKeystore(this.keystorePath, this.brokerUrl);
81
+ if (existing) {
82
+ this.privateKey = existing.privateKey;
83
+ this.publicJwk = existing.publicJwk;
84
+ this.kid = existing.kid;
85
+ console.error("[auth] Loaded existing keypair from keystore");
86
+ return {
87
+ privateKey: existing.privateKey,
88
+ publicJwk: existing.publicJwk,
89
+ kid: existing.kid
90
+ };
91
+ }
92
+ console.error("[auth] No valid keystore found, enrolling new keypair...");
93
+ const { privateKey, publicJwk } = await generateES256KeyPair();
94
+ const regResult = await registerKey(this.brokerUrl, this.token, publicJwk);
95
+ console.error(`[auth] Key registered: ${regResult.bot_key_id}`);
96
+ const thumbprint = await calculateJwkThumbprint(publicJwk, "sha256");
97
+ const challengeData = `${regResult.challenge}.${thumbprint}`;
98
+ const signature = await signChallengeRaw(privateKey, challengeData);
99
+ const confirmResult = await confirmKey(
100
+ this.brokerUrl,
101
+ this.token,
102
+ regResult.bot_key_id,
103
+ regResult.challenge,
104
+ signature
105
+ );
106
+ if (!confirmResult.key_confirmed) {
107
+ throw new Error("Key enrollment failed: confirmation was rejected");
108
+ }
109
+ console.error("[auth] Key enrollment confirmed");
110
+ await saveKeystore(
111
+ privateKey,
112
+ publicJwk,
113
+ regResult.bot_key_id,
114
+ this.brokerUrl,
115
+ this.keystorePath
116
+ );
117
+ this.privateKey = privateKey;
118
+ this.publicJwk = publicJwk;
119
+ this.kid = regResult.bot_key_id;
120
+ return {
121
+ privateKey,
122
+ publicJwk,
123
+ kid: regResult.bot_key_id
124
+ };
125
+ }
126
+ getPrivateKey() {
127
+ if (!this.privateKey) throw new Error("Not enrolled yet");
128
+ return this.privateKey;
129
+ }
130
+ getPublicJwk() {
131
+ if (!this.publicJwk) throw new Error("Not enrolled yet");
132
+ return this.publicJwk;
133
+ }
134
+ getKid() {
135
+ if (!this.kid) throw new Error("Not enrolled yet");
136
+ return this.kid;
137
+ }
138
+ };
139
+
140
+ export {
141
+ AuthModule
142
+ };
@@ -16,7 +16,12 @@ function issueControlToken(filePath = controlTokenPath()) {
16
16
  const dir = path.dirname(filePath);
17
17
  fs.mkdirSync(dir, { recursive: true, mode: 448 });
18
18
  secureDir(dir);
19
- fs.writeFileSync(filePath, token + "\n", { mode: 384 });
19
+ try {
20
+ fs.unlinkSync(filePath);
21
+ } catch (err) {
22
+ if (err.code !== "ENOENT") throw err;
23
+ }
24
+ fs.writeFileSync(filePath, token + "\n", { mode: 384, flag: "wx" });
20
25
  secureFile(filePath);
21
26
  return token;
22
27
  }
@@ -28,8 +33,14 @@ function loadControlToken(filePath = controlTokenPath()) {
28
33
  return null;
29
34
  }
30
35
  }
36
+ function controlTokenAuthHeaders(filePath) {
37
+ const token = loadControlToken(filePath);
38
+ return token ? { Authorization: `Bearer ${token}` } : {};
39
+ }
40
+
31
41
  export {
32
42
  controlTokenPath,
33
43
  issueControlToken,
34
- loadControlToken
44
+ loadControlToken,
45
+ controlTokenAuthHeaders
35
46
  };
@@ -54,6 +54,7 @@ async function resubscribeMemberships(opts) {
54
54
  });
55
55
  return touched;
56
56
  }
57
+
57
58
  export {
58
59
  resubscribeMemberships
59
60
  };