@gen3/workspaces 0.12.70 → 0.12.72

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.
@@ -3,6 +3,8 @@
3
3
  var core = require('@gen3/core');
4
4
  var nodePath = require('path');
5
5
  var fs = require('fs');
6
+ var http = require('http');
7
+ var https = require('https');
6
8
  var cookiesNext = require('cookies-next');
7
9
  var server = require('@gen3/core/server');
8
10
  var server$1 = require('@gen3/frontend/server');
@@ -13,6 +15,134 @@ process.env.GEN3_KERNEL_API || `${core.GEN3_API}/lw-workspace/proxy/jeg-panel/ap
13
15
  process.env.JEG_GATEWAY_API || `${core.GEN3_API}/lw-workspace/proxy/jeg-panel/api`;
14
16
  const JEG_SERVICE_API = 'http://jupyter-enterprise-gateway.jupyter-pods.svc.cluster.local:8888';
15
17
 
18
+ /**
19
+ * Shared HTTP proxy utility for server-side proxy handlers.
20
+ * Streams request body through and forwards response status + body.
21
+ *
22
+ * Also exports two token helpers used by the proxy factories:
23
+ * - extractTokenFallback: minimal cookie/header extraction used when the host
24
+ * app has not injected its own getToken (standalone / test use).
25
+ * - decodeJwtClaims: base64url decode of the JWT payload for REMOTE_USER header.
26
+ * Crypto verification is Ambassador's job — we only need the sub/username claim.
27
+ */ /**
28
+ * Minimal JWT extraction fallback — used only when the host app does not inject
29
+ * its own getToken. Prefers Authorization: Bearer, then access_token cookie.
30
+ * In gen3-vectis production this is never reached — getAccessToken is always wired.
31
+ */ function extractTokenFallback(req) {
32
+ const auth = req.headers['authorization'];
33
+ if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
34
+ return auth.slice(7).trim() || null;
35
+ }
36
+ const cookie = req.headers['cookie'] ?? '';
37
+ const m = cookie.match(/(?:^|;\s*)access_token=([^;]+)/);
38
+ return m ? decodeURIComponent(m[1]).trim() || null : null;
39
+ }
40
+ /**
41
+ * Decode JWT payload claims without cryptographic verification.
42
+ * Ambassador has already verified the RS256 signature — we just need the sub claim
43
+ * to set the REMOTE_USER header for per-user isolation enforcement downstream.
44
+ */ // Maximum JWT string length accepted before base64 decoding.
45
+ // GA4GH RAS Passport JWTs embed multiple signed Visa JWTs as claims, so the
46
+ // outer passport can easily exceed 8 KiB (50 visas × ~2 KB each ≈ 136 KB encoded).
47
+ // 512 KiB covers the most complex real-world passports while still guarding against
48
+ // crafted JWTs designed to exhaust server memory. Ambassador has already verified
49
+ // the RS256 signature — this cap only defends the local base64/JSON decode step.
50
+ const MAX_JWT_LENGTH = 524288; // 512 KiB
51
+ function decodeJwtClaims(jwt) {
52
+ if (jwt.length > MAX_JWT_LENGTH) return null;
53
+ try {
54
+ const parts = jwt.split('.');
55
+ if (parts.length < 2) return null;
56
+ const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(parts[1].length / 4) * 4, '=');
57
+ return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ async function createProxyRequest({ req, res, targetUrl, method, extraHeaders = {}, forwardBody = false }) {
63
+ const headers = {
64
+ ...extraHeaders
65
+ };
66
+ if (forwardBody && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
67
+ headers['Content-Type'] ??= 'application/json';
68
+ }
69
+ let body;
70
+ if (forwardBody && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
71
+ let buf;
72
+ try {
73
+ buf = await bufferBody(req);
74
+ } catch (err) {
75
+ const statusCode = err.statusCode ?? 400;
76
+ const msg = err instanceof Error ? err.message : 'Bad request';
77
+ res.status(statusCode).json({
78
+ error: msg
79
+ });
80
+ return;
81
+ }
82
+ // Node.js Buffer is a Uint8Array subclass; cast to silence DOM BodyInit mismatch
83
+ body = buf;
84
+ if (buf.length > 0) {
85
+ headers['Content-Length'] = String(buf.length);
86
+ }
87
+ }
88
+ let upRes;
89
+ try {
90
+ upRes = await fetch(targetUrl, {
91
+ method,
92
+ headers,
93
+ body,
94
+ signal: AbortSignal.timeout(30_000)
95
+ });
96
+ } catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ res.status(502).json({
99
+ error: `Upstream unreachable: ${msg}`
100
+ });
101
+ return;
102
+ }
103
+ // Forward status and content-type
104
+ const contentType = upRes.headers.get('content-type') || 'application/json';
105
+ res.setHeader('Content-Type', contentType);
106
+ if (upRes.status === 204 || upRes.status === 304) {
107
+ res.status(upRes.status).end();
108
+ return;
109
+ }
110
+ // Do not forward raw upstream error bodies — they may contain stack traces or
111
+ // internal hostnames. Log them server-side (CloudWatch) and return a generic message.
112
+ if (upRes.status >= 500) {
113
+ const errBody = await upRes.text().catch(()=>'');
114
+ console.error('[proxy] upstream %d from %s: %s', upRes.status, targetUrl, errBody);
115
+ res.status(502).json({
116
+ error: 'Upstream service error.'
117
+ });
118
+ return;
119
+ }
120
+ const responseBody = await upRes.text();
121
+ res.status(upRes.status).send(responseBody);
122
+ }
123
+ // Maximum request body size accepted by the proxy (1 MiB).
124
+ // Prevents memory exhaustion from oversized payloads before any business logic runs.
125
+ const MAX_BODY_BYTES = 1 * 1024 * 1024;
126
+ function bufferBody(req) {
127
+ return new Promise((resolve, reject)=>{
128
+ const chunks = [];
129
+ let total = 0;
130
+ req.on('data', (chunk)=>{
131
+ total += chunk.length;
132
+ if (total > MAX_BODY_BYTES) {
133
+ req.destroy();
134
+ reject(Object.assign(new Error('Request body too large'), {
135
+ statusCode: 413
136
+ }));
137
+ return;
138
+ }
139
+ chunks.push(chunk);
140
+ });
141
+ req.on('end', ()=>resolve(Buffer.concat(chunks)));
142
+ req.on('error', reject);
143
+ });
144
+ }
145
+
16
146
  const MAX_FILE_SIZE_BYTES = 67108864; // 64 * 1024 * 1024
17
147
  // ---------- constants ----------
18
148
  const ALLOWED_TIERS = new Set([
@@ -38,6 +168,14 @@ const MIME_TYPES = {
38
168
  '.ipynb': 'application/json; charset=utf-8'
39
169
  };
40
170
  const DEFAULT_FULL_THEMES_URL = '/workspace-api/workspace-assets/remote/build/themes';
171
+ /**
172
+ * First path segments that identify server extension REST APIs (not static assets).
173
+ * Requests whose first segment matches one of these are proxied to
174
+ * `serverExtensionProxyPath` when that option is configured for the remote tier.
175
+ */ const SERVER_EXTENSION_PREFIXES = new Set([
176
+ 'module',
177
+ 'server-proxy'
178
+ ]);
41
179
  const REMOTE_DISABLED_EXTENSIONS = [
42
180
  '@jupyterlite/services-extension:config-section-manager',
43
181
  '@jupyterlite/services-extension:default-drive',
@@ -178,6 +316,98 @@ function injectBranding(html, branding) {
178
316
  });
179
317
  return replaceTitle(result, branding);
180
318
  }
319
+ // ---------- server-extension proxy ----------
320
+ function readBody$2(req) {
321
+ return new Promise((resolve, reject)=>{
322
+ const chunks = [];
323
+ req.on('data', (chunk)=>chunks.push(chunk));
324
+ req.on('end', ()=>resolve(Buffer.concat(chunks)));
325
+ req.on('error', reject);
326
+ });
327
+ }
328
+ async function proxyServerExtension(req, res, targetUrl, getToken) {
329
+ const forwardHeaders = {};
330
+ for (const [key, value] of Object.entries(req.headers)){
331
+ if (!value) continue;
332
+ const lower = key.toLowerCase();
333
+ if (lower === 'host' || lower === 'content-length' || lower === 'transfer-encoding') continue;
334
+ forwardHeaders[key] = Array.isArray(value) ? value.join(',') : value;
335
+ }
336
+ // Inject identity headers so workspace-proxy auth passes.
337
+ // Direct server-side calls bypass nginx (which normally injects X-Gen3-User-ID /
338
+ // REMOTE_USER from the validated access_token), so we must add them explicitly.
339
+ // only add in production mode
340
+ // TODO: refactor this to handle development mode better
341
+ if (process.env.NODE_ENV === 'production') {
342
+ const jwt = getToken ? getToken(req) : extractTokenFallback(req);
343
+ if (jwt) {
344
+ const claims = decodeJwtClaims(jwt);
345
+ const rawUsername = claims?.sub ?? claims?.preferred_username ?? claims?.username;
346
+ if (typeof rawUsername === 'string') {
347
+ const safeUsername = rawUsername.replace(/[\r\n]/g, '');
348
+ const safeJwt = jwt.replace(/[\r\n]/g, '');
349
+ forwardHeaders['Authorization'] = `Bearer ${safeJwt}`;
350
+ forwardHeaders['REMOTE_USER'] = safeUsername;
351
+ forwardHeaders['X-Gen3-User-ID'] = safeUsername;
352
+ }
353
+ }
354
+ }
355
+ const method = (req.method || 'GET').toUpperCase();
356
+ let body;
357
+ if (![
358
+ 'GET',
359
+ 'HEAD'
360
+ ].includes(method)) {
361
+ try {
362
+ body = await readBody$2(req);
363
+ } catch {
364
+ res.status(500).end('Failed to read request body');
365
+ return;
366
+ }
367
+ if (body?.length) {
368
+ forwardHeaders['content-length'] = String(body.length);
369
+ }
370
+ }
371
+ console.log('[workspace-assets] proxying extension API to', targetUrl);
372
+ await new Promise((resolve)=>{
373
+ const parsed = new URL(targetUrl);
374
+ const transport = parsed.protocol === 'https:' ? https : http;
375
+ const reqOptions = {
376
+ method,
377
+ hostname: parsed.hostname,
378
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
379
+ path: parsed.pathname + parsed.search,
380
+ headers: forwardHeaders
381
+ };
382
+ const proxyReq = transport.request(reqOptions, (proxyRes)=>{
383
+ res.status(proxyRes.statusCode ?? 502);
384
+ const skip = new Set([
385
+ 'transfer-encoding',
386
+ 'content-length'
387
+ ]);
388
+ for (const [key, value] of Object.entries(proxyRes.headers)){
389
+ if (!skip.has(key.toLowerCase()) && value !== undefined) {
390
+ res.setHeader(key, value);
391
+ }
392
+ }
393
+ proxyRes.pipe(res, {
394
+ end: true
395
+ });
396
+ proxyRes.on('end', resolve);
397
+ });
398
+ proxyReq.on('error', (err)=>{
399
+ console.error('[workspace-assets] extension proxy failed for', targetUrl, err.message);
400
+ if (!res.headersSent) {
401
+ res.status(502).end('Extension proxy unreachable');
402
+ }
403
+ resolve();
404
+ });
405
+ if (body?.length) {
406
+ proxyReq.write(body);
407
+ }
408
+ proxyReq.end();
409
+ });
410
+ }
181
411
  // ---------- factory ----------
182
412
  function createWorkspaceAssetsHandler(options) {
183
413
  const gatewayBaseUrl = options?.gatewayBaseUrl ?? process.env.JUPYTER_GATEWAY_BASE_URL ?? '/api/workspace/gateway/';
@@ -193,6 +423,9 @@ function createWorkspaceAssetsHandler(options) {
193
423
  faviconUrl: options.faviconUrl,
194
424
  pageTitle: options.pageTitle
195
425
  } : undefined;
426
+ const serverExtensionProxyPath = options?.serverExtensionProxyPath;
427
+ const serverExtensionProxyBaseUrl = options?.serverExtensionProxyBaseUrl;
428
+ const getToken = options?.getToken;
196
429
  const remoteInjectionConfig = {
197
430
  gatewayBaseUrl,
198
431
  disabledExtensions,
@@ -251,6 +484,21 @@ function createWorkspaceAssetsHandler(options) {
251
484
  }
252
485
  safeSegments.push(nodePath.basename(seg));
253
486
  }
487
+ // Proxy server extension API calls (e.g. jupyterlmod /module/*, jupyter-server-proxy
488
+ // /server-proxy/*) to the configured upstream rather than attempting a static file
489
+ // lookup, which would always 404.
490
+ if (tier === 'remote' && serverExtensionProxyPath && safeSegments.length > 0 && SERVER_EXTENSION_PREFIXES.has(safeSegments[0])) {
491
+ const extensionPath = safeSegments.join('/');
492
+ const rawQuery = req.url?.split('?')[1];
493
+ const qs = rawQuery ? `?${rawQuery}` : '';
494
+ const proxyBase = serverExtensionProxyBaseUrl ?? (()=>{
495
+ const { proto, host } = resolveOrigin(req);
496
+ return `${proto}://${host}`;
497
+ })();
498
+ const targetUrl = `${proxyBase}${serverExtensionProxyPath}/${extensionPath}${qs}`;
499
+ console.log('[workspace-assets] proxying extension API to', targetUrl);
500
+ return proxyServerExtension(req, res, targetUrl, getToken);
501
+ }
254
502
  const allowedFiles = allowedFilesByTier[tier];
