@tangle-network/agent-core 0.2.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/auth/browser.d.ts +22 -0
  4. package/dist/auth/browser.d.ts.map +1 -0
  5. package/dist/auth/browser.js +43 -0
  6. package/dist/auth/browser.js.map +1 -0
  7. package/dist/auth/index.d.ts +430 -0
  8. package/dist/auth/index.d.ts.map +1 -0
  9. package/dist/auth/index.js +841 -0
  10. package/dist/auth/index.js.map +1 -0
  11. package/dist/computer-use/index.d.ts +122 -0
  12. package/dist/computer-use/index.d.ts.map +1 -0
  13. package/dist/computer-use/index.js +422 -0
  14. package/dist/computer-use/index.js.map +1 -0
  15. package/dist/errors-DJ6U6cHY.js +146 -0
  16. package/dist/errors-DJ6U6cHY.js.map +1 -0
  17. package/dist/events/index.d.ts +2 -0
  18. package/dist/events/index.js +2 -0
  19. package/dist/events-CUWh9qrv.js +263 -0
  20. package/dist/events-CUWh9qrv.js.map +1 -0
  21. package/dist/index-CFA8kgHJ.d.ts +352 -0
  22. package/dist/index-CFA8kgHJ.d.ts.map +1 -0
  23. package/dist/index-CtebBodu.d.ts +108 -0
  24. package/dist/index-CtebBodu.d.ts.map +1 -0
  25. package/dist/index-DiQX9bjq.d.ts +55 -0
  26. package/dist/index-DiQX9bjq.d.ts.map +1 -0
  27. package/dist/index-DlPznmKA.d.ts +158 -0
  28. package/dist/index-DlPznmKA.d.ts.map +1 -0
  29. package/dist/index-z2wPdzFb.d.ts +133 -0
  30. package/dist/index-z2wPdzFb.d.ts.map +1 -0
  31. package/dist/index.d.ts +565 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +822 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/resilience/index.d.ts +2 -0
  36. package/dist/resilience/index.js +2 -0
  37. package/dist/resilience-BQzDfw_C.js +221 -0
  38. package/dist/resilience-BQzDfw_C.js.map +1 -0
  39. package/dist/sse/index.d.ts +174 -0
  40. package/dist/sse/index.d.ts.map +1 -0
  41. package/dist/sse/index.js +303 -0
  42. package/dist/sse/index.js.map +1 -0
  43. package/dist/transport/index.d.ts +2 -0
  44. package/dist/transport/index.js +2 -0
  45. package/dist/transport-mKLoQep5.js +427 -0
  46. package/dist/transport-mKLoQep5.js.map +1 -0
  47. package/dist/types/index.d.ts +2 -0
  48. package/dist/types/index.js +2 -0
  49. package/dist/types-D5VLaol0.js +31 -0
  50. package/dist/types-D5VLaol0.js.map +1 -0
  51. package/dist/types-DC6OJ1um.js +331 -0
  52. package/dist/types-DC6OJ1um.js.map +1 -0
  53. package/dist/types-DiDSGM8K.d.ts +320 -0
  54. package/dist/types-DiDSGM8K.d.ts.map +1 -0
  55. package/package.json +132 -0
