@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.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/auth/browser.d.ts +22 -0
- package/dist/auth/browser.d.ts.map +1 -0
- package/dist/auth/browser.js +43 -0
- package/dist/auth/browser.js.map +1 -0
- package/dist/auth/index.d.ts +430 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +841 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/computer-use/index.d.ts +122 -0
- package/dist/computer-use/index.d.ts.map +1 -0
- package/dist/computer-use/index.js +422 -0
- package/dist/computer-use/index.js.map +1 -0
- package/dist/errors-DJ6U6cHY.js +146 -0
- package/dist/errors-DJ6U6cHY.js.map +1 -0
- package/dist/events/index.d.ts +2 -0
- package/dist/events/index.js +2 -0
- package/dist/events-CUWh9qrv.js +263 -0
- package/dist/events-CUWh9qrv.js.map +1 -0
- package/dist/index-CFA8kgHJ.d.ts +352 -0
- package/dist/index-CFA8kgHJ.d.ts.map +1 -0
- package/dist/index-CtebBodu.d.ts +108 -0
- package/dist/index-CtebBodu.d.ts.map +1 -0
- package/dist/index-DiQX9bjq.d.ts +55 -0
- package/dist/index-DiQX9bjq.d.ts.map +1 -0
- package/dist/index-DlPznmKA.d.ts +158 -0
- package/dist/index-DlPznmKA.d.ts.map +1 -0
- package/dist/index-z2wPdzFb.d.ts +133 -0
- package/dist/index-z2wPdzFb.d.ts.map +1 -0
- package/dist/index.d.ts +565 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +822 -0
- package/dist/index.js.map +1 -0
- package/dist/resilience/index.d.ts +2 -0
- package/dist/resilience/index.js +2 -0
- package/dist/resilience-BQzDfw_C.js +221 -0
- package/dist/resilience-BQzDfw_C.js.map +1 -0
- package/dist/sse/index.d.ts +174 -0
- package/dist/sse/index.d.ts.map +1 -0
- package/dist/sse/index.js +303 -0
- package/dist/sse/index.js.map +1 -0
- package/dist/transport/index.d.ts +2 -0
- package/dist/transport/index.js +2 -0
- package/dist/transport-mKLoQep5.js +427 -0
- package/dist/transport-mKLoQep5.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types-D5VLaol0.js +31 -0
- package/dist/types-D5VLaol0.js.map +1 -0
- package/dist/types-DC6OJ1um.js +331 -0
- package/dist/types-DC6OJ1um.js.map +1 -0
- package/dist/types-DiDSGM8K.d.ts +320 -0
- package/dist/types-DiDSGM8K.d.ts.map +1 -0
- 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
|