@stacknet/stacks 0.1.2 → 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.
@@ -1,7 +1,16 @@
1
1
  /**
2
2
  * Next.js API Route Handler Factories
3
3
  *
4
- * Create pre-configured route handlers for common patterns
4
+ * Create pre-configured route handlers for common patterns.
5
+ *
6
+ * SECURITY NOTES
7
+ * - Every path-position parameter is run through `validateId` before being
8
+ * interpolated into the upstream path. Without this, characters like `?`,
9
+ * `&`, `#`, or `/` in an id would smuggle extra query params or escape
10
+ * the intended namespace.
11
+ * - Every factory supports `extractAuth`; the forwarder receives an
12
+ * `Authorization` header so the upstream can attribute the request to
13
+ * the real caller instead of treating it as anonymous.
5
14
  */
6
15
 
7
16
  import { forwardRequest, forwardJSON, type ForwarderConfig } from './forwarder';
@@ -9,6 +18,11 @@ import { forwardRequest, forwardJSON, type ForwarderConfig } from './forwarder';
9
18
  export interface RouteHandlerConfig extends ForwarderConfig {
10
19
  requireAuth?: boolean;
11
20
  enrichResponse?: (data: unknown) => Promise<unknown>;
21
+ /** Extract the caller's bearer token from the incoming Request and
22
+ * forward it upstream as `Authorization: Bearer <token>`. If unset,
23
+ * the handler passes through any inbound `Authorization` header
24
+ * verbatim. Without one of these the upstream sees no auth at all. */
25
+ extractAuth?: (request: Request) => string | null;
12
26
  }
13
27
 
14
28
  export type RouteHandler = (request: Request, context?: { params?: Record<string, string> }) => Promise<Response>;
@@ -20,6 +34,65 @@ export interface CRUDRouteHandlers {
20
34
  DELETE?: RouteHandler;
21
35
  }
22
36
 
37
+ /** Back-compat: `StackRouteHandlerConfig` used to be distinct; now every
38
+ * factory accepts `extractAuth`, so the two configs are identical. */
39
+ export type StackRouteHandlerConfig = RouteHandlerConfig;
40
+
41
+ // ----------------------------------------------------------------------------
42
+ // Shared helpers
43
+ // ----------------------------------------------------------------------------
44
+
45
+ /** Validate that an id/param is a safe alphanumeric/dash/underscore/dot string.
46
+ * Blocks path traversal (`..`, `/`), query smuggling (`?`, `&`, `#`), and
47
+ * header injection (`\r`, `\n`) in one shot. */
48
+ function validateId(id: string | null | undefined, name: string): string {
49
+ if (!id || typeof id !== 'string') {
50
+ throw new Error(`Missing required parameter: ${name}`);
51
+ }
52
+ if (!/^[\w.\-]+$/.test(id)) {
53
+ throw new Error(`Invalid ${name}: contains disallowed characters`);
54
+ }
55
+ return id;
56
+ }
57
+
58
+ /** Convert a thrown validation error into a 400 response so handlers don't
59
+ * have to pepper try/catch everywhere. */
60
+ function badRequest(err: unknown): Response {
61
+ const message = err instanceof Error ? err.message : 'Bad request';
62
+ return Response.json({ error: message }, { status: 400 });
63
+ }
64
+
65
+ function authHeaders(config: RouteHandlerConfig, request: Request): Record<string, string> {
66
+ const headers: Record<string, string> = {};
67
+ if (config.extractAuth) {
68
+ const token = config.extractAuth(request);
69
+ if (token) headers['Authorization'] = `Bearer ${token}`;
70
+ } else {
71
+ const auth = request.headers.get('Authorization');
72
+ if (auth) headers['Authorization'] = auth;
73
+ }
74
+ return headers;
75
+ }
76
+
77
+ /** Pull an id from the Next-style context first, then fall back to the
78
+ * last path segment. Runs through validateId either way. */
79
+ function idFromContext(
80
+ request: Request,
81
+ context: { params?: Record<string, string | undefined> } | undefined,
82
+ key: string,
83
+ segmentFromEnd = 1,
84
+ ): string {
85
+ const fromCtx = context?.params?.[key];
86
+ if (fromCtx) return validateId(fromCtx, key);
87
+ const segs = new URL(request.url).pathname.split('/').filter(Boolean);
88
+ const candidate = segs[segs.length - segmentFromEnd];
89
+ return validateId(candidate, key);
90
+ }
91
+
92
+ // ============================================================================
93
+ // Agent Route Handlers
94
+ // ============================================================================
95
+
23
96
  /**
24
97
  * Create agent route handlers
25
98
  */
@@ -35,12 +108,15 @@ export function createAgentRoutes(config: RouteHandlerConfig = {}): CRUDRouteHan
35
108
  const path = userMid
36
109
  ? `/agents?creator_mid=${encodeURIComponent(userMid)}`
37
110
  : visibility