@@ -0,0 +1,841 @@
1
+ import { a as ReadTokenPayloadSchema, c as CallbackAuth, d as RefreshableTokenAuth, f as StaticTokenAuth, i as ProjectScopedTokenPayloadSchema, l as NoAuth, n as CreateSessionRequestSchema, o as SendMessageRequestSchema, r as ProductSchema, s as SessionScopedTokenPayloadSchema, t as BatchScopedTokenPayloadSchema, u as ReadTokenAuth } from "../types-DC6OJ1um.js";
2
+ import { createHmac, generateKeyPairSync, randomBytes, sign, timingSafeEqual, verify } from "node:crypto";
3
+ //#region src/auth/tokens.ts
4
+ /**
5
+ * JWT Token Utilities
6
+ *
7
+ * Token generation and verification for multi-tenant auth.
8
+ * - Read/product tokens: HMAC-SHA256 (symmetric, fast)
9
+ * - Sidecar tokens: Ed25519 (asymmetric — sidecar cannot forge tokens)
10
+ */
11
+ /**
12
+ * Generate a cryptographically secure random string.
13
+ * @param prefix - Prefix for the generated string (e.g., "orch_prod_")
14
+ * @param bytes - Number of random bytes (default: 32 = 256 bits)
15
+ */
16
+ function generateSecureToken(prefix, bytes = 32) {
17
+ return `${prefix}${randomBytes(bytes).toString("base64url")}`;
18
+ }
19
+ /**
20
+ * Generate a product API key.
21
+ */
22
+ function generateApiKey() {
23
+ return generateSecureToken("orch_prod_");
24
+ }
25
+ /**
26
+ * Generate a signing secret.
27
+ */
28
+ function generateSigningSecret() {
29
+ return generateSecureToken("orch_sign_");
30
+ }
31
+ /**
32
+ * Base64URL encode (RFC 7515).
33
+ */
34
+ function base64UrlEncode(data) {
35
+ return (typeof data === "string" ? Buffer.from(data) : data).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
36
+ }
37
+ /**
38
+ * Base64URL decode.
39
+ */
40
+ function base64UrlDecode(data) {
41
+ const padded = data + "=".repeat((4 - data.length % 4) % 4);
42
+ return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString();
43
+ }
44
+ /**
45
+ * Create HMAC-SHA256 signature.
46
+ */
47
+ function createSignature(data, secret) {
48
+ return base64UrlEncode(createHmac("sha256", secret).update(data).digest());
49
+ }
50
+ /**
51
+ * Verify signature using timing-safe comparison on raw HMAC bytes.
52
+ *
53
+ * Decoding both sides to raw bytes before the compare strips the
54
+ * length-of-base64url-string oracle: every legitimate signature
55
+ * decodes to exactly 32 bytes (HMAC-SHA256 digest size), so a
56
+ * length mismatch on the decoded buffer means malformed input,
57
+ * not a partial match. Equivalent to the pattern used in
58
+ * `products/sandbox/sdk/src/auth/tokens.ts`.
59
+ */
60
+ function verifySignature(data, signature, secret) {
61
+ const expectedBuf = createHmac("sha256", secret).update(data).digest();
62
+ let providedBuf;
63
+ try {
64
+ providedBuf = Buffer.from(signature, "base64url");
65
+ } catch {
66
+ return false;
67
+ }
68
+ if (providedBuf.length !== expectedBuf.length) return false;
69
+ try {
70
+ return timingSafeEqual(providedBuf, expectedBuf);
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+ /**
76
+ * JWT header (always the same for our use case).
77
+ */
78
+ const JWT_HEADER = base64UrlEncode(JSON.stringify({
79
+ alg: "HS256",
80
+ typ: "JWT"
81
+ }));
82
+ /**
83
+ * Issue a read token (JWT) for WebSocket authentication.
84
+ *
85
+ * @param signingSecret - The product's signing secret
86
+ * @param payload - Token payload (without iat/exp, those are added)
87
+ * @param ttlMinutes - Token TTL in minutes
88
+ */
89
+ function issueReadToken(signingSecret, payload, ttlMinutes) {
90
+ const now = Math.floor(Date.now() / 1e3);
91
+ const fullPayload = {
92
+ ...payload,
93
+ typ: "read",
94
+ iat: now,
95
+ exp: now + ttlMinutes * 60
96
+ };
97
+ const data = `${JWT_HEADER}.${base64UrlEncode(JSON.stringify(fullPayload))}`;
98
+ return `${data}.${createSignature(data, signingSecret)}`;
99
+ }
100
+ /**
101
+ * Issue a session-scoped token (JWT) for WebSocket authentication.
102
+ * Grants access to a single session's events.
103
+ *
104
+ * @param signingSecret - The product's signing secret
105
+ * @param payload - Token payload with session ID
106
+ * @param ttlMinutes - Token TTL in minutes
107
+ */
108
+ function issueSessionScopedToken(signingSecret, payload, ttlMinutes) {
109
+ return issueReadToken(signingSecret, payload, ttlMinutes);
110
+ }
111
+ /**
112
+ * Issue a project-scoped token (JWT) for WebSocket authentication.
113
+ * Grants access to all sessions within a single project.
114
+ *
115
+ * @param signingSecret - The product's signing secret
116
+ * @param payload - Token payload with project ID
117
+ * @param ttlMinutes - Token TTL in minutes
118
+ */
119
+ function issueProjectScopedToken(signingSecret, payload, ttlMinutes) {
120
+ return issueReadToken(signingSecret, payload, ttlMinutes);
121
+ }
122
+ /**
123
+ * Issue a batch-scoped token (JWT) for WebSocket authentication.
124
+ * Grants access to multiple projects (organization-level access).
125
+ *
126
+ * @param signingSecret - The product's signing secret
127
+ * @param payload - Token payload with project IDs array
128
+ * @param ttlMinutes - Token TTL in minutes
129
+ */
130
+ function issueBatchScopedToken(signingSecret, payload, ttlMinutes) {
131
+ return issueReadToken(signingSecret, payload, ttlMinutes);
132
+ }
133
+ const JWT_HEADER_EDDSA = base64UrlEncode(JSON.stringify({
134
+ alg: "EdDSA",
135
+ typ: "JWT"
136
+ }));
137
+ /**
138
+ * Public ↔ capability binding. The sidecar's auth middleware uses this
139
+ * to enforce: a token with a `cap` claim can only call routes whose
140
+ * required capability is in the token's `cap` list. Routes not listed
141
+ * here implicitly require full scope (no `cap` claim, i.e. legacy
142
+ * orchestrator-internal tokens).
143
+ *
144
+ * Order matters — longest prefix wins so `/mcp/x` matches before `/`.
145
+ */
146
+ const SIDECAR_CAPABILITY_ROUTES = [
147
+ {
148
+ prefix: "/mcp",
149
+ capability: "computer_use"
150
+ },
151
+ {
152
+ prefix: "/computer-use",
153
+ capability: "computer_use"
154
+ },
155
+ {
156
+ prefix: "/a11y",
157
+ capability: "computer_use"
158
+ },
159
+ {
160
+ prefix: "/debug",
161
+ capability: "debug"
162
+ }
163
+ ];
164
+ /**
165
+ * Pattern-gated routes, for capabilities that must bind to a SPECIFIC path
166
+ * shape rather than a whole prefix. The terminal/ssh WebSocket upgrade paths
167
+ * are the raw-input interactive surface (keystrokes straight to a PTY / SSH
168
+ * tunnel) — the highest-trust action in the box — so they require a `terminal`
169
+ * capability, while the sibling REST route `/terminals/commands` (one-shot
170
+ * command exec, called by the public SDK + artifact queue) deliberately stays
171
+ * OUT of the gate so a prefix match does not break those callers.
172
+ *
173
+ * Adding a pattern: anchor it (`^…$`) so it cannot over-match a sibling path.
174
+ */
175
+ const SIDECAR_CAPABILITY_ROUTE_PATTERNS = [{
176
+ pattern: /^\/terminals\/[^/]+\/ws$/,
177
+ capability: "terminal"
178
+ }, {
179
+ pattern: /^\/ssh\/?$/,
180
+ capability: "terminal"
181
+ }];
182
+ /**
183
+ * Routes a token with `cap: ["read"]` is allowed to call, with the HTTP
184
+ * methods permitted on each. Used by `box.mintScopedToken({scope:
185
+ * "read-only" | "project" | "session"})` (Issue #913 Gap 1).
186
+ *
187
+ * The "read" capability is intentionally NOT in
188
+ * `SIDECAR_CAPABILITY_ROUTES` — that table maps gated routes that ONLY
189
+ * the matching cap can access. "read" is *additive*: it grants a token
190
+ * access to a narrow allow-list of read routes WITHOUT also denying
191
+ * those same routes to master tokens. Master tokens (cap=undefined)
192
+ * keep reaching every non-gated route as before.
193
+ *
194
+ * Adding a route here: keep it GET-only by default. Mutating methods
195
+ * on a read-token would defeat the "read-only" promise customers rely
196
+ * on when handing the token to browser code.
197
+ */
198
+ const READ_CAPABILITY_ROUTES = [
199
+ {
200
+ prefix: "/agents/sessions",
201
+ methods: ["GET"]
202
+ },
203
+ {
204
+ prefix: "/agents/events",
205
+ methods: ["GET"]
206
+ },
207
+ {
208
+ prefix: "/health",
209
+ methods: ["GET"]
210
+ },
211
+ {
212
+ prefix: "/privacy",
213
+ methods: ["GET"]
214
+ },
215
+ {
216
+ prefix: "/computer-use/screenshot",
217
+ methods: ["GET"]
218
+ }
219
+ ];
220
+ function isReadCapAllowed(path, method) {
221
+ if (!method) return false;
222
+ const m = method.toUpperCase();
223
+ for (const entry of READ_CAPABILITY_ROUTES) if (path === entry.prefix || path.startsWith(`${entry.prefix}/`)) return entry.methods.includes(m);
224
+ return false;
225
+ }
226
+ /**
227
+ * Returns the capability required to access `path`, or `null` if the
228
+ * route requires full scope (no `cap` claim).
229
+ */
230
+ function requiredCapabilityForPath(path) {
231
+ for (const { pattern, capability } of SIDECAR_CAPABILITY_ROUTE_PATTERNS) if (pattern.test(path)) return capability;
232
+ for (const { prefix, capability } of SIDECAR_CAPABILITY_ROUTES) if (path === prefix || path.startsWith(`${prefix}/`)) return capability;
233
+ return null;
234
+ }
235
+ /**
236
+ * Authorize a verified sidecar token for `path` (and optionally
237
+ * `method`). Returns true iff one of:
238
+ * - the token has NO `cap` claim (legacy full-scope master) AND the
239
+ * path is not capability-gated;
240
+ * - the path is capability-gated and the token's `cap` claim
241
+ * includes the required capability;
242
+ * - the token has `cap: ["read"]` (Issue #913 Gap 1 consumer
243
+ * scoped tokens) AND the path is in `READ_CAPABILITY_ROUTES`
244
+ * with the request method on the allow-list.
245
+ *
246
+ * Fail-closed semantics:
247
+ * - An explicit `cap` claim is a scope assertion. Even an empty
248
+ * array (`cap: []`) means "scoped to no capability" → reject every
249
+ * request, including admin routes (see harden report 2026-04-26).
250
+ * - A capability-scoped token CANNOT access non-capability-gated
251
+ * routes outside its explicit allow-list.
252
+ *
253
+ * `method` defaults to undefined for backwards compatibility — callers
254
+ * that don't pass it will fail-closed for the read-cap allow-list,
255
+ * which matches the old behavior (read-cap didn't exist).
256
+ */
257
+ function authorizeSidecarToken(payload, path, method) {
258
+ const required = requiredCapabilityForPath(path);
259
+ if (payload.cap !== void 0) {
260
+ if (payload.cap.includes("read") && isReadCapAllowed(path, method)) return true;
261
+ return required !== null && payload.cap.includes(required);
262
+ }
263
+ return required === null;
264
+ }
265
+ /**
266
+ * Generate an Ed25519 key pair for per-session JWT signing.
267
+ * Private key stays in orchestrator memory. Public key is injected into
268
+ * the sidecar container. Even if the sidecar is fully compromised,
269
+ * the attacker cannot forge JWTs for any container.
270
+ *
271
+ * @returns { privateKey, publicKey } as PEM strings
272
+ */
273
+ function generateSidecarKeyPair() {
274
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519", {
275
+ publicKeyEncoding: {
276
+ type: "spki",
277
+ format: "pem"
278
+ },
279
+ privateKeyEncoding: {
280
+ type: "pkcs8",
281
+ format: "pem"
282
+ }
283
+ });
284
+ return {
285
+ privateKey,
286
+ publicKey
287
+ };
288
+ }
289
+ /**
290
+ * Issue a sidecar access token (JWT) using Ed25519 signing.
291
+ * Scoped to a specific container via the required `cid` claim.
292
+ *
293
+ * @param privateKey - Ed25519 private key (PEM). If not provided, falls back to HMAC.
294
+ * @param hmacFallbackSecret - HMAC secret for backward compat (used when no Ed25519 key)
295
+ * @param payload - Token payload with container ID
296
+ * @param ttlMinutes - Token TTL in minutes
297
+ */
298
+ function issueSidecarAccessToken(privateKey, payload, ttlMinutes) {
299
+ if (!privateKey.includes("PRIVATE KEY")) throw new Error("issueSidecarAccessToken requires an Ed25519 private key (PEM). HMAC fallback has been removed — generate a key pair with generateSidecarKeyPair().");
300
+ const now = Math.floor(Date.now() / 1e3);
301
+ const jti = `${payload.cid}:${now}:${randomBytes(8).toString("hex")}`;
302
+ const fullPayload = {
303
+ ...payload,
304
+ typ: "sidecar",
305
+ jti,
306
+ iat: now,
307
+ exp: now + ttlMinutes * 60
308
+ };
309
+ const data = `${JWT_HEADER_EDDSA}.${base64UrlEncode(JSON.stringify(fullPayload))}`;
310
+ return `${data}.${base64UrlEncode(sign(null, Buffer.from(data), privateKey))}`;
311
+ }
312
+ /**
313
+ * Verify a sidecar JWT using Ed25519 public key. Rejects non-EdDSA tokens.
314
+ * Returns decoded payload on success, null on failure.
315
+ */
316
+ function verifySidecarToken(token, publicKey, containerId) {
317
+ try {
318
+ const parts = token.split(".");
319
+ if (parts.length !== 3) return null;
320
+ const headerJson = Buffer.from(parts[0], "base64url").toString("utf-8");
321
+ if (JSON.parse(headerJson).alg !== "EdDSA") return null;
322
+ const data = `${parts[0]}.${parts[1]}`;
323
+ const signatureBuffer = Buffer.from(parts[2], "base64url");
324
+ if (!verify(null, Buffer.from(data), publicKey, signatureBuffer)) return null;
325
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
326
+ if (payload.typ !== "sidecar") return null;
327
+ if (typeof payload.jti !== "string" || payload.jti.length === 0) return null;
328
+ const exactContainerMatch = payload.cid === containerId;
329
+ const shortDockerIdMatch = isDockerShortIdMatch(payload.cid, containerId);
330
+ if (!exactContainerMatch && !shortDockerIdMatch) return null;
331
+ const now = Math.floor(Date.now() / 1e3);
332
+ if (typeof payload.exp !== "number" || payload.exp < now) return null;
333
+ if (typeof payload.sub !== "string" || payload.sub.length === 0) return null;
334
+ return payload;
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+ function isDockerShortIdMatch(payloadCid, containerId) {
340
+ if (typeof payloadCid !== "string" || typeof containerId !== "string") return false;
341
+ if (!/^[a-f0-9]{64}$/i.test(payloadCid)) return false;
342
+ if (!/^[a-f0-9]{12}$/i.test(containerId)) return false;
343
+ return payloadCid.startsWith(containerId);
344
+ }
345
+ /**
346
+ * Check if a token payload is session-scoped.
347
+ * Session-scoped tokens have a sid claim and no projectId/projectIds.
348
+ */
349
+ function isSessionScopedToken(payload) {
350
+ return "sid" in payload && typeof payload.sid === "string" && payload.sid.length > 0 && !("projectId" in payload && payload.projectId) && !("projectIds" in payload && payload.projectIds);
351
+ }
352
+ /**
353
+ * Check if a token payload is project-scoped.
354
+ * Project-scoped tokens have a projectId claim and no sid/projectIds.
355
+ */
356
+ function isProjectScopedToken(payload) {
357
+ return "projectId" in payload && typeof payload.projectId === "string" && payload.projectId.length > 0 && !("sid" in payload && payload.sid) && !("projectIds" in payload && payload.projectIds);
358
+ }
359
+ /**
360
+ * Check if a token payload is batch-scoped.
361
+ * Batch-scoped tokens have a projectIds claim and no sid/projectId.
362
+ */
363
+ function isBatchScopedToken(payload) {
364
+ return "projectIds" in payload && Array.isArray(payload.projectIds) && payload.projectIds.length > 0 && !("sid" in payload && payload.sid) && !("projectId" in payload && payload.projectId);
365
+ }
366
+ /**
367
+ * Get the scope of a token payload.
368
+ * Returns null if the token has an invalid/ambiguous scope.
369
+ */
370
+ function getTokenScope(payload) {
371
+ if (isSessionScopedToken(payload)) return "session";
372
+ if (isProjectScopedToken(payload)) return "project";
373
+ if (isBatchScopedToken(payload)) return "batch";
374
+ return null;
375
+ }
376
+ /**
377
+ * Validate that a token payload has exactly one valid scope.
378
+ * Returns an error message if invalid, null if valid.
379
+ */
380
+ function validateTokenScope(payload) {
381
+ const scopeCount = [
382
+ "sid" in payload && payload.sid,
383
+ "projectId" in payload && payload.projectId,
384
+ "projectIds" in payload && Array.isArray(payload.projectIds) && payload.projectIds.length > 0
385
+ ].filter(Boolean).length;
386
+ if (scopeCount === 0) return "Token must have one of: sid (session), projectId (project), or projectIds (batch)";
387
+ if (scopeCount > 1) return "Token cannot have multiple scope claims (sid, projectId, projectIds are mutually exclusive)";
388
+ return null;
389
+ }
390
+ /**
391
+ * Decode a JWT without verification (to extract claims for lookup).
392
+ * Returns null if the token is malformed.
393
+ */
394
+ function decodeToken(token) {
395
+ try {
396
+ const parts = token.split(".");
397
+ if (parts.length !== 3) return null;
398
+ return JSON.parse(base64UrlDecode(parts[1]));
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+ /**
404
+ * Verify a read token against a product's signing secrets.
405
+ *
406
+ * @param token - The JWT token to verify
407
+ * @param product - Product auth info containing secrets
408
+ */
409
+ function verifyReadToken(token, product) {
410
+ if (product.status !== "active") return {
411
+ valid: false,
412
+ error: "Product is suspended",
413
+ errorCode: "PRODUCT_SUSPENDED"
414
+ };
415
+ const parts = token.split(".");
416
+ if (parts.length !== 3) return {
417
+ valid: false,
418
+ error: "Invalid token format",
419
+ errorCode: "TOKEN_INVALID"
420
+ };
421
+ const [header, encodedPayload, signature] = parts;
422
+ const data = `${header}.${encodedPayload}`;
423
+ if (header !== JWT_HEADER) {
424
+ let parsedAlg = null;
425
+ try {
426
+ const decoded = JSON.parse(base64UrlDecode(header));
427
+ if (typeof decoded.alg === "string") parsedAlg = decoded.alg;
428
+ } catch {}
429
+ if (parsedAlg !== "HS256") return {
430
+ valid: false,
431
+ error: "Unsupported token algorithm",
432
+ errorCode: "TOKEN_INVALID"
433
+ };
434
+ }
435
+ let signatureValid = verifySignature(data, signature, product.secrets.current.secret);
436
+ if (!signatureValid && product.secrets.previous) {
437
+ if (Date.now() < product.secrets.previous.expires_at) signatureValid = verifySignature(data, signature, product.secrets.previous.secret);
438
+ }
439
+ if (!signatureValid) return {
440
+ valid: false,
441
+ error: "Invalid signature",
442
+ errorCode: "SIGNATURE_INVALID"
443
+ };
444
+ let payload;
445
+ try {
446
+ payload = JSON.parse(base64UrlDecode(encodedPayload));
447
+ } catch {
448
+ return {
449
+ valid: false,
450
+ error: "Invalid token payload",
451
+ errorCode: "TOKEN_INVALID"
452
+ };
453
+ }
454
+ if (payload.typ !== "read") return {
455
+ valid: false,
456
+ error: "Not a read token",
457
+ errorCode: "TOKEN_WRONG_TYPE"
458
+ };
459
+ const now = Math.floor(Date.now() / 1e3);
460
+ if (payload.exp < now) return {
461
+ valid: false,
462
+ error: "Token expired",
463
+ errorCode: "TOKEN_EXPIRED"
464
+ };
465
+ if (payload.pid !== product.product_id) return {
466
+ valid: false,
467
+ error: "Product ID mismatch",
468
+ errorCode: "TOKEN_INVALID"
469
+ };
470
+ const scopeError = validateTokenScope(payload);
471
+ if (scopeError) return {
472
+ valid: false,
473
+ error: scopeError,
474
+ errorCode: "TOKEN_INVALID"
475
+ };
476
+ return {
477
+ valid: true,
478
+ payload,
479
+ scope: getTokenScope(payload) ?? void 0
480
+ };
481
+ }
482
+ /**
483
+ * Get time until token expires (in seconds).
484
+ * Returns negative if expired.
485
+ */
486
+ function getTokenTTL(payload) {
487
+ const now = Math.floor(Date.now() / 1e3);
488
+ return payload.exp - now;
489
+ }
490
+ /**
491
+ * Check if token is expiring soon (within buffer seconds).
492
+ */
493
+ function isTokenExpiringSoon(payload, bufferSeconds = 60) {
494
+ return getTokenTTL(payload) <= bufferSeconds;
495
+ }
496
+ /**
497
+ * Default salt for API key hashing (development only).
498
+ * Production MUST set API_KEY_HASH_SALT environment variable.
499
+ * WARNING: Changing the salt will invalidate all existing API key hashes.
500
+ */
501
+ const DEFAULT_API_KEY_SALT = "api_key_hash_salt";
502
+ let _saltWarned = false;
503
+ /**
504
+ * Get the API key hash salt from environment.
505
+ * Logs a warning if falling back to the weak default (development only).
506
+ */
507
+ function getApiKeyHashSalt() {
508
+ if (typeof process !== "undefined" && process.env?.API_KEY_HASH_SALT) return process.env.API_KEY_HASH_SALT;
509
+ if (!_saltWarned && typeof process !== "undefined" && process.env?.NODE_ENV === "production") {
510
+ _saltWarned = true;
511
+ console.error("[SECURITY] API_KEY_HASH_SALT not set in production. Using weak default salt. Set API_KEY_HASH_SALT to a cryptographically random value.");
512
+ }
513
+ return DEFAULT_API_KEY_SALT;
514
+ }
515
+ /**
516
+ * Hash an API key for storage/lookup.
517
+ * Uses HMAC-SHA256 for consistent, fast hashing.
518
+ *
519
+ * The salt can be configured via API_KEY_HASH_SALT environment variable.
520
+ * WARNING: Changing the salt will invalidate all existing API key hashes.
521
+ */
522
+ function hashApiKey(apiKey) {
523
+ return createHmac("sha256", getApiKeyHashSalt()).update(apiKey).digest("hex");
524
+ }
525
+ /**
526
+ * Hash an API key with an explicit salt.
527
+ * Use this when you need to verify against a specific salt.
528
+ */
529
+ function hashApiKeyWithSalt(apiKey, salt) {
530
+ return createHmac("sha256", salt).update(apiKey).digest("hex");
531
+ }
532
+ /**
533
+ * Verify an API key using timing-safe comparison.
534
+ */
535
+ function verifyApiKey(provided, stored) {
536
+ if (provided.length !== stored.length) return false;
537
+ try {
538
+ return timingSafeEqual(Buffer.from(provided), Buffer.from(stored));
539
+ } catch {
540
+ return false;
541
+ }
542
+ }
543
+ //#endregion
544
+ //#region src/auth/channel-access.ts
545
+ /**
546
+ * Channel Access Control
547
+ *
548
+ * Determines what channels a token can access based on its scope.
549
+ * Used by SessionGateway to validate subscriptions and event delivery.
550
+ *
551
+ * Channel patterns:
552
+ * - session:{sessionId} - Events for a specific session
553
+ * - agent:{sessionId} - Agent events for a specific session
554
+ * - project:{projectId} - All events for a project
555
+ * - system - System-wide events (heartbeat, etc.)
556
+ */
557
+ /**
558
+ * Extract session ID from a session channel pattern.
559
+ * Returns null if not a session channel.
560
+ */
561
+ function extractSessionFromChannel(channel) {
562
+ if (channel.startsWith("session:")) return channel.slice(8);
563
+ if (channel.startsWith("agent:")) return channel.slice(6);
564
+ return null;
565
+ }
566
+ /**
567
+ * Extract project ID from a project channel pattern.
568
+ * Returns null if not a project channel.
569
+ */
570
+ function extractProjectFromChannel(channel) {
571
+ if (channel.startsWith("project:")) return channel.slice(8);
572
+ return null;
573
+ }
574
+ /**
575
+ * Check if a session-scoped token can access a channel.
576
+ *
577
+ * Session tokens can access:
578
+ * - session:{their-sid}
579
+ * - agent:{their-sid}
580
+ * - system (always allowed)
581
+ * - Wildcard patterns matching their session
582
+ */
583
+ function canSessionTokenAccessChannel(payload, channel) {
584
+ if (channel === "system" || channel.startsWith("system.")) return { allowed: true };
585
+ if (channel === "*") return {
586
+ allowed: false,
587
+ reason: "Session-scoped tokens cannot subscribe to all channels"
588
+ };
589
+ const sessionId = extractSessionFromChannel(channel);
590
+ if (sessionId !== null) {
591
+ if (sessionId === payload.sid) return { allowed: true };
592
+ if (sessionId === "*") return {
593
+ allowed: false,
594
+ reason: "Session-scoped tokens cannot subscribe to all sessions"
595
+ };
596
+ return {
597
+ allowed: false,
598
+ reason: `Token only has access to session ${payload.sid}`
599
+ };
600
+ }
601
+ if (channel.startsWith("project:")) return {
602
+ allowed: false,
603
+ reason: "Session-scoped tokens cannot access project channels"
604
+ };
605
+ return { allowed: true };
606
+ }
607
+ /**
608
+ * Check if a project-scoped token can access a channel.
609
+ *
610
+ * Project tokens can access:
611
+ * - project:{their-projectId}
612
+ * - session:* (all sessions within project - requires validation by caller)
613
+ * - agent:* (all agent events within project)
614
+ * - system (always allowed)
615
+ */
616
+ function canProjectTokenAccessChannel(payload, channel) {
617
+ if (channel === "system" || channel.startsWith("system.")) return { allowed: true };
618
+ if (channel === "*") return {
619
+ allowed: false,
620
+ reason: "Project-scoped tokens cannot subscribe to all channels across projects"
621
+ };
622
+ const projectId = extractProjectFromChannel(channel);
623
+ if (projectId !== null) {
624
+ if (projectId === payload.projectId) return { allowed: true };
625
+ return {
626
+ allowed: false,
627
+ reason: `Token only has access to project ${payload.projectId}`
628
+ };
629
+ }
630
+ if (channel.startsWith("session:") || channel.startsWith("agent:")) {
631
+ if (extractSessionFromChannel(channel) === "*") return { allowed: true };
632
+ return { allowed: true };
633
+ }
634
+ return { allowed: true };
635
+ }
636
+ /**
637
+ * Check if a batch-scoped token can access a channel.
638
+ *
639
+ * Batch tokens can access:
640
+ * - project:{any of their projectIds}
641
+ * - session:* (all sessions within their projects)
642
+ * - agent:* (all agent events within their projects)
643
+ * - system (always allowed)
644
+ * - * (global wildcard - for organization-wide subscriptions)
645
+ */
646
+ function canBatchTokenAccessChannel(payload, channel) {
647
+ if (channel === "system" || channel.startsWith("system.")) return { allowed: true };
648
+ if (channel === "*") return { allowed: true };
649
+ const projectId = extractProjectFromChannel(channel);
650
+ if (projectId !== null) {
651
+ if (projectId === "*") return { allowed: true };
652
+ if (payload.projectIds.includes(projectId)) return { allowed: true };
653
+ return {
654
+ allowed: false,
655
+ reason: `Token does not have access to project ${projectId}`
656
+ };
657
+ }
658
+ if (channel.startsWith("session:") || channel.startsWith("agent:")) return { allowed: true };
659
+ return { allowed: true };
660
+ }
661
+ /**
662
+ * Check if a token can access a channel.
663
+ * Dispatches to scope-specific functions based on token type.
664
+ *
665
+ * @param payload - The token payload
666
+ * @param channel - The channel to check access for
667
+ * @returns Access result with allowed boolean and optional reason
668
+ */
669
+ function canTokenAccessChannel(payload, channel) {
670
+ if (isSessionScopedToken(payload)) return canSessionTokenAccessChannel(payload, channel);
671
+ if (isProjectScopedToken(payload)) return canProjectTokenAccessChannel(payload, channel);
672
+ if (isBatchScopedToken(payload)) return canBatchTokenAccessChannel(payload, channel);
673
+ return {
674
+ allowed: false,
675
+ reason: "Token has no valid scope (missing sid, projectId, or projectIds)"
676
+ };
677
+ }
678
+ /**
679
+ * Get the list of allowed channel patterns for a token.
680
+ * Useful for informing clients what they can subscribe to.
681
+ *
682
+ * @param payload - The token payload
683
+ * @returns Array of allowed channel patterns
684
+ */
685
+ function getAllowedChannelPatterns(payload) {
686
+ const patterns = ["system", "system.*"];
687
+ if (isSessionScopedToken(payload)) {
688
+ patterns.push(`session:${payload.sid}`, `agent:${payload.sid}`);
689
+ return patterns;
690
+ }
691
+ if (isProjectScopedToken(payload)) {
692
+ patterns.push(`project:${payload.projectId}`, "session:*", "agent:*");
693
+ return patterns;
694
+ }
695
+ if (isBatchScopedToken(payload)) {
696
+ patterns.push("*", "project:*", ...payload.projectIds.map((id) => `project:${id}`), "session:*", "agent:*");
697
+ return patterns;
698
+ }
699
+ return patterns;
700
+ }
701
+ /**
702
+ * Validate channel subscription request.
703
+ * Checks multiple channels and returns aggregated result.
704
+ *
705
+ * @param payload - The token payload
706
+ * @param channels - Channels to validate
707
+ * @returns Object with allowed channels and denied channels with reasons
708
+ */
709
+ function validateChannelSubscription(payload, channels) {
710
+ const allowed = [];
711
+ const denied = [];
712
+ for (const channel of channels) {
713
+ const result = canTokenAccessChannel(payload, channel);
714
+ if (result.allowed) allowed.push(channel);
715
+ else denied.push({
716
+ channel,
717
+ reason: result.reason ?? "Access denied"
718
+ });
719
+ }
720
+ return {
721
+ allowed,
722
+ denied
723
+ };
724
+ }
725
+ //#endregion
726
+ //#region src/auth/index.ts
727
+ /**
728
+ * Authentication Configuration
729
+ *
730
+ * Token management with refresh hooks and expiration handling.
731
+ */
732
+ /**
733
+ * Environment-based auth that reads from env vars.
734
+ * Matches the server-side patterns used in sidecar/orchestrator.
735
+ */
736
+ var EnvTokenAuth = class {
737
+ envVar;
738
+ fallback;
739
+ constructor(options) {
740
+ this.envVar = options.envVar;
741
+ this.fallback = options.fallback;
742
+ }
743
+ async getToken() {
744
+ if (typeof process !== "undefined" && process.env) return process.env[this.envVar] ?? this.fallback ?? null;
745
+ return this.fallback ?? null;
746
+ }
747
+ };
748
+ /**
749
+ * Pre-configured auth for sidecar connections.
750
+ * Uses SIDECAR_AUTH_TOKEN env var (matches server expectation).
751
+ */
752
+ var SidecarAuth = class extends EnvTokenAuth {
753
+ constructor(token) {
754
+ super({
755
+ envVar: "SIDECAR_AUTH_TOKEN",
756
+ fallback: token
757
+ });
758
+ }
759
+ };
760
+ /**
761
+ * Product-based auth for multi-tenant orchestrator connections.
762
+ * Uses ORCHESTRATOR_API_SECRET_KEY env var.
763
+ */
764
+ var ProductAuth = class extends EnvTokenAuth {
765
+ constructor(apiKey) {
766
+ super({
767
+ envVar: "ORCHESTRATOR_API_SECRET_KEY",
768
+ fallback: apiKey
769
+ });
770
+ }
771
+ };
772
+ /**
773
+ * Product token issuer for backend services.
774
+ *
775
+ * Use this in product backends to issue read tokens for WebSocket connections.
776
+ *
777
+ * @example
778
+ * ```typescript
779
+ * import { ProductTokenIssuer } from "@tangle-network/agent-core";
780
+ *
781
+ * const issuer = new ProductTokenIssuer({
782
+ * productId: "vibecode",
783
+ * signingSecret: process.env.ORCHESTRATOR_SIGNING_SECRET!,
784
+ * ttlMinutes: { free: 15, pro: 240 },
785
+ * });
786
+ *
787
+ * // Issue a token for a user session
788
+ * const token = issuer.issue({
789
+ * userId: "user_123",
790
+ * sessionId: "sess_abc",
791
+ * tier: "pro",
792
+ * });
793
+ *
794
+ * // Return token to frontend for WebSocket connection
795
+ * res.json({
796
+ * readToken: token.token,
797
+ * expiresAt: token.expiresAt,
798
+ * websocketUrl: `wss://orchestrator.example.com/session?token=${token.token}`,
799
+ * });
800
+ * ```
801
+ */
802
+ var ProductTokenIssuer = class {
803
+ productId;
804
+ signingSecret;
805
+ ttlMinutes;
806
+ constructor(config) {
807
+ this.productId = config.productId;
808
+ this.signingSecret = config.signingSecret;
809
+ this.ttlMinutes = {
810
+ free: config.ttlMinutes?.free ?? 15,
811
+ pro: config.ttlMinutes?.pro ?? 240,
812
+ enterprise: config.ttlMinutes?.enterprise ?? 480
813
+ };
814
+ }
815
+ /**
816
+ * Issue a read token for a user session.
817
+ */
818
+ issue(params) {
819
+ const tier = params.tier ?? "free";
820
+ const ttl = this.ttlMinutes[tier] ?? this.ttlMinutes.free;
821
+ return {
822
+ token: issueReadToken(this.signingSecret, {
823
+ sub: params.userId,
824
+ sid: params.sessionId,
825
+ pid: this.productId,
826
+ cid: params.sidecarId
827
+ }, ttl),
828
+ expiresAt: Math.floor(Date.now() / 1e3) + ttl * 60
829
+ };
830
+ }
831
+ /**
832
+ * Get the TTL in minutes for a tier.
833
+ */
834
+ getTtlMinutes(tier = "free") {
835
+ return this.ttlMinutes[tier] ?? this.ttlMinutes.free;
836
+ }
837
+ };
838
+ //#endregion
839
+ export { BatchScopedTokenPayloadSchema, CallbackAuth, CreateSessionRequestSchema, EnvTokenAuth, NoAuth, ProductAuth, ProductSchema, ProductTokenIssuer, ProjectScopedTokenPayloadSchema, READ_CAPABILITY_ROUTES, ReadTokenAuth, ReadTokenPayloadSchema, RefreshableTokenAuth, SIDECAR_CAPABILITY_ROUTES, SIDECAR_CAPABILITY_ROUTE_PATTERNS, SendMessageRequestSchema, SessionScopedTokenPayloadSchema, SidecarAuth, StaticTokenAuth, authorizeSidecarToken, canBatchTokenAccessChannel, canProjectTokenAccessChannel, canSessionTokenAccessChannel, canTokenAccessChannel, decodeToken, extractProjectFromChannel, extractSessionFromChannel, generateApiKey, generateSecureToken, generateSidecarKeyPair, generateSigningSecret, getAllowedChannelPatterns, getTokenScope, getTokenTTL, hashApiKey, hashApiKeyWithSalt, isBatchScopedToken, isProjectScopedToken, isSessionScopedToken, isTokenExpiringSoon, issueBatchScopedToken, issueProjectScopedToken, issueReadToken, issueSessionScopedToken, issueSidecarAccessToken, requiredCapabilityForPath, validateChannelSubscription, validateTokenScope, verifyApiKey, verifyReadToken, verifySidecarToken };
840
+
841
+ //# sourceMappingURL=index.js.map