@rstreamlabs/tunnels 1.0.7 → 1.0.8

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/dist/index.mjs CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  tunnelSchema,
17
17
  webhookEventsSchema,
18
18
  wsEventsSchema
19
- } from "./chunk-OPJKEDVS.mjs";
19
+ } from "./chunk-7RZ743EF.mjs";
20
20
 
21
21
  // src/auth-resource.ts
22
22
  import { createClientCredentialsToken } from "@rstreamlabs/rstream";
@@ -25,9 +25,9 @@ import { createClientCredentialsToken } from "@rstreamlabs/rstream";
25
25
  import * as z from "zod";
26
26
  var StringFilter = z.union([
27
27
  z.string(),
28
- z.object({ exact: z.string() }),
29
- z.object({ oneof: z.array(z.string()) }),
30
- z.object({ regex: z.string() })
28
+ z.object({ exact: z.string() }).strict(),
29
+ z.object({ oneof: z.array(z.string()) }).strict(),
30
+ z.object({ regex: z.string() }).strict()
31
31
  ]);
32
32
  function transform(field) {
33
33
  if (field instanceof z.ZodOptional) {
@@ -61,7 +61,7 @@ function select(base) {
61
61
  return [key, z.boolean().optional()];
62
62
  });
63
63
  const shape = Object.fromEntries(entries);
64
- return z.object(shape);
64
+ return z.object(shape).strict();
65
65
  }
66
66
 
67
67
  // src/auth.ts
@@ -88,7 +88,7 @@ var authTokenTunnelsScopesSchema = z2.object({
88
88
  creation_date: true
89
89
  })
90
90
  ).optional()
91
- })
91
+ }).strict()
92
92
  ]).optional(),
93
93
  // Scopes for connecting to tunnels
94
94
  connect: z2.union([
@@ -100,7 +100,7 @@ var authTokenTunnelsScopesSchema = z2.object({
100
100
  path: z2.string().optional()
101
101
  })
102
102
  ).optional()
103
- })
103
+ }).strict()
104
104
  ]).optional(),
105
105
  // Scopes for listing tunnels
106
106
  list: z2.union([
@@ -108,17 +108,32 @@ var authTokenTunnelsScopesSchema = z2.object({
108
108
  z2.object({
109
109
  filters: filters(tunnelSchema).optional(),
110
110
  select: select(tunnelSchema).optional()
111
- })
111
+ }).strict()
112
112
  ]).optional()
113
+ }).strict().superRefine((scopes, ctx) => {
114
+ if (scopes.create === void 0 && scopes.connect === void 0 && scopes.list === void 0) {
115
+ ctx.addIssue({
116
+ code: z2.ZodIssueCode.custom,
117
+ message: "At least one tunnel scope action is required."
118
+ });
119
+ }
113
120
  });
114
121
  var authTokenScopesSchema = z2.object({
115
122
  tunnels: authTokenTunnelsScopesSchema.optional()
116
123
  // Scopes related to tunnels
124
+ }).strict().superRefine((scopes, ctx) => {
125
+ if (scopes.tunnels === void 0) {
126
+ ctx.addIssue({
127
+ code: z2.ZodIssueCode.custom,
128
+ message: "Tunnel scopes are required.",
129
+ path: ["tunnels"]
130
+ });
131
+ }
117
132
  });