38
- ? `/agents?visibility=${visibility}`
111
+ ? `/agents?visibility=${encodeURIComponent(visibility)}`
39
112
  : '/agents';
40
113
 
41
- const { data, status } = await forwardJSON(path, {}, { baseUrl });
114
+ const { data, status } = await forwardJSON(
115
+ path,
116
+ { headers: authHeaders(config, request) },
117
+ { baseUrl },
118
+ );
42
119
 
43
- // Optional response enrichment
44
120
  let responseData = data;
45
121
  if (config.enrichResponse) {
46
122
  responseData = await config.enrichResponse(data);
@@ -53,7 +129,7 @@ export function createAgentRoutes(config: RouteHandlerConfig = {}): CRUDRouteHan
53
129
  const body = await request.json();
54
130
  const { data, status } = await forwardJSON(
55
131
  '/agents',
56
- { method: 'POST', body },
132
+ { method: 'POST', body, headers: authHeaders(config, request) },
57
133
  { baseUrl }
58
134
  );
59
135
  return Response.json(data, { status });
@@ -69,8 +145,13 @@ export function createAgentDetailRoutes(config: RouteHandlerConfig = {}): CRUDRo
69
145
 
70
146
  return {
71
147
  GET: async (request: Request, context?: { params?: { id?: string } }) => {
72
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
73
- const { data, status } = await forwardJSON(`/agents/${id}`, {}, { baseUrl });
148
+ let id: string;
149
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
150
+ const { data, status } = await forwardJSON(
151
+ `/agents/${id}`,
152
+ { headers: authHeaders(config, request) },
153
+ { baseUrl },
154
+ );
74
155
 
75
156
  let responseData = data;
76
157
  if (config.enrichResponse) {
@@ -81,32 +162,35 @@ export function createAgentDetailRoutes(config: RouteHandlerConfig = {}): CRUDRo
81
162
  },
82
163
 
83
164
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
84
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
165
+ let id: string;
166
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
85
167
  const body = await request.json();
86
168
  const { data, status } = await forwardJSON(
87
169
  `/agents/${id}`,
88
- { method: 'POST', body },
170
+ { method: 'POST', body, headers: authHeaders(config, request) },
89
171
  { baseUrl }
90
172
  );
91
173
  return Response.json(data, { status });
92
174
  },
93
175
 
94
176
  PUT: async (request: Request, context?: { params?: { id?: string } }) => {
95
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
177
+ let id: string;
178
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
96
179
  const body = await request.json();
97
180
  const { data, status } = await forwardJSON(
98
181
  `/agents/${id}`,
99
- { method: 'PUT', body },
182
+ { method: 'PUT', body, headers: authHeaders(config, request) },
100
183
  { baseUrl }
101
184
  );
102
185
  return Response.json(data, { status });
103
186
  },
104
187
 
105
188
  DELETE: async (request: Request, context?: { params?: { id?: string } }) => {
106
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
189
+ let id: string;
190
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
107
191
  const { data, status } = await forwardJSON(
108
192
  `/agents/${id}`,
109
- { method: 'DELETE' },
193
+ { method: 'DELETE', headers: authHeaders(config, request) },
110
194
  { baseUrl }
111
195
  );
112
196
  return Response.json(data, { status });
@@ -122,12 +206,13 @@ export function createAgentExecuteRoute(config: RouteHandlerConfig = {}): { POST
122
206
 
123
207
  return {
124
208
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
125
- const id = context?.params?.id || new URL(request.url).pathname.split('/').slice(-2)[0];
209
+ let id: string;
210
+ try { id = idFromContext(request, context, 'id', 2); } catch (e) { return badRequest(e); }
126
211
  const body = await request.json();
127
212
 
128
213
  const response = await forwardRequest(
129
214
  `/agents/${id}/execute`,
130
- { method: 'POST', body, stream: true },
215
+ { method: 'POST', body, stream: true, headers: authHeaders(config, request) },
131
216
  { baseUrl }
132
217
  );
133
218
 
@@ -162,10 +247,11 @@ export function createAgentToggleRoutes(config: RouteHandlerConfig = {}): {
162
247
  return {
163
248
  enable: {
164
249
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
165
- const id = context?.params?.id || new URL(request.url).pathname.split('/').slice(-2)[0];
250
+ let id: string;
251
+ try { id = idFromContext(request, context, 'id', 2); } catch (e) { return badRequest(e); }
166
252
  const { data, status } = await forwardJSON(
167
253
  `/agents/${id}/enable`,
168
- { method: 'POST' },
254
+ { method: 'POST', headers: authHeaders(config, request) },
169
255
  { baseUrl }
170
256
  );
171
257
  return Response.json(data, { status });
@@ -173,10 +259,11 @@ export function createAgentToggleRoutes(config: RouteHandlerConfig = {}): {
173
259
  },
174
260
  disable: {
175
261
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
176
- const id = context?.params?.id || new URL(request.url).pathname.split('/').slice(-2)[0];
262
+ let id: string;
263
+ try { id = idFromContext(request, context, 'id', 2); } catch (e) { return badRequest(e); }
177
264
  const { data, status } = await forwardJSON(
178
265
  `/agents/${id}/disable`,
179
- { method: 'POST' },
266
+ { method: 'POST', headers: authHeaders(config, request) },
180
267
  { baseUrl }
181
268
  );
182
269
  return Response.json(data, { status });
@@ -185,6 +272,10 @@ export function createAgentToggleRoutes(config: RouteHandlerConfig = {}): {
185
272
  };
186
273
  }
187
274
 
275
+ // ============================================================================
276
+ // Skill Route Handlers
277
+ // ============================================================================
278
+
188
279
  /**
189
280
  * Create skill route handlers
190
281
  */
@@ -201,7 +292,11 @@ export function createSkillRoutes(config: RouteHandlerConfig = {}): CRUDRouteHan
201
292
  if (scope) path += `?scope=${encodeURIComponent(scope)}`;
202
293
  else if (creatorMid) path += `?creator_mid=${encodeURIComponent(creatorMid)}`;
203
294
 
204
- const { data, status } = await forwardJSON(path, {}, { baseUrl });
295
+ const { data, status } = await forwardJSON(
296
+ path,
297
+ { headers: authHeaders(config, request) },
298
+ { baseUrl },
299
+ );
205
300
  return Response.json(data, { status });
206
301
  },
207
302
 
@@ -209,7 +304,7 @@ export function createSkillRoutes(config: RouteHandlerConfig = {}): CRUDRouteHan
209
304
  const body = await request.json();
210
305
  const { data, status } = await forwardJSON(
211
306
  '/skills',
212
- { method: 'POST', body },
307
+ { method: 'POST', body, headers: authHeaders(config, request) },
213
308
  { baseUrl }
214
309
  );
215
310
  return Response.json(data, { status });
@@ -225,38 +320,46 @@ export function createSkillDetailRoutes(config: RouteHandlerConfig = {}): CRUDRo
225
320
 
226
321
  return {
227
322
  GET: async (request: Request, context?: { params?: { id?: string } }) => {
228
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
229
- const { data, status } = await forwardJSON(`/skills/${id}`, {}, { baseUrl });
323
+ let id: string;
324
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
325
+ const { data, status } = await forwardJSON(
326
+ `/skills/${id}`,
327
+ { headers: authHeaders(config, request) },
328
+ { baseUrl },
329
+ );
230
330
  return Response.json(data, { status });
231
331
  },
232
332
 
233
333
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
234
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
334
+ let id: string;
335
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
235
336
  const body = await request.json();
236
337
  const { data, status } = await forwardJSON(
237
338
  `/skills/${id}`,
238
- { method: 'POST', body },
339
+ { method: 'POST', body, headers: authHeaders(config, request) },
239
340
  { baseUrl }
240
341
  );
241
342
  return Response.json(data, { status });
242
343
  },
243
344
 
244
345
  PUT: async (request: Request, context?: { params?: { id?: string } }) => {
245
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
346
+ let id: string;
347
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
246
348
  const body = await request.json();
247
349
  const { data, status } = await forwardJSON(
248
350
  `/skills/${id}`,
249
- { method: 'PUT', body },
351
+ { method: 'PUT', body, headers: authHeaders(config, request) },
250
352
  { baseUrl }
251
353
  );
252
354
  return Response.json(data, { status });
253
355
  },
254
356
 
255
357
  DELETE: async (request: Request, context?: { params?: { id?: string } }) => {
256
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
358
+ let id: string;
359
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
257
360
  const { data, status } = await forwardJSON(
258
361
  `/skills/${id}`,
259
- { method: 'DELETE' },
362
+ { method: 'DELETE', headers: authHeaders(config, request) },
260
363
  { baseUrl }
261
364
  );
262
365
  return Response.json(data, { status });
@@ -264,6 +367,10 @@ export function createSkillDetailRoutes(config: RouteHandlerConfig = {}): CRUDRo
264
367
  };
265
368
  }
266
369
 
370
+ // ============================================================================
371
+ // Widget Route Handlers
372
+ // ============================================================================
373
+
267
374
  /**
268
375
  * Create widget route handlers (same pattern as skills)
269
376
  */
@@ -280,7 +387,11 @@ export function createWidgetRoutes(config: RouteHandlerConfig = {}): CRUDRouteHa
280
387
  if (scope) path += `?scope=${encodeURIComponent(scope)}`;
281
388
  else if (creatorMid) path += `?creator_mid=${encodeURIComponent(creatorMid)}`;
282
389
 
283
- const { data, status } = await forwardJSON(path, {}, { baseUrl });
390
+ const { data, status } = await forwardJSON(
391
+ path,
392
+ { headers: authHeaders(config, request) },
393
+ { baseUrl },
394
+ );
284
395
  return Response.json(data, { status });
285
396
  },
286
397
 
@@ -288,7 +399,7 @@ export function createWidgetRoutes(config: RouteHandlerConfig = {}): CRUDRouteHa
288
399
  const body = await request.json();
289
400
  const { data, status } = await forwardJSON(
290
401
  '/widgets',
291
- { method: 'POST', body },
402
+ { method: 'POST', body, headers: authHeaders(config, request) },
292
403
  { baseUrl }
293
404
  );
294
405
  return Response.json(data, { status });
@@ -304,38 +415,46 @@ export function createWidgetDetailRoutes(config: RouteHandlerConfig = {}): CRUDR
304
415
 
305
416
  return {
306
417
  GET: async (request: Request, context?: { params?: { id?: string } }) => {
307
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
308
- const { data, status } = await forwardJSON(`/widgets/${id}`, {}, { baseUrl });
418
+ let id: string;
419
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
420
+ const { data, status } = await forwardJSON(
421
+ `/widgets/${id}`,
422
+ { headers: authHeaders(config, request) },
423
+ { baseUrl },
424
+ );
309
425
  return Response.json(data, { status });
310
426
  },
311
427
 
312
428
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
313
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
429
+ let id: string;
430
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
314
431
  const body = await request.json();
315
432
  const { data, status } = await forwardJSON(
316
433
  `/widgets/${id}`,
317
- { method: 'POST', body },
434
+ { method: 'POST', body, headers: authHeaders(config, request) },
318
435
  { baseUrl }
319
436
  );
320
437
  return Response.json(data, { status });
321
438
  },
322
439
 
323
440
  PUT: async (request: Request, context?: { params?: { id?: string } }) => {
324
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
441
+ let id: string;
442
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
325
443
  const body = await request.json();
326
444
  const { data, status } = await forwardJSON(
327
445
  `/widgets/${id}`,
328
- { method: 'PUT', body },
446
+ { method: 'PUT', body, headers: authHeaders(config, request) },
329
447
  { baseUrl }
330
448
  );
331
449
  return Response.json(data, { status });
332
450
  },
333
451
 
334
452
  DELETE: async (request: Request, context?: { params?: { id?: string } }) => {
335
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
453
+ let id: string;
454
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
336
455
  const { data, status } = await forwardJSON(
337
456
  `/widgets/${id}`,
338
- { method: 'DELETE' },
457
+ { method: 'DELETE', headers: authHeaders(config, request) },
339
458
  { baseUrl }
340
459
  );
341
460
  return Response.json(data, { status });
@@ -343,6 +462,10 @@ export function createWidgetDetailRoutes(config: RouteHandlerConfig = {}): CRUDR
343
462
  };
344
463
  }
345
464
 
465
+ // ============================================================================
466
+ // Imagination Route Handlers
467
+ // ============================================================================
468
+
346
469
  /**
347
470
  * Create imagination route handlers
348
471
  */
@@ -352,14 +475,26 @@ export function createImaginationRoutes(config: RouteHandlerConfig = {}): CRUDRo
352
475
  return {
353
476
  GET: async (request: Request) => {
354
477
  const url = new URL(request.url);
355
- const id = url.searchParams.get('id');
478
+ const idParam = url.searchParams.get('id');
356
479
  const creatorMid = url.searchParams.get('creator_mid');
357
480
 
358
481
  let path = '/imaginations';
359
- if (id) path = `/imaginations/${id}`;
360
- else if (creatorMid) path += `?creator_mid=${encodeURIComponent(creatorMid)}`;
482
+ if (idParam) {
483
+ try {
484
+ const id = validateId(idParam, 'id');
485
+ path = `/imaginations/${id}`;
486
+ } catch (e) {
487
+ return badRequest(e);
488
+ }
489
+ } else if (creatorMid) {
490
+ path += `?creator_mid=${encodeURIComponent(creatorMid)}`;
491
+ }
361
492
 
362
- const { data, status } = await forwardJSON(path, {}, { baseUrl });
493
+ const { data, status } = await forwardJSON(
494
+ path,
495
+ { headers: authHeaders(config, request) },
496
+ { baseUrl },
497
+ );
363
498
  return Response.json(data, { status });
364
499
  },
365
500
 
@@ -367,7 +502,7 @@ export function createImaginationRoutes(config: RouteHandlerConfig = {}): CRUDRo
367
502
  const body = await request.json();
368
503
  const { data, status } = await forwardJSON(
369
504
  '/imaginations',
370
- { method: 'POST', body },
505
+ { method: 'POST', body, headers: authHeaders(config, request) },
371
506
  { baseUrl }
372
507
  );
373
508
  return Response.json(data, { status });
@@ -397,7 +532,11 @@ export function createCoderSessionRoutes(config: RouteHandlerConfig = {}): CRUDR
397
532
  if (limit) params.push(`limit=${encodeURIComponent(limit)}`);
398
533
  if (params.length) path += `?${params.join('&')}`;
399
534
 
400
- const { data, status: resStatus } = await forwardJSON(path, {}, { baseUrl });
535
+ const { data, status: resStatus } = await forwardJSON(
536
+ path,
537
+ { headers: authHeaders(config, request) },
538
+ { baseUrl },
539
+ );
401
540
  return Response.json(data, { status: resStatus });
402
541
  },
403
542
 
@@ -405,7 +544,7 @@ export function createCoderSessionRoutes(config: RouteHandlerConfig = {}): CRUDR
405
544
  const body = await request.json();
406
545
  const { data, status } = await forwardJSON(
407
546
  '/coder/sessions',
408
- { method: 'POST', body },
547
+ { method: 'POST', body, headers: authHeaders(config, request) },
409
548
  { baseUrl }
410
549
  );
411
550
  return Response.json(data, { status });
@@ -423,27 +562,34 @@ export function createCoderSessionDetailRoutes(config: RouteHandlerConfig = {}):
423
562
 
424
563
  return {
425
564
  GET: async (request: Request, context?: { params?: { id?: string } }) => {
426
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
427
- const { data, status } = await forwardJSON(`/coder/sessions/${id}`, {}, { baseUrl });
565
+ let id: string;
566
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
567
+ const { data, status } = await forwardJSON(
568
+ `/coder/sessions/${id}`,
569
+ { headers: authHeaders(config, request) },
570
+ { baseUrl },
571
+ );
428
572
  return Response.json(data, { status });
429
573
  },
430
574
 
431
575
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
432
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
576
+ let id: string;
577
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
433
578
  const body = await request.json();
434
579
  const { data, status } = await forwardJSON(
435
580
  `/coder/sessions/${id}`,
436
- { method: 'POST', body },
581
+ { method: 'POST', body, headers: authHeaders(config, request) },
437
582
  { baseUrl }
438
583
  );
439
584
  return Response.json(data, { status });
440
585
  },
441
586
 
442
587
  DELETE: async (request: Request, context?: { params?: { id?: string } }) => {
443
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
588
+ let id: string;
589
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
444
590
  const { data, status } = await forwardJSON(
445
591
  `/coder/sessions/${id}`,
446
- { method: 'DELETE' },
592
+ { method: 'DELETE', headers: authHeaders(config, request) },
447
593
  { baseUrl }
448
594
  );
449
595
  return Response.json(data, { status });
@@ -451,10 +597,11 @@ export function createCoderSessionDetailRoutes(config: RouteHandlerConfig = {}):
451
597
 
452
598
  abort: {
453
599
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
454
- const id = context?.params?.id || new URL(request.url).pathname.split('/').slice(-2)[0];
600
+ let id: string;
601
+ try { id = idFromContext(request, context, 'id', 2); } catch (e) { return badRequest(e); }
455
602
  const { data, status } = await forwardJSON(
456
603
  `/coder/sessions/${id}/abort`,
457
- { method: 'POST' },
604
+ { method: 'POST', headers: authHeaders(config, request) },
458
605
  { baseUrl }
459
606
  );
460
607
  return Response.json(data, { status });
@@ -475,7 +622,7 @@ export function createCoderExecuteRoute(config: RouteHandlerConfig = {}): { POST
475
622
 
476
623
  const response = await forwardRequest(
477
624
  '/coder/execute',
478
- { method: 'POST', body, stream: body.stream },
625
+ { method: 'POST', body, stream: body.stream, headers: authHeaders(config, request) },
479
626
  { baseUrl }
480
627
  );
481
628
 
@@ -508,18 +655,23 @@ export function createCoderToolsRoutes(config: RouteHandlerConfig = {}): {
508
655
  const baseUrl = config.baseUrl;
509
656
 
510
657
  return {
511
- GET: async () => {
512
- const { data, status } = await forwardJSON('/coder/tools', {}, { baseUrl });
658
+ GET: async (request: Request) => {
659
+ const { data, status } = await forwardJSON(
660
+ '/coder/tools',
661
+ { headers: authHeaders(config, request) },
662
+ { baseUrl },
663
+ );
513
664
  return Response.json(data, { status });
514
665
  },
515
666
 
516
667
  call: {
517
668
  POST: async (request: Request, context?: { params?: { tool?: string } }) => {
518
- const tool = context?.params?.tool || new URL(request.url).pathname.split('/').pop();
669
+ let tool: string;
670
+ try { tool = idFromContext(request, context, 'tool'); } catch (e) { return badRequest(e); }
519
671
  const body = await request.json();
520
672
  const { data, status } = await forwardJSON(
521
673
  `/coder/tools/${tool}`,
522
- { method: 'POST', body },
674
+ { method: 'POST', body, headers: authHeaders(config, request) },
523
675
  { baseUrl }
524
676
  );
525
677
  return Response.json(data, { status });
@@ -552,7 +704,11 @@ export function createCoderFilesRoutes(config: RouteHandlerConfig = {}): {
552
704
  if (sessionId) apiPath += `&sessionId=${encodeURIComponent(sessionId)}`;
553
705
  if (sandboxId) apiPath += `&sandboxId=${encodeURIComponent(sandboxId)}`;
554
706
 
555
- const { data, status } = await forwardJSON(apiPath, {}, { baseUrl });
707
+ const { data, status } = await forwardJSON(
708
+ apiPath,
709
+ { headers: authHeaders(config, request) },
710
+ { baseUrl },
711
+ );
556
712
  return Response.json(data, { status });
557
713
  },
558
714
  },
@@ -562,7 +718,7 @@ export function createCoderFilesRoutes(config: RouteHandlerConfig = {}): {
562
718
  const body = await request.json();
563
719
  const { data, status } = await forwardJSON(
564
720
  '/coder/files/write',
565
- { method: 'POST', body },
721
+ { method: 'POST', body, headers: authHeaders(config, request) },
566
722
  { baseUrl }
567
723
  );
568
724
  return Response.json(data, { status });
@@ -579,12 +735,16 @@ export function createCoderFilesRoutes(config: RouteHandlerConfig = {}): {
579
735
  const sandboxId = url.searchParams.get('sandboxId');
580
736
 
581
737
  let apiPath = `/coder/files/list?path=${encodeURIComponent(path)}`;
582
- if (recursive) apiPath += `&recursive=${recursive}`;
583
- if (maxDepth) apiPath += `&maxDepth=${maxDepth}`;
738
+ if (recursive) apiPath += `&recursive=${encodeURIComponent(recursive)}`;
739
+ if (maxDepth) apiPath += `&maxDepth=${encodeURIComponent(maxDepth)}`;
584
740
  if (sessionId) apiPath += `&sessionId=${encodeURIComponent(sessionId)}`;
585
741
  if (sandboxId) apiPath += `&sandboxId=${encodeURIComponent(sandboxId)}`;
586
742
 
587
- const { data, status } = await forwardJSON(apiPath, {}, { baseUrl });
743
+ const { data, status } = await forwardJSON(
744
+ apiPath,
745
+ { headers: authHeaders(config, request) },
746
+ { baseUrl },
747
+ );
588
748
  return Response.json(data, { status });
589
749
  },
590
750
  },
@@ -604,7 +764,11 @@ export function createCoderFilesRoutes(config: RouteHandlerConfig = {}): {
604
764
  if (sessionId) apiPath += `&sessionId=${encodeURIComponent(sessionId)}`;
605
765
  if (sandboxId) apiPath += `&sandboxId=${encodeURIComponent(sandboxId)}`;
606
766
 
607
- const { data, status } = await forwardJSON(apiPath, {}, { baseUrl });
767
+ const { data, status } = await forwardJSON(
768
+ apiPath,
769
+ { headers: authHeaders(config, request) },
770
+ { baseUrl },
771
+ );
608
772
  return Response.json(data, { status });
609
773
  },
610
774
  },
@@ -614,7 +778,7 @@ export function createCoderFilesRoutes(config: RouteHandlerConfig = {}): {
614
778
  const body = await request.json();
615
779
  const { data, status } = await forwardJSON(
616
780
  '/coder/files/diff',
617
- { method: 'POST', body },
781
+ { method: 'POST', body, headers: authHeaders(config, request) },
618
782
  { baseUrl }
619
783
  );
620
784
  return Response.json(data, { status });
@@ -640,7 +804,7 @@ export function createCoderSandboxRoutes(config: RouteHandlerConfig = {}): {
640
804
  const body = await request.json();
641
805
  const { data, status } = await forwardJSON(
642
806
  '/coder/sandbox',
643
- { method: 'POST', body },
807
+ { method: 'POST', body, headers: authHeaders(config, request) },
644
808
  { baseUrl }
645
809
  );
646
810
  return Response.json(data, { status });
@@ -649,20 +813,26 @@ export function createCoderSandboxRoutes(config: RouteHandlerConfig = {}): {
649
813
 
650
814
  get: {
651
815
  GET: async (request: Request, context?: { params?: { id?: string } }) => {
652
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
653
- const { data, status } = await forwardJSON(`/coder/sandbox/${id}`, {}, { baseUrl });
816
+ let id: string;
817
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
818
+ const { data, status } = await forwardJSON(
819
+ `/coder/sandbox/${id}`,
820
+ { headers: authHeaders(config, request) },
821
+ { baseUrl },
822
+ );
654
823
  return Response.json(data, { status });
655
824
  },
656
825
  },
657
826
 
658
827
  exec: {
659
828
  POST: async (request: Request, context?: { params?: { id?: string } }) => {
660
- const id = context?.params?.id || new URL(request.url).pathname.split('/').slice(-2)[0];
829
+ let id: string;
830
+ try { id = idFromContext(request, context, 'id', 2); } catch (e) { return badRequest(e); }
661
831
  const body = await request.json();
662
832
 
663
833
  const response = await forwardRequest(
664
834
  `/coder/sandbox/${id}/exec`,
665
- { method: 'POST', body, stream: body.stream },
835
+ { method: 'POST', body, stream: body.stream, headers: authHeaders(config, request) },
666
836
  { baseUrl }
667
837
  );
668
838
 
@@ -686,10 +856,11 @@ export function createCoderSandboxRoutes(config: RouteHandlerConfig = {}): {
686
856
 
687
857
  destroy: {
688
858
  DELETE: async (request: Request, context?: { params?: { id?: string } }) => {
689
- const id = context?.params?.id || new URL(request.url).pathname.split('/').pop();
859
+ let id: string;
860
+ try { id = idFromContext(request, context, 'id'); } catch (e) { return badRequest(e); }
690
861
  const { data, status } = await forwardJSON(
691
862
  `/coder/sandbox/${id}`,
692
- { method: 'DELETE' },
863
+ { method: 'DELETE', headers: authHeaders(config, request) },
693
864
  { baseUrl }
694
865
  );
695
866
  return Response.json(data, { status });
@@ -712,7 +883,7 @@ export function createCoderExecRoutes(config: RouteHandlerConfig = {}): {
712
883
  const body = await request.json();
713
884
  const { data, status } = await forwardJSON(
714
885
  '/coder/exec',
715
- { method: 'POST', body },
886
+ { method: 'POST', body, headers: authHeaders(config, request) },
716
887
  { baseUrl }
717
888
  );
718
889
  return Response.json(data, { status });
@@ -724,7 +895,7 @@ export function createCoderExecRoutes(config: RouteHandlerConfig = {}): {
724
895
 
725
896
  const response = await forwardRequest(
726
897
  '/coder/exec/stream',
727
- { method: 'POST', body, stream: true },
898
+ { method: 'POST', body, stream: true, headers: authHeaders(config, request) },
728
899
  { baseUrl }
729
900
  );
730
901
 
@@ -750,35 +921,6 @@ export function createCoderExecRoutes(config: RouteHandlerConfig = {}): {
750
921
  // Stack Management Route Handlers
751
922
  // ============================================================================
752
923
 
753
- export interface StackRouteHandlerConfig extends RouteHandlerConfig {
754
- /** Extract JWT from incoming request and forward as Authorization header */
755
- extractAuth?: (request: Request) => string | null;
756
- }
757
-
758
- /** Validate that an ID parameter is a safe alphanumeric/dash/underscore string */
759
- function validateId(id: string | null | undefined, name: string): string {
760
- if (!id || typeof id !== 'string') {
761
- throw new Error(`Missing required parameter: ${name}`);
762
- }
763
- // Allow alphanumeric, dashes, underscores, dots — block path traversal
764
- if (!/^[\w.\-]+$/.test(id)) {
765
- throw new Error(`Invalid ${name}: contains disallowed characters`);
766
- }
767
- return id;
768
- }
769
-
770
- function authHeaders(config: StackRouteHandlerConfig, request: Request): Record<string, string> {
771
- const headers: Record<string, string> = {};
772
- if (config.extractAuth) {
773
- const token = config.extractAuth(request);
774
- if (token) headers['Authorization'] = `Bearer ${token}`;
775
- } else {
776
- const auth = request.headers.get('Authorization');
777
- if (auth) headers['Authorization'] = auth;
778
- }
779
- return headers;
780
- }
781
-
782
924
  /**
783
925
  * Create stack CRUD route handlers (list + create)
784
926
  */
@@ -815,7 +957,8 @@ export function createStackDetailRoutes(config: StackRouteHandlerConfig = {}): C
815
957
 
816
958
  return {
817
959
  GET: async (request: Request, context?: { params?: { stackId?: string } }) => {
818
- const stackId = validateId(context?.params?.stackId, 'stackId');
960
+ let stackId: string;
961
+ try { stackId = validateId(context?.params?.stackId, 'stackId'); } catch (e) { return badRequest(e); }
819
962
  const { data, status } = await forwardJSON(
820
963
  `/api/v2/stacks/${stackId}`,
821
964
  { headers: authHeaders(config, request) },
@@ -827,7 +970,8 @@ export function createStackDetailRoutes(config: StackRouteHandlerConfig = {}): C
827
970
  POST: async () => Response.json({ error: 'Method not allowed' }, { status: 405 }),
828
971
 
829
972
  PUT: async (request: Request, context?: { params?: { stackId?: string } }) => {
830
- const stackId = validateId(context?.params?.stackId, 'stackId');
973
+ let stackId: string;
974
+ try { stackId = validateId(context?.params?.stackId, 'stackId'); } catch (e) { return badRequest(e); }
831
975
  const body = await request.json();
832
976
  const { data, status } = await forwardJSON(
833
977
  `/api/v2/stacks/${stackId}`,
@@ -838,7 +982,8 @@ export function createStackDetailRoutes(config: StackRouteHandlerConfig = {}): C
838
982
  },
839
983
 
840
984
  DELETE: async (request: Request, context?: { params?: { stackId?: string } }) => {
841
- const stackId = validateId(context?.params?.stackId, 'stackId');
985
+ let stackId: string;
986
+ try { stackId = validateId(context?.params?.stackId, 'stackId'); } catch (e) { return badRequest(e); }
842
987
  const { data, status } = await forwardJSON(
843
988
  `/api/v2/stacks/${stackId}`,
844
989
  { method: 'DELETE', headers: authHeaders(config, request) },
@@ -857,7 +1002,8 @@ export function createStackKeysRoutes(config: StackRouteHandlerConfig = {}): CRU
857
1002
 
858
1003
  return {
859
1004
  GET: async (request: Request, context?: { params?: { stackId?: string } }) => {
860
- const stackId = validateId(context?.params?.stackId, 'stackId');
1005
+ let stackId: string;
1006
+ try { stackId = validateId(context?.params?.stackId, 'stackId'); } catch (e) { return badRequest(e); }
861
1007
  const { data, status } = await forwardJSON(
862
1008
  `/api/v2/stacks/${stackId}/keys`,
863
1009
  { headers: authHeaders(config, request) },
@@ -867,7 +1013,8 @@ export function createStackKeysRoutes(config: StackRouteHandlerConfig = {}): CRU
867
1013
  },
868
1014
 
869
1015
  POST: async (request: Request, context?: { params?: { stackId?: string } }) => {
870
- const stackId = validateId(context?.params?.stackId, 'stackId');
1016
+ let stackId: string;
1017
+ try { stackId = validateId(context?.params?.stackId, 'stackId'); } catch (e) { return badRequest(e); }
871
1018
  const body = await request.json();
872
1019
  const { data, status } = await forwardJSON(
873
1020
  `/api/v2/stacks/${stackId}/keys`,
@@ -878,8 +1025,12 @@ export function createStackKeysRoutes(config: StackRouteHandlerConfig = {}): CRU
878
1025
  },
879
1026
 
880
1027
  DELETE: async (request: Request, context?: { params?: { stackId?: string; keyId?: string } }) => {
881
- const stackId = validateId(context?.params?.stackId, 'stackId');
882
- const keyId = validateId(context?.params?.keyId, 'keyId');
1028
+ let stackId: string;
1029
+ let keyId: string;
1030
+ try {
1031
+ stackId = validateId(context?.params?.stackId, 'stackId');
1032
+ keyId = validateId(context?.params?.keyId, 'keyId');
1033
+ } catch (e) { return badRequest(e); }
883
1034
  const { data, status } = await forwardJSON(
884
1035
  `/api/v2/stacks/${stackId}/keys/${keyId}`,
885
1036
  { method: 'DELETE', headers: authHeaders(config, request) },
@@ -902,7 +1053,8 @@ export function createStackMembersRoutes(config: StackRouteHandlerConfig = {}):
902
1053
 
903
1054
  return {
904
1055
  GET: async (request: Request, context?: { params?: { stackId?: string } }) => {
905
- const stackId = validateId(context?.params?.stackId, 'stackId');
1056
+ let stackId: string;
1057
+ try { stackId = validateId(context?.params?.stackId, 'stackId'); } catch (e) { return badRequest(e); }
906
1058
  const url = new URL(request.url);
907
1059
  const params = new URLSearchParams();
908
1060
  const limit = url.searchParams.get('limit');
@@ -923,7 +1075,8 @@ export function createStackMembersRoutes(config: StackRouteHandlerConfig = {}):
923
1075
 
924
1076
  stats: {
925
1077
  GET: async (request: Request, context?: { params?: { stackId?: string } }) => {
926
- const stackId = validateId(context?.params?.stackId, 'stackId');
1078
+ let stackId: string;
1079
+ try { stackId = validateId(context?.params?.stackId, 'stackId'); } catch (e) { return badRequest(e); }
927
1080
  const { data, status } = await forwardJSON(
928
1081
  `/api/v2/stacks/${stackId}/members/stats`,
929
1082
  { headers: authHeaders(config, request) },
@@ -935,11 +1088,15 @@ export function createStackMembersRoutes(config: StackRouteHandlerConfig = {}):
935
1088
 
936
1089
  updateRole: {
937
1090
  PATCH: async (request: Request, context?: { params?: { stackId?: string; userId?: string } }) => {
938
- const stackId = validateId(context?.params?.stackId, 'stackId');
939
- const userId = validateId(context?.params?.userId, 'userId');
1091
+ let stackId: string;
1092
+ let userId: string;
1093
+ try {
1094
+ stackId = validateId(context?.params?.stackId, 'stackId');
1095
+ userId = validateId(context?.params?.userId, 'userId');
1096
+ } catch (e) { return badRequest(e); }
940
1097
  const body = await request.json();
941
1098
  const { data, status } = await forwardJSON(
942
- `/api/v2/stacks/${stackId}/members/${encodeURIComponent(userId)}/role`,
1099
+ `/api/v2/stacks/${stackId}/members/${userId}/role`,
943
1100
  { method: 'PATCH', body, headers: authHeaders(config, request) },
944
1101
  { baseUrl }
945
1102
  );