255
503
  const candidatePath = nodePath.resolve(tierRoot, ...safeSegments);
256
504
  const candidateIndex = nodePath.join(candidatePath, 'index.html');
@@ -404,134 +652,6 @@ function withJupyterWorkspaces(nextConfig, options) {
404
652
  };
405
653
  }
406
654
 
407
- /**
408
- * Shared HTTP proxy utility for server-side proxy handlers.
409
- * Streams request body through and forwards response status + body.
410
- *
411
- * Also exports two token helpers used by the proxy factories:
412
- * - extractTokenFallback: minimal cookie/header extraction used when the host
413
- * app has not injected its own getToken (standalone / test use).
414
- * - decodeJwtClaims: base64url decode of the JWT payload for REMOTE_USER header.
415
- * Crypto verification is Ambassador's job — we only need the sub/username claim.
416
- */ /**
417
- * Minimal JWT extraction fallback — used only when the host app does not inject
418
- * its own getToken. Prefers Authorization: Bearer, then access_token cookie.
419
- * In gen3-vectis production this is never reached — getAccessToken is always wired.
420
- */ function extractTokenFallback(req) {
421
- const auth = req.headers['authorization'];
422
- if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
423
- return auth.slice(7).trim() || null;
424
- }
425
- const cookie = req.headers['cookie'] ?? '';
426
- const m = cookie.match(/(?:^|;\s*)access_token=([^;]+)/);
427
- return m ? decodeURIComponent(m[1]).trim() || null : null;
428
- }
429
- /**
430
- * Decode JWT payload claims without cryptographic verification.
431
- * Ambassador has already verified the RS256 signature — we just need the sub claim
432
- * to set the REMOTE_USER header for per-user isolation enforcement downstream.
433
- */ // Maximum JWT string length accepted before base64 decoding.
434
- // GA4GH RAS Passport JWTs embed multiple signed Visa JWTs as claims, so the
435
- // outer passport can easily exceed 8 KiB (50 visas × ~2 KB each ≈ 136 KB encoded).
436
- // 512 KiB covers the most complex real-world passports while still guarding against
437
- // crafted JWTs designed to exhaust server memory. Ambassador has already verified
438
- // the RS256 signature — this cap only defends the local base64/JSON decode step.
439
- const MAX_JWT_LENGTH = 524288; // 512 KiB
440
- function decodeJwtClaims(jwt) {
441
- if (jwt.length > MAX_JWT_LENGTH) return null;
442
- try {
443
- const parts = jwt.split('.');
444
- if (parts.length < 2) return null;
445
- const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(parts[1].length / 4) * 4, '=');
446
- return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
447
- } catch {
448
- return null;
449
- }
450
- }
451
- async function createProxyRequest({ req, res, targetUrl, method, extraHeaders = {}, forwardBody = false }) {
452
- const headers = {
453
- ...extraHeaders
454
- };
455
- if (forwardBody && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
456
- headers['Content-Type'] ??= 'application/json';
457
- }
458
- let body;
459
- if (forwardBody && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
460
- let buf;
461
- try {
462
- buf = await bufferBody(req);
463
- } catch (err) {
464
- const statusCode = err.statusCode ?? 400;
465
- const msg = err instanceof Error ? err.message : 'Bad request';
466
- res.status(statusCode).json({
467
- error: msg
468
- });
469
- return;
470
- }
471
- // Node.js Buffer is a Uint8Array subclass; cast to silence DOM BodyInit mismatch
472
- body = buf;
473
- if (buf.length > 0) {
474
- headers['Content-Length'] = String(buf.length);
475
- }
476
- }
477
- let upRes;
478
- try {
479
- upRes = await fetch(targetUrl, {
480
- method,
481
- headers,
482
- body,
483
- signal: AbortSignal.timeout(30_000)
484
- });
485
- } catch (err) {
486
- const msg = err instanceof Error ? err.message : String(err);
487
- res.status(502).json({
488
- error: `Upstream unreachable: ${msg}`
489
- });
490
- return;
491
- }
492
- // Forward status and content-type
493
- const contentType = upRes.headers.get('content-type') || 'application/json';
494
- res.setHeader('Content-Type', contentType);
495
- if (upRes.status === 204 || upRes.status === 304) {
496
- res.status(upRes.status).end();
497
- return;
498
- }
499
- // Do not forward raw upstream error bodies — they may contain stack traces or
500
- // internal hostnames. Log them server-side (CloudWatch) and return a generic message.
501
- if (upRes.status >= 500) {
502
- const errBody = await upRes.text().catch(()=>'');
503
- console.error('[proxy] upstream %d from %s: %s', upRes.status, targetUrl, errBody);
504
- res.status(502).json({
505
- error: 'Upstream service error.'
506
- });
507
- return;
508
- }
509
- const responseBody = await upRes.text();
510
- res.status(upRes.status).send(responseBody);
511
- }
512
- // Maximum request body size accepted by the proxy (1 MiB).
513
- // Prevents memory exhaustion from oversized payloads before any business logic runs.
514
- const MAX_BODY_BYTES = 1 * 1024 * 1024;
515
- function bufferBody(req) {
516
- return new Promise((resolve, reject)=>{
517
- const chunks = [];
518
- let total = 0;
519
- req.on('data', (chunk)=>{
520
- total += chunk.length;
521
- if (total > MAX_BODY_BYTES) {
522
- req.destroy();
523
- reject(Object.assign(new Error('Request body too large'), {
524
- statusCode: 413
525
- }));
526
- return;
527
- }
528
- chunks.push(chunk);
529
- });
530
- req.on('end', ()=>resolve(Buffer.concat(chunks)));
531
- req.on('error', reject);
532
- });
533
- }
534
-
535
655
  /* ------------------------------------------------------------------ */ /* Default billing sink — structured CloudWatch-compatible JSON log */ /* ------------------------------------------------------------------ */ function defaultBillingSink(event) {
536
656
  // CloudWatch Logs Insights picks up structured JSON automatically
537
657
  process.stdout.write(JSON.stringify({
@@ -1059,7 +1179,16 @@ const DEFAULT_OPTIONS = {
1059
1179
  // launches/channels to the container; GPU kernel launches are gated (403)
1060
1180
  // with a message to use the Kernel Panel.
1061
1181
  remoteKernelsPath: '/lw-workspace/proxy/jeg-proxy',
1062
- // The remote tier attaches to a real kernel gateway, so JupyterLite's
1182
+ // Proxy server extension REST calls (jupyterlmod /module/*, jupyter-server-proxy
1183
+ // /server-proxy/*) to jeg-proxy. These calls originate from JupyterLab extensions
1184
+ // in the remote build and resolve relative to the JupyterLite base URL, which points
1185
+ // at this static-file handler — without a proxy they always 404.
1186
+ serverExtensionProxyPath: '/lw-workspace/proxy',
1187
+ // Optional internal k8s service base URL for the extension proxy. When set,
1188
+ // proxy requests target this address directly instead of routing through the
1189
+ // ingress (avoiding self-signed-cert issues). Readable from the JSON config
1190
+ // file or the WORKSPACE_EXTENSION_PROXY_BASE_URL env var.
1191
+ serverExtensionProxyBaseUrl: 'http://workspace-proxy-service:8080',
1063
1192
  // in-browser Pyodide kernel must not be offered alongside it.
1064
1193
  additionalDisabledExtensions: [
1065
1194
  '@jupyterlite/pyodide-kernel-extension:kernel'
@@ -1073,7 +1202,9 @@ const STRING_OPTIONS = [
1073
1202
  'faviconUrl',
1074
1203
  'pageTitle',
1075
1204
  'remoteKernelsPath',
1076
- 'fullThemesUrl'
1205
+ 'fullThemesUrl',
1206
+ 'serverExtensionProxyPath',
1207
+ 'serverExtensionProxyBaseUrl'
1077
1208
  ];
1078
1209
  function configFilePath() {
1079
1210
  return nodePath.resolve(process.cwd(), CONFIG_ROOT, core.GEN3_COMMONS_NAME, CONFIG_FILENAME);