118
133
  var authTokenTunnelGrantSchema = z2.object({
119
134
  workspaces: z2.array(nonEmptyStringSchema).min(1).optional(),
120
135
  projects: z2.array(nonEmptyStringSchema).min(1).optional(),
121
- scopes: authTokenScopesSchema.optional()
136
+ scopes: authTokenScopesSchema
122
137
  }).strict().superRefine((grant, ctx) => {
123
138
  if (grant.workspaces !== void 0 && grant.projects !== void 0) {
124
139
  ctx.addIssue({
@@ -127,6 +142,13 @@ var authTokenTunnelGrantSchema = z2.object({
127
142
  path: ["projects"]
128
143
  });
129
144
  }
145
+ if (grant.workspaces === void 0 && grant.projects === void 0) {
146
+ ctx.addIssue({
147
+ code: z2.ZodIssueCode.custom,
148
+ message: "A grant must target workspaces or projects.",
149
+ path: ["workspaces"]
150
+ });
151
+ }
130
152
  });
131
153
  var authTokenCommonSchema = {
132
154
  aud: z2.union([z2.string(), z2.array(z2.string())]).optional(),
@@ -162,10 +184,9 @@ var authTokenSchema = z2.union([
162
184
  ]);
163
185
  var tokenSchema = authTokenSchema;
164
186
  var createAuthTokenParamsSchema = z2.object({
165
- expires_in: z2.number().default(60),
166
- // 1 minute
187
+ expires_in: z2.number().int().min(1).max(3600).default(60),
167
188
  scopes: authTokenScopesSchema.optional(),
168
- tunnelsGrants: z2.array(authTokenTunnelGrantSchema).optional(),
189
+ tunnelsGrants: z2.array(authTokenTunnelGrantSchema).min(1).optional(),
169
190
  metadata: z2.unknown().optional()
170
191
  // Additional metadata
171
192
  }).strict().superRefine((params, ctx) => {
@@ -176,6 +197,13 @@ var createAuthTokenParamsSchema = z2.object({
176
197
  path: ["tunnelsGrants"]
177
198
  });
178
199
  }
200
+ if (params.scopes === void 0 && params.tunnelsGrants === void 0) {
201
+ ctx.addIssue({
202
+ code: z2.ZodIssueCode.custom,
203
+ message: "Explicit scopes or tunnelsGrants are required.",
204
+ path: ["scopes"]
205
+ });
206
+ }
179
207
  });
180
208
  var createAuthTokenResponseSchema = z2.object({
181
209
  token: z2.string()
@@ -202,12 +230,12 @@ var RstreamAuthResource = class {
202
230
  }
203
231
  };
204
232
  function createAuthTokenFromClientCredentials(credentials, params, options) {
205
- const tokenParams = createAuthTokenParamsSchema.parse(params ?? {});
233
+ const tokenParams = createAuthTokenParamsSchema.parse(params);
206
234
  const payload = {
207
235
  metadata: {
208
236
  engine: options?.engine
209
237
  },
210
- tunnelsGrants: normalizeAuthTokenTunnelGrants(tokenParams),
238
+ tunnelsGrants: normalizeAuthTokenTunnelGrants(tokenParams, options),
211
239
  permissions: null
212
240
  };
213
241
  const { token } = createClientCredentialsToken(credentials, {
@@ -216,21 +244,45 @@ function createAuthTokenFromClientCredentials(credentials, params, options) {
216
244
  });
217
245
  return { token };
218
246
  }
219
- function normalizeAuthTokenTunnelGrants(params) {
220
- if (!params) {
221
- return void 0;
222
- }
247
+ function normalizeAuthTokenTunnelGrants(params, options) {
223
248
  const tunnelsGrants = params.tunnelsGrants;
224
249
  if (tunnelsGrants !== void 0) {
225
250
  return [...tunnelsGrants];
226
251
  }
227
252
  if (params.scopes) {
228
- return [{ scopes: params.scopes }];
253
+ return [{ ...normalizeTunnelGrantTarget(options), scopes: params.scopes }];
229
254
  }
230
255
  return void 0;
231
256
  }
257
+ function normalizeTunnelGrantTarget(options) {
258
+ const projectId = normalizeOptionalString(options?.projectId);
259
+ const workspaceId = normalizeOptionalString(options?.workspaceId);
260
+ if (projectId !== void 0 && workspaceId !== void 0) {
261
+ throw new Error("Use either projectId or workspaceId, not both.");
262
+ }
263
+ if (projectId !== void 0) {
264
+ return { projects: [projectId] };
265
+ }
266
+ if (workspaceId !== void 0) {
267
+ return { workspaces: [workspaceId] };
268
+ }
269
+ throw new Error(
270
+ "Project ID or workspace ID is required when using scope-only tunnel grants."
271
+ );
272
+ }
273
+ function normalizeOptionalString(value) {
274
+ const normalized = value?.trim();
275
+ return normalized ? normalized : void 0;
276
+ }
232
277
 
233
278
  // src/clients-resource.ts
279
+ function clientPathId(id) {
280
+ const normalized = id.trim();
281
+ if (!normalized) {
282
+ throw new Error("Client ID is required.");
283
+ }
284
+ return encodeURIComponent(normalized);
285
+ }
234
286
  var RstreamClientsResource = class {
235
287
  client;
236
288
  constructor(client) {
@@ -244,9 +296,12 @@ var RstreamClientsResource = class {
244
296
  return listClientsResponseSchema.parse(response);
245
297
  }
246
298
  async get(id) {
247
- const response = await this.client.request(`/clients/${id}`, {
248
- method: "GET"
249
- });
299
+ const response = await this.client.request(
300
+ `/clients/${clientPathId(id)}`,
301
+ {
302
+ method: "GET"
303
+ }
304
+ );
250
305
  return clientSchema.parse(response);
251
306
  }
252
307
  };
@@ -257,22 +312,37 @@ import { getTunnelsProjectEngine } from "@rstreamlabs/rstream";
257
312
  import { isTokenCredentials } from "@rstreamlabs/rstream";
258
313
  import { readEnvironment } from "@rstreamlabs/rstream";
259
314
  import { RstreamClient } from "@rstreamlabs/rstream";
260
- import jwt from "jsonwebtoken";
261
315
  function trimOptionalString(value) {
262
316
  const normalized = value?.trim();
263
317
  return normalized ? normalized : void 0;
264
318
  }
265
- function getEngineFromToken(token) {
266
- if (!token) {
267
- return void 0;
319
+ var dnsLabelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
320
+ function validateHostname(hostname) {
321
+ if (!hostname || hostname.length > 253 || hostname.includes("..")) {
322
+ return false;
268
323
  }
269
- const parsed = authTokenSchema.safeParse(
270
- jwt.decode(token, { complete: false })
271
- );
272
- if (!parsed.success) {
273
- return void 0;
324
+ return hostname.split(".").every((label) => label === "localhost" || dnsLabelPattern.test(label));
325
+ }
326
+ function normalizeEngineAddress(engine) {
327
+ const normalized = engine.trim();
328
+ if (!normalized || /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized) || /[/?#@\\\s]/.test(normalized)) {
329
+ throw new Error("Engine must be a host[:port] value.");
330
+ }
331
+ const explicitPort = normalized.match(/:(\d+)$/)?.[1];
332
+ const url = new URL(`https://${normalized}`);
333
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
334
+ throw new Error("Engine must be a host[:port] value.");
335
+ }
336
+ if (!validateHostname(url.hostname)) {
337
+ throw new Error("Engine must use a valid hostname.");
338
+ }
339
+ if (explicitPort) {
340
+ const port = Number(explicitPort);
341
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
342
+ throw new Error("Engine port must be an integer between 1 and 65535.");
343
+ }
274
344
  }
275
- return trimOptionalString(parsed.data.metadata?.engine);
345
+ return `${url.hostname.toLowerCase()}${explicitPort ? `:${explicitPort}` : ""}`;
276
346
  }
277
347
  function resolveTunnelsAPIURL(apiUrl) {
278
348
  return trimOptionalString(apiUrl) ?? readEnvironment().apiUrl ?? DEFAULT_API_URL;
@@ -306,37 +376,22 @@ function resolveControlPlaneCredentials(controlPlaneCredentials, credentials, au
306
376
  async function resolveTunnelsEngine(config) {
307
377
  const explicitEngine = trimOptionalString(config.engine);
308
378
  if (explicitEngine) {
309
- return explicitEngine;
379
+ return normalizeEngineAddress(explicitEngine);
310
380
  }
311
381
  const environmentEngine = readEnvironment().engine;
312
382
  if (environmentEngine) {
313
- return environmentEngine;
383
+ return normalizeEngineAddress(environmentEngine);
314
384
  }
315
385
  const token = trimOptionalString(config.token) ?? (isTokenCredentials(config.credentials) ? config.credentials.token : void 0);
316
- const tokenEngine = getEngineFromToken(token);
317
- if (tokenEngine) {
318
- return tokenEngine;
319
- }
320
386
  const projectEndpoint = trimOptionalString(config.projectEndpoint);
321
387
  if (projectEndpoint) {
322
- const credentials = resolveControlPlaneCredentials(
323
- config.controlPlaneCredentials,
324
- config.credentials,
388
+ const project = await resolveManagedTunnelsProject({
389
+ ...config,
325
390
  token
326
- );
327
- if (credentials === void 0) {
328
- throw new Error(
329
- "Control-plane credentials are required to resolve a managed project endpoint."
330
- );
331
- }
332
- const controlPlane = new RstreamClient({
333
- apiUrl: resolveTunnelsAPIURL(config.apiUrl),
334
- credentials
335
391
  });
336
- const project = await controlPlane.tunnels.projects.resolveByEndpoint(projectEndpoint);
337
392
  const engine = getTunnelsProjectEngine(project);
338
393
  if (engine) {
339
- return engine;
394
+ return normalizeEngineAddress(engine);
340
395
  }
341
396
  throw new Error(
342
397
  "Failed to resolve the engine address from the managed tunnels project."
@@ -346,8 +401,36 @@ async function resolveTunnelsEngine(config) {
346
401
  "Engine URL is not defined. Provide an engine, use RSTREAM_ENGINE, or configure a managed project endpoint with control-plane access."
347
402
  );
348
403
  }
404
+ async function resolveManagedTunnelsProject(config) {
405
+ const projectEndpoint = trimOptionalString(config.projectEndpoint);
406
+ if (!projectEndpoint) {
407
+ throw new Error("Managed project endpoint is required.");
408
+ }
409
+ const credentials = resolveControlPlaneCredentials(
410
+ config.controlPlaneCredentials,
411
+ config.credentials,
412
+ config.token
413
+ );
414
+ if (credentials === void 0) {
415
+ throw new Error(
416
+ "Control-plane credentials are required to resolve a managed project endpoint."
417
+ );
418
+ }
419
+ const controlPlane = new RstreamClient({
420
+ apiUrl: resolveTunnelsAPIURL(config.apiUrl),
421
+ credentials
422
+ });
423
+ return await controlPlane.tunnels.projects.resolveByEndpoint(projectEndpoint);
424
+ }
349
425
 
350
426
  // src/tunnels-resource.ts
427
+ function tunnelPathId(id) {
428
+ const normalized = id.trim();
429
+ if (!normalized) {
430
+ throw new Error("Tunnel ID is required.");
431
+ }
432
+ return encodeURIComponent(normalized);
433
+ }
351
434
  var RstreamTunnelsResource = class {
352
435
  client;
353
436
  constructor(client) {
@@ -361,15 +444,20 @@ var RstreamTunnelsResource = class {
361
444
  return listTunnelsResponseSchema.parse(response);
362
445
  }
363
446
  async get(id) {
364
- const response = await this.client.request(`/tunnels/${id}`, {
365
- method: "GET"
366
- });
447
+ const response = await this.client.request(
448
+ `/tunnels/${tunnelPathId(id)}`,
449
+ {
450
+ method: "GET"
451
+ }
452
+ );
367
453
  return tunnelSchema.parse(response);
368
454
  }
369
455
  };
370
456
 
371
457
  // src/tunnels.ts
458
+ import { getTunnelsProjectEngine as getTunnelsProjectEngine2 } from "@rstreamlabs/rstream";
372
459
  import { isClientCredentials as isClientCredentials3 } from "@rstreamlabs/rstream";
460
+ import { readEnvironment as readEnvironment2 } from "@rstreamlabs/rstream";
373
461
 
374
462
  // src/turn.ts
375
463
  import { isClientCredentials as isClientCredentials2 } from "@rstreamlabs/rstream";
@@ -380,46 +468,52 @@ import * as z3 from "zod";
380
468
  import crypto from "crypto";
381
469
  var defaultTURNPort = 3478;
382
470
  var defaultTURNSPort = 5349;
383
- var defaultTURNCredentialTTLSeconds = 24 * 60 * 60;
471
+ var defaultTURNCredentialTTLSeconds = 10 * 60;
472
+ var maxTURNCredentialTTLSeconds = 60 * 60;
384
473
  var turnTokenClaimsSchema = z3.object({
385
- token_endpoint: z3.string().optional(),
386
- type: z3.string()
474
+ exp: z3.number().int().optional(),
475
+ iat: z3.number().int().optional(),
476
+ token_endpoint: z3.string().regex(/^[0-9a-f]{8}$/i).optional(),
477
+ type: z3.enum(["app", "auth", "pat"])
387
478
  });
388
479
  var turnCredentialModeSchema = z3.enum(["api", "app", "pat"]);
389
- function normalizeOptionalString(value) {
480
+ function normalizeOptionalString2(value) {
390
481
  const normalized = value?.trim();
391
482
  return normalized ? normalized : void 0;
392
483
  }
393
484
  function normalizeClusterDomain(clusterDomain) {
394
- const normalized = normalizeOptionalString(clusterDomain);
485
+ const normalized = normalizeOptionalString2(clusterDomain);
395
486
  return normalized?.toLowerCase();
396
487
  }
397
488
  function normalizeNow(now) {
398
- if (now instanceof Date) {
399
- return Math.floor(now.getTime() / 1e3);
489
+ if (now === void 0) {
490
+ return Math.floor(Date.now() / 1e3);
400
491
  }
401
- if (typeof now === "number" && Number.isFinite(now)) {
402
- return Math.floor(now);
492
+ const timestamp = now instanceof Date ? now.getTime() / 1e3 : now;
493
+ if (!Number.isFinite(timestamp)) {
494
+ throw new Error("TURN timestamp must be finite.");
403
495
  }
404
- return Math.floor(Date.now() / 1e3);
496
+ return Math.floor(timestamp);
405
497
  }
406
498
  function normalizeTTLSeconds(ttlSeconds) {
407
- if (typeof ttlSeconds === "number" && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
408
- return Math.floor(ttlSeconds);
499
+ const ttl = ttlSeconds ?? defaultTURNCredentialTTLSeconds;
500
+ if (!Number.isInteger(ttl) || ttl < 1 || ttl > maxTURNCredentialTTLSeconds) {
501
+ throw new Error("TURN TTL must be an integer between 1 and 3600 seconds.");
409
502
  }
410
- return defaultTURNCredentialTTLSeconds;
503
+ return ttl;
411
504
  }
412
- function normalizeTURNPort(port) {
413
- if (typeof port === "number" && Number.isFinite(port) && port > 0) {
414
- return Math.floor(port);
505
+ function normalizePort(port, fallback, name) {
506
+ const normalized = port ?? fallback;
507
+ if (!Number.isInteger(normalized) || normalized < 1 || normalized > 65535) {
508
+ throw new Error(`${name} must be an integer between 1 and 65535.`);
415
509
  }
416
- return defaultTURNPort;
510
+ return normalized;
511
+ }
512
+ function normalizeTURNPort(port) {
513
+ return normalizePort(port, defaultTURNPort, "TURN port");
417
514
  }
418
515
  function normalizeTURNSPort(port) {
419
- if (typeof port === "number" && Number.isFinite(port) && port > 0) {
420
- return Math.floor(port);
421
- }
422
- return defaultTURNSPort;
516
+ return normalizePort(port, defaultTURNSPort, "TURNS port");
423
517
  }
424
518
  function createTURNURLs(clusterDomain, turnPort, turnsPort) {
425
519
  const normalizedTURNPort = normalizeTURNPort(turnPort);
@@ -437,16 +531,32 @@ function parseTURNTokenClaims(token) {
437
531
  if (!payloadPart) {
438
532
  throw new Error("Invalid token format.");
439
533
  }
440
- const payload = Buffer.from(payloadPart, "base64url").toString("utf8");
441
- const parsed = turnTokenClaimsSchema.safeParse(JSON.parse(payload));
534
+ let decoded;
535
+ try {
536
+ const payload = Buffer.from(payloadPart, "base64url").toString("utf8");
537
+ decoded = JSON.parse(payload);
538
+ } catch {
539
+ throw new Error("Invalid token format.");
540
+ }
541
+ const parsed = turnTokenClaimsSchema.safeParse(decoded);
442
542
  if (!parsed.success) {
443
543
  throw new Error("Invalid token format.");
444
544
  }
445
545
  return parsed.data;
446
546
  }
547
+ function requirePATExpiration(claims, now) {
548
+ const exp = claims.exp;
549
+ if (typeof exp !== "number") {
550
+ throw new Error("TURN PAT mode requires a PAT token with expiration.");
551
+ }
552
+ if (exp <= now) {
553
+ throw new Error("TURN PAT mode requires a non-expired PAT token.");
554
+ }
555
+ return exp;
556
+ }
447
557
  function deriveClusterDomainFromEngine(projectEndpoint, engine) {
448
- const normalizedProjectEndpoint = normalizeOptionalString(projectEndpoint);
449
- const normalizedEngine = normalizeOptionalString(engine);
558
+ const normalizedProjectEndpoint = normalizeOptionalString2(projectEndpoint);
559
+ const normalizedEngine = normalizeOptionalString2(engine);
450
560
  if (!normalizedProjectEndpoint || !normalizedEngine) {
451
561
  throw new Error(
452
562
  "Failed to derive the cluster domain from the engine address."
@@ -480,17 +590,11 @@ function resolveMode(credentials, mode) {
480
590
  if (isClientCredentials2(credentials)) {
481
591
  return "app";
482
592
  }
483
- if (isTokenCredentials2(credentials)) {
484
- const claims = parseTURNTokenClaims(credentials.token);
485
- if (claims.type === "pat" && normalizeOptionalString(claims.token_endpoint)) {
486
- return "pat";
487
- }
488
- }
489
593
  return "api";
490
594
  }
491
595
  async function loadTURNServerPublicKeyHex(clusterDomain, options) {
492
- const configuredKey = normalizeOptionalString(options.keyringBaseUrl);
493
- const keyringBaseURL = configuredKey ?? normalizeOptionalString(options.apiUrl) ?? "https://rstream.io";
596
+ const configuredKey = normalizeOptionalString2(options.keyringBaseUrl);
597
+ const keyringBaseURL = configuredKey ?? normalizeOptionalString2(options.apiUrl) ?? "https://rstream.io";
494
598
  const url = new URL(
495
599
  `/keyrings/turn/${clusterDomain}.spki.der.hex`,
496
600
  keyringBaseURL
@@ -502,8 +606,8 @@ async function loadTURNServerPublicKeyHex(clusterDomain, options) {
502
606
  return (await response.text()).trim();
503
607
  }
504
608
  async function createAPITURNCredentials(options) {
505
- const projectId = normalizeOptionalString(options.projectId);
506
- const projectEndpoint = normalizeOptionalString(options.projectEndpoint);
609
+ const projectId = normalizeOptionalString2(options.projectId);
610
+ const projectEndpoint = normalizeOptionalString2(options.projectEndpoint);
507
611
  if (!projectId && !projectEndpoint) {
508
612
  throw new Error(
509
613
  "Project ID or project endpoint is required for TURN API mode."
@@ -530,11 +634,11 @@ function createPATTURNCredentials(options) {
530
634
  if (!clusterDomain) {
531
635
  throw new Error("Cluster domain is required for TURN PAT mode.");
532
636
  }
533
- const projectEndpoint = normalizeOptionalString(options.projectEndpoint);
637
+ const projectEndpoint = normalizeOptionalString2(options.projectEndpoint);
534
638
  if (!projectEndpoint) {
535
639
  throw new Error("Project endpoint is required for TURN PAT mode.");
536
640
  }
537
- const token = normalizeOptionalString(options.token);
641
+ const token = normalizeOptionalString2(options.token);
538
642
  if (!token) {
539
643
  throw new Error("Token is required for TURN PAT mode.");
540
644
  }
@@ -542,14 +646,17 @@ function createPATTURNCredentials(options) {
542
646
  if (claims.type !== "pat") {
543
647
  throw new Error("TURN PAT mode requires a PAT token.");
544
648
  }
545
- const tokenEndpoint = normalizeOptionalString(claims.token_endpoint);
649
+ const tokenEndpoint = normalizeOptionalString2(options.tokenEndpoint);
546
650
  if (!tokenEndpoint) {
547
- throw new Error(
548
- "TURN PAT mode requires a PAT token carrying a token endpoint."
549
- );
651
+ throw new Error("Token endpoint is required for TURN PAT mode.");
652
+ }
653
+ if (claims.token_endpoint !== void 0 && claims.token_endpoint !== tokenEndpoint) {
654
+ throw new Error("Token endpoint does not match the PAT token.");
550
655
  }
656
+ const now = normalizeNow(options.now);
551
657
  const ttlSeconds = normalizeTTLSeconds(options.ttlSeconds);
552
- const exp = normalizeNow(options.now) + ttlSeconds;
658
+ const exp = Math.min(now + ttlSeconds, requirePATExpiration(claims, now));
659
+ const ttl = exp - now;
553
660
  const username = `v1:${exp}:pat:${projectEndpoint}:${tokenEndpoint}`;
554
661
  const tokenHash = crypto.createHash("sha256").update(token, "utf8").digest();
555
662
  const key = Buffer.from(
@@ -564,7 +671,7 @@ function createPATTURNCredentials(options) {
564
671
  const credential = crypto.createHmac("sha256", key).update(username, "utf8").digest("base64");
565
672
  return turnCredentialsSchema.parse({
566
673
  credential,
567
- ttl: ttlSeconds,
674
+ ttl,
568
675
  urls: createTURNURLs(clusterDomain, options.turnPort, options.turnsPort),
569
676
  username
570
677
  });
@@ -574,15 +681,15 @@ async function createAPPTURNCredentials(options) {
574
681
  if (!clusterDomain) {
575
682
  throw new Error("Cluster domain is required for TURN APP mode.");
576
683
  }
577
- const projectEndpoint = normalizeOptionalString(options.projectEndpoint);
684
+ const projectEndpoint = normalizeOptionalString2(options.projectEndpoint);
578
685
  if (!projectEndpoint) {
579
686
  throw new Error("Project endpoint is required for TURN APP mode.");
580
687
  }
581
- const clientId = normalizeOptionalString(options.clientId);
688
+ const clientId = normalizeOptionalString2(options.clientId);
582
689
  if (!clientId) {
583
690
  throw new Error("Client ID is required for TURN APP mode.");
584
691
  }
585
- const clientSecret = normalizeOptionalString(options.clientSecret);
692
+ const clientSecret = normalizeOptionalString2(options.clientSecret);
586
693
  if (!clientSecret) {
587
694
  throw new Error("Client secret is required for TURN APP mode.");
588
695
  }
@@ -594,7 +701,7 @@ async function createAPPTURNCredentials(options) {
594
701
  format: "der",
595
702
  type: "pkcs8"
596
703
  });
597
- const serverPublicKeyHex = normalizeOptionalString(options.serverPublicKeyHex) ?? await loadTURNServerPublicKeyHex(clusterDomain, options);
704
+ const serverPublicKeyHex = normalizeOptionalString2(options.serverPublicKeyHex) ?? await loadTURNServerPublicKeyHex(clusterDomain, options);
598
705
  const serverPublicKey = crypto.createPublicKey({
599
706
  key: Buffer.from(serverPublicKeyHex, "hex"),
600
707
  format: "der",
@@ -624,7 +731,7 @@ async function createAPPTURNCredentials(options) {
624
731
  async function createTURNCredentials(options) {
625
732
  const credentials = options.credentials;
626
733
  const mode = resolveMode(credentials, options.mode);
627
- const projectEndpoint = normalizeOptionalString(options.projectEndpoint);
734
+ const projectEndpoint = normalizeOptionalString2(options.projectEndpoint);
628
735
  if (mode === "api") {
629
736
  const controlPlaneCredentials = options.controlPlaneCredentials ?? credentials;
630
737
  if (!controlPlaneCredentials) {
@@ -655,6 +762,10 @@ async function createTURNCredentials(options) {
655
762
  "Project endpoint is required for TURN PAT mode."
656
763
  ),
657
764
  token: credentials.token,
765
+ tokenEndpoint: requireOption(
766
+ normalizeOptionalString2(options.tokenEndpoint),
767
+ "Token endpoint is required for TURN PAT mode."
768
+ ),
658
769
  ttlSeconds: options.ttlSeconds,
659
770
  turnPort: options.turnPort,
660
771
  turnsPort: options.turnsPort
@@ -704,6 +815,7 @@ var RstreamTURNResource = class {
704
815
  projectEndpoint: options.projectEndpoint ?? this.client.projectEndpoint,
705
816
  projectId: options.projectId,
706
817
  serverPublicKeyHex: options.serverPublicKeyHex,
818
+ tokenEndpoint: options.tokenEndpoint,
707
819
  ttlSeconds: options.ttlSeconds,
708
820
  turnPort: options.turnPort,
709
821
  turnsPort: options.turnsPort
@@ -713,6 +825,41 @@ var RstreamTURNResource = class {
713
825
 
714
826
  // src/webhooks-resource.ts
715
827
  import crypto2 from "crypto";
828
+ var SIGNATURE_HEX_BYTES = 32;
829
+ var signatureHexSchema = /^[0-9a-fA-F]{64}$/;
830
+ var timestampSchema = /^\d+$/;
831
+ function parseSignatureTimestamp(timestamp) {
832
+ if (!timestampSchema.test(timestamp)) {
833
+ throw new Error("Invalid signature timestamp");
834
+ }
835
+ const parsed = Number(timestamp);
836
+ if (!Number.isSafeInteger(parsed)) {
837
+ throw new Error("Invalid signature timestamp");
838
+ }
839
+ return parsed;
840
+ }
841
+ function normalizeTolerance(tolerance) {
842
+ if (!Number.isFinite(tolerance) || !Number.isInteger(tolerance) || tolerance < 0) {
843
+ throw new Error("Invalid signature tolerance");
844
+ }
845
+ return tolerance;
846
+ }
847
+ function readSignature(signature) {
848
+ if (!signatureHexSchema.test(signature)) {
849
+ return void 0;
850
+ }
851
+ const parsed = Buffer.from(signature, "hex");
852
+ return parsed.length === SIGNATURE_HEX_BYTES ? parsed : void 0;
853
+ }
854
+ function signatureMatches(signature, expected) {
855
+ const parsed = readSignature(signature);
856
+ return parsed === void 0 ? false : crypto2.timingSafeEqual(parsed, expected);
857
+ }
858
+ function assertSecretConfigured(secret) {
859
+ if (secret.trim().length === 0) {
860
+ throw new Error("Webhook signing secret is required");
861
+ }
862
+ }
716
863
  var RstreamWebhookResource = class {
717
864
  async event(payload, header, secret, tolerance = 300, receivedAt = Date.now()) {
718
865
  const payloadBuffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
@@ -720,31 +867,26 @@ var RstreamWebhookResource = class {
720
867
  if (!signatureHeader) {
721
868
  throw new Error("No signature header");
722
869
  }
870
+ assertSecretConfigured(secret);
723
871
  const elements = signatureHeader.split(",").map((element) => element.trim());
724
872
  const timestamp = elements.find((element) => element.startsWith("t="))?.substring(2);
725
873
  const signatures = elements.filter((element) => element.startsWith("v1=")).map((element) => element.substring(3));
726
874
  if (!timestamp || signatures.length === 0) {
727
875
  throw new Error("Invalid signature header format");
728
876
  }
729
- const timestampNum = parseInt(timestamp, 10);
877
+ const timestampNum = parseSignatureTimestamp(timestamp);
878
+ const signatureTolerance = normalizeTolerance(tolerance);
730
879
  const now = Math.floor(receivedAt / 1e3);
731
- if (Math.abs(now - timestampNum) > tolerance) {
880
+ if (Math.abs(now - timestampNum) > signatureTolerance) {
732
881
  throw new Error(
733
882
  `Webhook signature timestamp outside tolerance: ${now}, ${timestampNum}`
734
883
  );
735
884
  }
736
885
  const signedPayload = `${timestamp}.${payloadBuffer.toString("utf8")}`;
737
- const expectedSignature = crypto2.createHmac("sha256", secret).update(signedPayload).digest("hex");
738
- const signatureMatched = signatures.some((signature) => {
739
- try {
740
- return crypto2.timingSafeEqual(
741
- Buffer.from(signature),
742
- Buffer.from(expectedSignature)
743
- );
744
- } catch {
745
- return false;
746
- }
747
- });
886
+ const expectedSignature = crypto2.createHmac("sha256", secret).update(signedPayload).digest();
887
+ const signatureMatched = signatures.some(
888
+ (signature) => signatureMatches(signature, expectedSignature)
889
+ );
748
890
  if (!signatureMatched) {
749
891
  throw new Error("Signature verification failed");
750
892
  }
@@ -762,8 +904,16 @@ var RstreamWebhookResource = class {
762
904
  };
763
905
 
764
906
  // src/tunnels.ts
907
+ var engineAuthTokenScopes = {
908
+ tunnels: {
909
+ connect: true,
910
+ create: true,
911
+ list: true
912
+ }
913
+ };
765
914
  var RstreamTunnelsClient = class {
766
915
  config;
916
+ managedProject;
767
917
  constructor(config) {
768
918
  this.config = config;
769
919
  }
@@ -783,6 +933,21 @@ var RstreamTunnelsClient = class {
783
933
  return this.config?.projectEndpoint;
784
934
  }
785
935
  async getEngine() {
936
+ const projectEndpoint = normalizeOptionalString3(
937
+ this.config?.projectEndpoint
938
+ );
939
+ if (projectEndpoint !== void 0 && normalizeOptionalString3(this.config?.engine) === void 0 && readEnvironment2().engine === void 0) {
940
+ const project = await this.getManagedProject();
941
+ if (project !== void 0) {
942
+ const engine = getTunnelsProjectEngine2(project);
943
+ if (engine) {
944
+ return normalizeEngineAddress(engine);
945
+ }
946
+ }
947
+ throw new Error(
948
+ "Failed to resolve the engine address from the managed tunnels project."
949
+ );
950
+ }
786
951
  return await resolveTunnelsEngine({
787
952
  apiUrl: this.config?.apiUrl,
788
953
  controlPlaneCredentials: this.controlPlaneCredentials,
@@ -791,6 +956,57 @@ var RstreamTunnelsClient = class {
791
956
  projectEndpoint: this.config?.projectEndpoint
792
957
  });
793
958
  }
959
+ async getManagedProject() {
960
+ const projectEndpoint = normalizeOptionalString3(
961
+ this.config?.projectEndpoint
962
+ );
963
+ if (projectEndpoint === void 0) {
964
+ return void 0;
965
+ }
966
+ if (this.managedProject === void 0) {
967
+ this.managedProject = resolveManagedTunnelsProject({
968
+ apiUrl: this.config?.apiUrl,
969
+ controlPlaneCredentials: this.controlPlaneCredentials,
970
+ credentials: this.credentials,
971
+ projectEndpoint
972
+ }).catch((error) => {
973
+ this.managedProject = void 0;
974
+ throw error;
975
+ });
976
+ }
977
+ return await this.managedProject;
978
+ }
979
+ async getEngineAuthTokenParams() {
980
+ const target = await this.getEngineAuthTokenTarget();
981
+ return {
982
+ tunnelsGrants: [
983
+ {
984
+ ...target,
985
+ scopes: engineAuthTokenScopes
986
+ }
987
+ ]
988
+ };
989
+ }
990
+ async getEngineAuthTokenTarget() {
991
+ const projectId = normalizeOptionalString3(this.config?.projectId);
992
+ const workspaceId = normalizeOptionalString3(this.config?.workspaceId);
993
+ if (projectId !== void 0 && workspaceId !== void 0) {
994
+ throw new Error("Use either projectId or workspaceId, not both.");
995
+ }
996
+ if (projectId !== void 0) {
997
+ return { projects: [projectId] };
998
+ }
999
+ if (workspaceId !== void 0) {
1000
+ return { workspaces: [workspaceId] };
1001
+ }
1002
+ const project = await this.getManagedProject();
1003
+ if (project !== void 0) {
1004
+ return { projects: [project.id] };
1005
+ }
1006
+ throw new Error(
1007
+ "Application credentials require projectId, workspaceId, or projectEndpoint to create scoped engine tokens."
1008
+ );
1009
+ }
794
1010
  get auth() {
795
1011
  return new RstreamAuthResource(this);
796
1012
  }
@@ -814,13 +1030,17 @@ var RstreamTunnelsClient = class {
814
1030
  if (!isClientCredentials3(credentials)) {
815
1031
  return credentials.token;
816
1032
  }
817
- return (await this.auth.createAuthToken(void 0, {
1033
+ const tokenParams = await this.getEngineAuthTokenParams();
1034
+ return (await this.auth.createAuthToken(tokenParams, {
818
1035
  credentials,
819
1036
  engine
820
1037
  })).token;
821
1038
  }
822
1039
  async request(path, options) {
823
- const engine = await this.getEngine();
1040
+ const engine = normalizeEngineAddress(await this.getEngine());
1041
+ if (!path.startsWith("/") || path.startsWith("//")) {
1042
+ throw new Error("Engine request path must be a relative absolute path.");
1043
+ }
824
1044
  const url = `https://${engine}/api${path}`;
825
1045
  const headers = new Headers(options?.headers || {});
826
1046
  const token = await this.getToken(engine);
@@ -829,7 +1049,8 @@ var RstreamTunnelsClient = class {
829
1049
  }
830
1050
  const response = await fetch(url, {
831
1051
  ...options,
832
- headers
1052
+ headers,
1053
+ redirect: options?.redirect ?? "manual"
833
1054
  });
834
1055
  if (!response.ok) {
835
1056
  const errorText = await response.text();
@@ -838,8 +1059,68 @@ var RstreamTunnelsClient = class {
838
1059
  return await response.json();
839
1060
  }
840
1061
  };
1062
+ function normalizeOptionalString3(value) {
1063
+ const normalized = value?.trim();
1064
+ return normalized ? normalized : void 0;
1065
+ }
841
1066
 
842
1067
  // src/watch.ts
1068
+ import jwt from "jsonwebtoken";
1069
+ var maxWatchTokenLifetimeSeconds = 3600;
1070
+ var watchTokenIssuedAtSkewSeconds = 300;
1071
+ function normalizeTransport(transport) {
1072
+ if (transport === void 0) {
1073
+ return "sse";
1074
+ }
1075
+ if (transport === "sse" || transport === "websocket") {
1076
+ return transport;
1077
+ }
1078
+ throw new Error("Watch: Unsupported transport.");
1079
+ }
1080
+ function validateWatchQueryToken(token) {
1081
+ const parsed = authTokenSchema.safeParse(jwt.decode(token));
1082
+ if (!parsed.success || parsed.data.type === "pat") {
1083
+ throw new Error(
1084
+ "Watch requires a short-lived auth or app token for URL-based authentication."
1085
+ );
1086
+ }
1087
+ const iat = parsed.data.iat;
1088
+ const exp = parsed.data.exp;
1089
+ const now = Math.floor(Date.now() / 1e3);
1090
+ if (typeof iat !== "number" || typeof exp !== "number" || !Number.isInteger(iat) || !Number.isInteger(exp) || exp <= iat || exp <= now || exp > now + maxWatchTokenLifetimeSeconds || iat > now + watchTokenIssuedAtSkewSeconds || exp - iat > maxWatchTokenLifetimeSeconds) {
1091
+ throw new Error(
1092
+ "Watch requires a token with a bounded lifetime of at most 3600 seconds."
1093
+ );
1094
+ }
1095
+ if (!hasWatchOnlyTunnelGrants(parsed.data)) {
1096
+ throw new Error(
1097
+ "Watch requires a fine-grained token limited to tunnel list grants for URL-based authentication."
1098
+ );
1099
+ }
1100
+ }
1101
+ function hasWatchOnlyTunnelGrants(token) {
1102
+ if (!hasWatchOnlyPermissions(token.permissions)) {
1103
+ return false;
1104
+ }
1105
+ if (!Array.isArray(token.tunnelsGrants) || token.tunnelsGrants.length === 0) {
1106
+ return false;
1107
+ }
1108
+ return token.tunnelsGrants.every((grant) => {
1109
+ const tunnels = grant.scopes?.tunnels;
1110
+ if (tunnels === void 0) {
1111
+ return false;
1112
+ }
1113
+ return tunnels.list !== void 0 && tunnels.create === void 0 && tunnels.connect === void 0;
1114
+ });
1115
+ }
1116
+ function hasWatchOnlyPermissions(permissions) {
1117
+ if (!Array.isArray(permissions)) {
1118
+ return true;
1119
+ }
1120
+ return permissions.every(
1121
+ (permission) => permission === "tunnels.resources.read-only" || permission === "network.resources.read-only"
1122
+ );
1123
+ }
843
1124
  var Watch = class {
844
1125
  connection = null;
845
1126
  connectionState = "preparing";
@@ -854,45 +1135,56 @@ var Watch = class {
854
1135
  throw new Error("Watch: Connection already started or closed.");
855
1136
  }
856
1137
  this.connectionState = "connecting";
857
- const token = typeof this.config.auth === "function" ? await this.config.auth() : this.config.auth;
858
- const engine = await resolveTunnelsEngine({
859
- apiUrl: this.config.apiUrl,
860
- controlPlaneCredentials: this.config.controlPlaneCredentials,
861
- engine: this.config.engine,
862
- projectEndpoint: this.config.projectEndpoint,
863
- token
864
- });
865
- const transport = this.config.transport ?? "sse";
866
- const base = `https://${engine}`;
867
- if (transport === "sse") {
868
- const url = new URL(`/api/sse`, base);
869
- url.searchParams.set("rstream.token", token);
870
- this.connection = new EventSource(url.toString());
871
- } else {
872
- const url = new URL(`/api/websocket`, base);
873
- url.searchParams.set("rstream.token", token);
874
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
875
- this.connection = new WebSocket(url.toString());
876
- }
877
- this.connection.onopen = () => {
878
- this.connectionState = "connected";
879
- this.events.onConnect?.();
880
- };
881
- this.connection.onmessage = (msg) => {
882
- const parsed = wsEventsSchema.parse(JSON.parse(msg.data));
883
- this.events.onEvent?.(parsed);
884
- };
885
- this.connection.onerror = () => {
886
- if (this.connectionState !== "closed") {
887
- this.disconnect();
1138
+ try {
1139
+ const token = typeof this.config.auth === "function" ? await this.config.auth() : this.config.auth;
1140
+ validateWatchQueryToken(token);
1141
+ const engine = await resolveTunnelsEngine({
1142
+ apiUrl: this.config.apiUrl,
1143
+ controlPlaneCredentials: this.config.controlPlaneCredentials,
1144
+ engine: this.config.engine,
1145
+ projectEndpoint: this.config.projectEndpoint,
1146
+ token
1147
+ });
1148
+ const transport = normalizeTransport(this.config.transport);
1149
+ const base = `https://${engine}`;
1150
+ if (transport === "sse") {
1151
+ const url = new URL(`/api/sse`, base);
1152
+ url.searchParams.set("rstream.token", token);
1153
+ this.connection = new EventSource(url.toString());
1154
+ } else {
1155
+ const url = new URL(`/api/websocket`, base);
1156
+ url.searchParams.set("rstream.token", token);
1157
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1158
+ this.connection = new WebSocket(url.toString());
888
1159
  }
889
- };
890
- if (this.connection instanceof WebSocket) {
891
- this.connection.onclose = () => {
1160
+ this.connection.onopen = () => {
1161
+ this.connectionState = "connected";
1162
+ this.events.onConnect?.();
1163
+ };
1164
+ this.connection.onmessage = (msg) => this.handleMessage(msg.data);
1165
+ this.connection.onerror = () => {
892
1166
  if (this.connectionState !== "closed") {
893
1167
  this.disconnect();
894
1168
  }
895
1169
  };
1170
+ if (this.connection instanceof WebSocket) {
1171
+ this.connection.onclose = () => {
1172
+ if (this.connectionState !== "closed") {
1173
+ this.disconnect();
1174
+ }
1175
+ };
1176
+ }
1177
+ } catch (error) {
1178
+ this.connectionState = "preparing";
1179
+ throw error;
1180
+ }
1181
+ }
1182
+ handleMessage(data) {
1183
+ try {
1184
+ const parsed = wsEventsSchema.parse(JSON.parse(data));
1185
+ this.events.onEvent?.(parsed);
1186
+ } catch {
1187
+ this.disconnect();
896
1188
  }
897
1189
  }
898
1190
  disconnect() {
@@ -1012,8 +1304,10 @@ export {
1012
1304
  listClientsResponseSchema,
1013
1305
  listTunnelsParamsSchema,
1014
1306
  listTunnelsResponseSchema,
1307
+ normalizeEngineAddress,
1015
1308
  parseWebTTYServers,
1016
1309
  resolveControlPlaneCredentials,
1310
+ resolveManagedTunnelsProject,
1017
1311
  resolveTunnelsAPIURL,
1018
1312
  resolveTunnelsCredentials,
1019
1313
  resolveTunnelsEngine,