From 367947ca9ec8cdb1c65b344624ab79685df64821 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Wed, 6 May 2026 06:32:45 -0700 Subject: [PATCH 01/16] #IDP-41 Add DataUploadDialog component and integrate with PageLayout for data uploads --- .../src/components/DataUploadDialog.tsx | 384 ++++++++++++++++++ .../frontend/src/components/PageLayout.tsx | 8 +- .../use-cases/createAndUploadWorkspace.ts | 18 +- 3 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 applications/idp-arc/frontend/src/components/DataUploadDialog.tsx diff --git a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx new file mode 100644 index 0000000..b554352 --- /dev/null +++ b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx @@ -0,0 +1,384 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + Box, + Button, + CircularProgress, + Dialog, + IconButton, + MenuItem, + Select, + Stack, + Typography, +} from '@mui/material' +import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlineOutlined' +import CloseIcon from '@mui/icons-material/Close' +import ArrowForwardIcon from '@mui/icons-material/ArrowForward' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' + +import { createAndUpload, loadWorkspaces } from '../app/container' +import { useAppContext } from '../AppContext' +import type { Workspace } from '../core/types' +import protocols from '../data/protocols.json' + +type DialogStep = 'select' | 'upload' | 'uploading' | 'success' + +export interface DataUploadDialogProps { + open: boolean + onClose: () => void +} + +export default function DataUploadDialog({ open, onClose }: DataUploadDialogProps) { + const { tokenParsed } = useAppContext() + + interface FormState { + step: DialogStep + behavioralTask: string + protocol: string + workspaceId: string | number + file: File | null + isDragging: boolean + uploadMessage: string + } + + const INITIAL_FORM: FormState = { + step: 'select', + behavioralTask: '', + protocol: '', + workspaceId: '', + file: null, + isDragging: false, + uploadMessage: '', + } + + const [form, setForm] = useState(INITIAL_FORM) + const { step, behavioralTask, protocol, workspaceId, file, isDragging, uploadMessage } = form + const [workspaces, setWorkspaces] = useState([]) + const [loadingWorkspaces, setLoadingWorkspaces] = useState(false) + const fileInputRef = useRef(null) + const abortRef = useRef(false) + + useEffect(() => { + if (!open) return + setForm(INITIAL_FORM) + abortRef.current = false + setLoadingWorkspaces(true) + loadWorkspaces() + .then(setWorkspaces) + .catch(console.error) + .finally(() => setLoadingWorkspaces(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + const dropped = e.dataTransfer.files[0] + setForm((prev) => ({ ...prev, isDragging: false, file: dropped ?? prev.file })) + }, []) + + const handleUpload = async () => { + if (!file) return + setForm((prev) => ({ ...prev, step: 'uploading' })) + abortRef.current = false + + const numericId = typeof workspaceId === 'string' ? parseInt(workspaceId, 10) : workspaceId + const selectedWorkspace = workspaces.find(w => w.id === workspaceId) + + await createAndUpload( + { + workspaceName: selectedWorkspace?.name ?? 'New Workspace', + workspaceId: !isNaN(numericId as number) ? (numericId as number) : undefined, + file, + userId: tokenParsed?.sub as string, + }, + (state) => { + setForm((prev) => ({ + ...prev, + uploadMessage: state.message, + ...(state.phase === 'done' ? { step: 'success' } : {}), + })) + }, + abortRef, + ) + } + + const stepIndex = step === 'select' ? 0 : 1 + const canGoNext = !!behavioralTask && !!protocol && workspaceId !== '' + const canUpload = !!file + + return ( + + {/* ── Header ─────────────────────────────────────────────────────── */} + + + + + {[0, 1].map((i) => ( + + ))} + + + + {tokenParsed && ( + + )} + + + + + + + {/* ── Title & subtitle ───────────────────────────────────────────── */} + {step === 'select' ? ( + + + Select behavioral task + + + Select a behavioral task, protocol and workspace + + + ) : ( + + + Upload your data + + {step === 'upload' && ( + + Upload your data to contribute to multimodal neurophysiology research and visualize it on the Open Source Brain platform. + + )} + + )} + + {/* ── Content ────────────────────────────────────────────────────── */} + + + {/* Step 1 — select */} + {step === 'select' && ( + + + + Behavioral task + + + + + Protocol + + + + + Open Source Brain workspace + + + + + + Protocol docs + + Download protocol docs based on your experiment type. + + + + + )} + + {/* Step 2 — file upload */} + {step === 'upload' && ( + + + setForm((prev) => ({ ...prev, file: e.target.files?.[0] ?? null }))} + /> + fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setForm((prev) => ({ ...prev, isDragging: true })) }} + onDragLeave={() => setForm((prev) => ({ ...prev, isDragging: false }))} + onDrop={handleDrop} + sx={{ + height: '100%', + border: '1px solid', + borderColor: isDragging ? 'primary.main' : 'divider', + borderRadius: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + bgcolor: isDragging ? 'action.hover' : 'transparent', + transition: 'border-color 0.2s, background-color 0.2s', + gap: 1, + }} + > + {file ? ( + + {file.name} ({(file.size / 1024).toFixed(1)} KB) + + ) : ( + <> + + + Click here or drag file to upload (.zip) + + + )} + + + + + Protocol template + + Download protocol template based on your experiment type. + + + + + )} + + {/* Loading */} + {step === 'uploading' && ( + + + {uploadMessage || 'Uploading your data..'} + + + )} + + {/* Success */} + {step === 'success' && ( + + + + Your files has been successfully uploaded to Open Source Brain. + + + You can close this dialog. + + + )} + + + {/* ── Footer ─────────────────────────────────────────────────────── */} + {step === 'select' && ( + + + + )} + {step === 'upload' && ( + + + + )} + + ) +} diff --git a/applications/idp-arc/frontend/src/components/PageLayout.tsx b/applications/idp-arc/frontend/src/components/PageLayout.tsx index 78dcc42..05b188c 100644 --- a/applications/idp-arc/frontend/src/components/PageLayout.tsx +++ b/applications/idp-arc/frontend/src/components/PageLayout.tsx @@ -28,6 +28,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { authClient } from '../app/container' import { useAppContext } from '../AppContext' import { Logo } from '../Icons' +import DataUploadDialog from './DataUploadDialog' const ArrowIcon = () => @@ -54,6 +55,7 @@ export default function PageLayout({ const avatarMenuOpen = Boolean(avatarAnchor) const [waitingForLogin, setWaitingForLogin] = useState(false) const popupRef = useRef(null) + const [uploadDialogOpen, setUploadDialogOpen] = useState(false) async function handleLogin() { const loginUrl = await authClient.getLoginUrl(`${window.location.origin}/`) @@ -268,7 +270,7 @@ export default function PageLayout({ @@ -367,8 +369,8 @@ export default function PageLayout({ variant="contained" endIcon={} onClick={() => { - navigate('/workspaces') setDrawerOpen(false) + setUploadDialogOpen(true) }} > {t('nav.dataUpload')} @@ -446,6 +448,8 @@ export default function PageLayout({ + setUploadDialogOpen(false)} /> + {t('nav.signingIn')} diff --git a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts index d0da2a7..fba75c4 100644 --- a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts +++ b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts @@ -12,6 +12,8 @@ export interface CreateAndUploadInput { file: File /** Subject claim (`sub`) from the token payload — identifies the JupyterHub user. */ userId: string + /** When provided the workspace creation step is skipped and the file is uploaded to this workspace. */ + workspaceId?: number /** * Called synchronously the moment the workspace id is known (right after * Step 1, before the long PVC wait). Use this to open the workspace tab @@ -49,15 +51,17 @@ export function createCreateAndUploadUseCase( const { workspaceName, file, userId, onWorkspaceCreated } = input try { - // ── Step 1: Create workspace ────────────────────────────────────────── - onProgress({ phase: 'creating', message: PHASE_LABELS.creating }) const token = await auth.getToken(30) - const wsId = await workspaceApi.createWorkspace(token, workspaceName) - // Notify the caller immediately — this fires before the long PVC wait - // so it is still within the browser's user-gesture async chain, which - // means window.open() passed here will NOT be blocked as a popup. - onWorkspaceCreated?.(wsId) + // ── Step 1: Create workspace (skipped when uploading to an existing one) ─ + let wsId: number + if (input.workspaceId) { + wsId = input.workspaceId + } else { + onProgress({ phase: 'creating', message: PHASE_LABELS.creating }) + wsId = await workspaceApi.createWorkspace(token, workspaceName) + onWorkspaceCreated?.(wsId) + } if (abortRef.current) return null From 2ebf3bfd25220f66bfda8b1a7e11fe33b9ab15d1 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Fri, 8 May 2026 11:59:37 -0700 Subject: [PATCH 02/16] #IDP-41 Update JupyterApiClient to include hubBase parameter and adjust fetch URLs --- applications/idp-arc/frontend/src/app/container.ts | 3 ++- .../idp-arc/frontend/src/infra/jupyterApiClient.ts | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/applications/idp-arc/frontend/src/app/container.ts b/applications/idp-arc/frontend/src/app/container.ts index 32379ed..25f42cf 100644 --- a/applications/idp-arc/frontend/src/app/container.ts +++ b/applications/idp-arc/frontend/src/app/container.ts @@ -31,6 +31,7 @@ const WORKSPACES_API = `${WWW_BASE}/proxy/workspaces/api` const WORKSPACES_LIST_URL = `${WWW_BASE}/proxy/workspaces/api/workspace?page=1&per_page=24&q=&tags=` const JUPYTER_BASE = `https://lab.${BASE_DOMAIN}` +const HUB_BASE = `https://www.${BASE_DOMAIN}` const FRONTEND_BASE = `https://www.${BASE_DOMAIN}` // ─── Infrastructure singletons ──────────────────────────────────────────────── @@ -42,7 +43,7 @@ export const authClient = new KeycloakAuthClient({ }) const workspaceApi = new WorkspaceApiClient(WORKSPACES_API, WORKSPACES_LIST_URL) -const jupyterApi = new JupyterApiClient(JUPYTER_BASE, BASE_DOMAIN) +const jupyterApi = new JupyterApiClient(JUPYTER_BASE, HUB_BASE, BASE_DOMAIN) // ─── Use-cases (injected with their concrete dependencies) ──────────────────── diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 7dacf9f..6a35cd9 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -15,6 +15,7 @@ import type { IJupyterApi } from '../core/ports/IJupyterApi' export class JupyterApiClient implements IJupyterApi { constructor( private readonly jupyterBase: string, // e.g. "https://lab.v2dev.opensourcebrain.org" + private readonly hubBase: string, // e.g. "https://www.v2dev.opensourcebrain.org" private readonly baseDomain: string, // e.g. "v2dev.opensourcebrain.org" ) {} @@ -23,7 +24,7 @@ export class JupyterApiClient implements IJupyterApi { document.cookie = `accessToken=${token};path=/;domain=.${this.baseDomain};SameSite=Lax;Secure` // Fire-and-forget — no-cors is intentional, we only need to trigger auth + spawn void fetch( - `${this.jupyterBase}/hub/chlogin?next=%2Fhub%2Fspawn%2F${userId}%2F${serverName}`, + `${this.hubBase}/hub/chlogin?next=%2Fhub%2Fspawn%2F${userId}%2F${serverName}`, { credentials: 'include', mode: 'no-cors' }, ) } @@ -40,8 +41,9 @@ export class JupyterApiClient implements IJupyterApi { try { const probe = await fetch(contentsUrl, { credentials: 'include' }) if (probe.ok) return true - // Any non-502/503 response means the server replied and is not in a transient retry state — stop waiting - if (probe.status !== 503 && probe.status !== 502) break + // 404 = named server not yet registered in the proxy (transient during spawn) + // 502/503 = server starting up; anything else is a definitive failure + if (probe.status !== 503 && probe.status !== 502 && probe.status !== 404) break } catch { // Network / CORS error — keep retrying } From f4565da79b4bd1565d3f43c26b61fe79ab35c587 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Fri, 8 May 2026 14:08:26 -0700 Subject: [PATCH 03/16] #IDP-41 Refactor JupyterApiClient to remove hubBase parameter and update fetch URLs; enhance Nginx configuration for cross-domain cookie handling --- .../idp-arc/deploy/resources/default.conf | 37 +++++++++++++++++++ .../idp-arc/frontend/src/app/container.ts | 5 +-- .../frontend/src/infra/jupyterApiClient.ts | 21 ++++++----- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index 3bc7e97..d45121d 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -1,9 +1,46 @@ +# When the frontend passes accessToken as a URL param (cross-domain cookie +# writes are blocked by browsers), Nginx injects it as a Cookie header in +# the forwarded request so chkclogin can read it. +# +# Two-case map: if the URL has accessToken, use only that cookie (browser has +# no JupyterHub cookies yet at this point anyway); otherwise forward the +# browser's existing cookies unchanged (for polling and upload requests). +map $arg_accessToken $proxy_cookie { + "" $http_cookie; + default "accessToken=$arg_accessToken"; +} + server { listen 8080; root /usr/share/nginx/html; index index.html; + # JupyterHub reverse proxy. + # Bridges the cross-domain cookie restriction: browser cannot set cookies for + # lab.v2dev.opensourcebrain.org from a different origin, but Nginx can inject + # them server-side. Set-Cookie domains are rewritten so session cookies are + # stored for this host and carried on subsequent polling / upload requests. + location /jupyter-proxy/ { + proxy_pass https://lab.v2dev.opensourcebrain.org/; + proxy_http_version 1.1; + + proxy_set_header Host lab.v2dev.opensourcebrain.org; + proxy_set_header Cookie $proxy_cookie; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_ssl_server_name on; + + # Rewrite Set-Cookie so the browser stores session cookies for this host. + proxy_cookie_domain lab.v2dev.opensourcebrain.org $host; + + # Rewrite Location redirect headers to keep subsequent requests in the proxy. + proxy_redirect https://lab.v2dev.opensourcebrain.org/ /jupyter-proxy/; + proxy_redirect / /jupyter-proxy/; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/applications/idp-arc/frontend/src/app/container.ts b/applications/idp-arc/frontend/src/app/container.ts index 25f42cf..a5131aa 100644 --- a/applications/idp-arc/frontend/src/app/container.ts +++ b/applications/idp-arc/frontend/src/app/container.ts @@ -30,8 +30,7 @@ const WWW_BASE = import.meta.env.DEV ? '/api-proxy' : `https://www.${BASE_ const WORKSPACES_API = `${WWW_BASE}/proxy/workspaces/api` const WORKSPACES_LIST_URL = `${WWW_BASE}/proxy/workspaces/api/workspace?page=1&per_page=24&q=&tags=` -const JUPYTER_BASE = `https://lab.${BASE_DOMAIN}` -const HUB_BASE = `https://www.${BASE_DOMAIN}` +const JUPYTER_BASE = '/jupyter-proxy' const FRONTEND_BASE = `https://www.${BASE_DOMAIN}` // ─── Infrastructure singletons ──────────────────────────────────────────────── @@ -43,7 +42,7 @@ export const authClient = new KeycloakAuthClient({ }) const workspaceApi = new WorkspaceApiClient(WORKSPACES_API, WORKSPACES_LIST_URL) -const jupyterApi = new JupyterApiClient(JUPYTER_BASE, HUB_BASE, BASE_DOMAIN) +const jupyterApi = new JupyterApiClient(JUPYTER_BASE) // ─── Use-cases (injected with their concrete dependencies) ──────────────────── diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 6a35cd9..177a684 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -14,17 +14,17 @@ import type { IJupyterApi } from '../core/ports/IJupyterApi' */ export class JupyterApiClient implements IJupyterApi { constructor( - private readonly jupyterBase: string, // e.g. "https://lab.v2dev.opensourcebrain.org" - private readonly hubBase: string, // e.g. "https://www.v2dev.opensourcebrain.org" - private readonly baseDomain: string, // e.g. "v2dev.opensourcebrain.org" + private readonly jupyterBase: string, // e.g. "/jupyter-proxy" ) {} triggerSpawn(token: string, userId: string, serverName: string): void { - // Set the session cookie so JupyterHub can authenticate the user - document.cookie = `accessToken=${token};path=/;domain=.${this.baseDomain};SameSite=Lax;Secure` - // Fire-and-forget — no-cors is intentional, we only need to trigger auth + spawn + // chkclogin reads 'kc-access' or 'accessToken' cookie. From a cross-domain origin + // (e.g. metacell.us → opensourcebrain.org) the browser blocks cookie writes, so the + // cookie approach fails silently. We pass the token as a URL param instead — the + // Nginx reverse proxy injects it as a Cookie header before forwarding to JupyterHub. + const next = encodeURIComponent(`/hub/spawn/${userId}/${serverName}`) void fetch( - `${this.hubBase}/hub/chlogin?next=%2Fhub%2Fspawn%2F${userId}%2F${serverName}`, + `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}&next=${next}`, { credentials: 'include', mode: 'no-cors' }, ) } @@ -39,13 +39,16 @@ export class JupyterApiClient implements IJupyterApi { while (!abortRef.current && Date.now() < deadlineMs) { try { - const probe = await fetch(contentsUrl, { credentials: 'include' }) + const probe = await fetch(contentsUrl, { + credentials: 'include', + redirect: 'error', // treat auth redirects as "not ready" rather than looping + }) if (probe.ok) return true // 404 = named server not yet registered in the proxy (transient during spawn) // 502/503 = server starting up; anything else is a definitive failure if (probe.status !== 503 && probe.status !== 502 && probe.status !== 404) break } catch { - // Network / CORS error — keep retrying + // Network / CORS error or redirect — keep retrying } await sleep(4_000) } From 345e0dc39ec3acc72a6dee53c8203143b32c3eb5 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Fri, 8 May 2026 14:41:36 -0700 Subject: [PATCH 04/16] Enhance Nginx configuration for proxy redirects to use explicit HTTPS; update JupyterApiClient to handle fetch errors gracefully --- applications/idp-arc/deploy/resources/default.conf | 6 ++++-- applications/idp-arc/frontend/src/infra/jupyterApiClient.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index d45121d..fe399b8 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -37,8 +37,10 @@ server { proxy_cookie_domain lab.v2dev.opensourcebrain.org $host; # Rewrite Location redirect headers to keep subsequent requests in the proxy. - proxy_redirect https://lab.v2dev.opensourcebrain.org/ /jupyter-proxy/; - proxy_redirect / /jupyter-proxy/; + # Use explicit https://$host to avoid Nginx constructing an http://:8080 URL + # (which would be blocked as mixed content when the page is served over HTTPS). + proxy_redirect https://lab.v2dev.opensourcebrain.org/ https://$host/jupyter-proxy/; + proxy_redirect / https://$host/jupyter-proxy/; } location / { diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 177a684..54e4521 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -26,7 +26,7 @@ export class JupyterApiClient implements IJupyterApi { void fetch( `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}&next=${next}`, { credentials: 'include', mode: 'no-cors' }, - ) + ).catch(() => {}) } async waitUntilReady( From d403dd7014f722835ad89585d87f2584992c7582 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Mon, 11 May 2026 21:42:28 -0700 Subject: [PATCH 05/16] Enhance Nginx configuration to rewrite cookie paths for auth cookies; update JupyterApiClient to handle spawn requests with manual redirects --- .../idp-arc/deploy/resources/default.conf | 3 +++ .../frontend/src/infra/jupyterApiClient.ts | 26 +++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index fe399b8..a4382bd 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -35,6 +35,9 @@ server { # Rewrite Set-Cookie so the browser stores session cookies for this host. proxy_cookie_domain lab.v2dev.opensourcebrain.org $host; + # Rewrite cookie Path so auth cookies scoped to /hub (or /user/...) are sent + # on /jupyter-proxy/hub (or /jupyter-proxy/user/...) requests. + proxy_cookie_path / /jupyter-proxy/; # Rewrite Location redirect headers to keep subsequent requests in the proxy. # Use explicit https://$host to avoid Nginx constructing an http://:8080 URL diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 54e4521..0f21b33 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -18,15 +18,25 @@ export class JupyterApiClient implements IJupyterApi { ) {} triggerSpawn(token: string, userId: string, serverName: string): void { - // chkclogin reads 'kc-access' or 'accessToken' cookie. From a cross-domain origin - // (e.g. metacell.us → opensourcebrain.org) the browser blocks cookie writes, so the - // cookie approach fails silently. We pass the token as a URL param instead — the - // Nginx reverse proxy injects it as a Cookie header before forwarding to JupyterHub. - const next = encodeURIComponent(`/hub/spawn/${userId}/${serverName}`) + // Two-step fire-and-forget: + // 1. chkclogin — Nginx injects the accessToken URL param as a Cookie header so + // JupyterHub can validate it. redirect:'manual' stops at the 302 response so we + // don't chase the login → OAuth → spawn-pending redirect chain, but the browser + // still stores the Set-Cookie from that 302 (the JupyterHub auth cookie). + // 2. spawn — tells JupyterHub to start the named server. redirect:'manual' again + // stops us from following the spawn-pending polling loop; waitUntilReady handles + // readiness independently. void fetch( - `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}&next=${next}`, - { credentials: 'include', mode: 'no-cors' }, - ).catch(() => {}) + `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}`, + { credentials: 'include', redirect: 'manual' }, + ) + .then(() => + fetch( + `${this.jupyterBase}/hub/spawn/${userId}/${serverName}`, + { credentials: 'include', redirect: 'manual' }, + ), + ) + .catch(() => {}) } async waitUntilReady( From 66e4e9cb547460eccdda1cfa3e698df81667c63f Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 May 2026 08:12:30 -0700 Subject: [PATCH 06/16] Enhance Nginx configuration to rewrite cookie paths for session cookies, ensuring they are sent for all /jupyter-proxy/ requests to prevent 403 errors on the Contents API. --- .../idp-arc/deploy/resources/default.conf | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index a4382bd..8afaa59 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -35,9 +35,19 @@ server { # Rewrite Set-Cookie so the browser stores session cookies for this host. proxy_cookie_domain lab.v2dev.opensourcebrain.org $host; - # Rewrite cookie Path so auth cookies scoped to /hub (or /user/...) are sent - # on /jupyter-proxy/hub (or /jupyter-proxy/user/...) requests. - proxy_cookie_path / /jupyter-proxy/; + # Rewrite cookie Path so session cookies reach both /hub and /user endpoints. + # + # JupyterHub sets the auth session cookie with Path=/hub (hub.base_url). + # After prefixing to /jupyter-proxy/hub it would NOT be sent to + # /jupyter-proxy/user/… — causing 403 on the Contents API. + # Fix: expand any hub-scoped path to the full proxy root so the cookie is + # sent for ALL /jupyter-proxy/… requests. + # + # Directive 1: /hub or /hub/… → /jupyter-proxy/ (hub cookies go everywhere) + proxy_cookie_path ~^/hub(/.*)?$ /jupyter-proxy/; + # Directive 2: everything else → prefix with /jupyter-proxy/ + # The (?!hub|jupyter-proxy) guard prevents double-transformation. + proxy_cookie_path ~^/(?!hub|jupyter-proxy)(.*) /jupyter-proxy/$1; # Rewrite Location redirect headers to keep subsequent requests in the proxy. # Use explicit https://$host to avoid Nginx constructing an http://:8080 URL From 04a51f8854dd603328dc4827e06e8c713897dbb0 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 May 2026 09:16:51 -0700 Subject: [PATCH 07/16] Refactor JupyterApiClient and related interfaces to support async triggerSpawn; update DataUploadDialog for workspace name handling and improve user prompts in the UI. --- .../src/components/DataUploadDialog.tsx | 9 +-- .../frontend/src/core/ports/IJupyterApi.ts | 2 +- .../use-cases/createAndUploadWorkspace.ts | 2 +- .../frontend/src/infra/jupyterApiClient.ts | 57 +++++++++++++------ .../src/infra/mocks/mockJupyterApiClient.ts | 2 +- 5 files changed, 47 insertions(+), 25 deletions(-) diff --git a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx index b554352..ad943f1 100644 --- a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx +++ b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx @@ -83,10 +83,11 @@ export default function DataUploadDialog({ open, onClose }: DataUploadDialogProp const numericId = typeof workspaceId === 'string' ? parseInt(workspaceId, 10) : workspaceId const selectedWorkspace = workspaces.find(w => w.id === workspaceId) + const newWorkspaceName = [behavioralTask, protocol].filter(Boolean).join(' — ') || 'New Workspace' await createAndUpload( { - workspaceName: selectedWorkspace?.name ?? 'New Workspace', + workspaceName: selectedWorkspace?.name ?? newWorkspaceName, workspaceId: !isNaN(numericId as number) ? (numericId as number) : undefined, file, userId: tokenParsed?.sub as string, @@ -103,7 +104,7 @@ export default function DataUploadDialog({ open, onClose }: DataUploadDialogProp } const stepIndex = step === 'select' ? 0 : 1 - const canGoNext = !!behavioralTask && !!protocol && workspaceId !== '' + const canGoNext = !!behavioralTask && !!protocol const canUpload = !!file return ( @@ -171,7 +172,7 @@ export default function DataUploadDialog({ open, onClose }: DataUploadDialogProp Select behavioral task - Select a behavioral task, protocol and workspace + Select a behavioral task and protocol. Optionally pick an existing workspace, or leave it empty to create a new one. ) : ( @@ -235,7 +236,7 @@ export default function DataUploadDialog({ open, onClose }: DataUploadDialogProp displayEmpty sx={{ fontFamily: 'Inter, sans-serif', fontWeight: 400, fontSize: '14px', lineHeight: '22px' }} renderValue={(v) => { - if (!v && v !== 0) return Select workspace to upload data to.. + if (!v && v !== 0) return Leave empty to create a new workspace return workspaces.find(w => w.id === v)?.name ?? String(v) }} > diff --git a/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts b/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts index 164705a..8cf2cb7 100644 --- a/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts +++ b/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts @@ -12,7 +12,7 @@ export interface IJupyterApi { * @param userId Subject claim from the token (used in hub URL). * @param serverName Named-server identifier (e.g. `"42lab"`). */ - triggerSpawn(token: string, userId: string, serverName: string): void + triggerSpawn(token: string, userId: string, serverName: string): Promise /** * Sets the session cookie required by JupyterHub and fires the spawn trigger. diff --git a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts index fba75c4..cc4528f 100644 --- a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts +++ b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts @@ -70,7 +70,7 @@ export function createCreateAndUploadUseCase( onProgress({ phase: 'spawning', message: PHASE_LABELS.spawning, workspaceId: wsId }) const spawnToken = await auth.getToken(30) - jupyterApi.triggerSpawn(spawnToken, userId, serverName) + await jupyterApi.triggerSpawn(spawnToken, userId, serverName) // Wait 30 s for the PVC to initialise before polling for (let i = 30; i > 0; i--) { diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 0f21b33..842a64c 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -13,30 +13,46 @@ import type { IJupyterApi } from '../core/ports/IJupyterApi' * Use-cases only see IJupyterApi — they have no idea these browser APIs exist. */ export class JupyterApiClient implements IJupyterApi { + private jupyterToken: string | null = null + constructor( private readonly jupyterBase: string, // e.g. "/jupyter-proxy" ) {} - triggerSpawn(token: string, userId: string, serverName: string): void { - // Two-step fire-and-forget: - // 1. chkclogin — Nginx injects the accessToken URL param as a Cookie header so - // JupyterHub can validate it. redirect:'manual' stops at the 302 response so we - // don't chase the login → OAuth → spawn-pending redirect chain, but the browser - // still stores the Set-Cookie from that 302 (the JupyterHub auth cookie). - // 2. spawn — tells JupyterHub to start the named server. redirect:'manual' again - // stops us from following the spawn-pending polling loop; waitUntilReady handles - // readiness independently. - void fetch( + async triggerSpawn(token: string, userId: string, serverName: string): Promise { + // Step 1: chkclogin — Nginx injects accessToken as Cookie header so JupyterHub + // validates it and sets the jupyterhub-hub-login session cookie. + // redirect:'manual' stops at the 302; the browser still stores Set-Cookie from it. + await fetch( `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}`, { credentials: 'include', redirect: 'manual' }, - ) - .then(() => - fetch( - `${this.jupyterBase}/hub/spawn/${userId}/${serverName}`, - { credentials: 'include', redirect: 'manual' }, - ), + ).catch(() => {}) + + // Step 2: Obtain a JupyterHub API token using the hub session cookie. + // This token bypasses the per-server OAuth flow that cannot complete through the + // proxy (Keycloak callback URL is hardcoded to lab.v2dev.opensourcebrain.org). + try { + const res = await fetch( + `${this.jupyterBase}/hub/api/users/${userId}/tokens`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: 'idp-arc upload' }), + }, ) - .catch(() => {}) + if (res.ok) { + const data = await res.json() as { token: string } + this.jupyterToken = data.token + } + } catch {} + + // Step 3: Spawn the named server. redirect:'manual' stops before the + // spawn-pending polling loop; waitUntilReady handles readiness independently. + await fetch( + `${this.jupyterBase}/hub/spawn/${userId}/${serverName}`, + { credentials: 'include', redirect: 'manual' }, + ).catch(() => {}) } async waitUntilReady( @@ -52,6 +68,7 @@ export class JupyterApiClient implements IJupyterApi { const probe = await fetch(contentsUrl, { credentials: 'include', redirect: 'error', // treat auth redirects as "not ready" rather than looping + headers: this.authHeaders(), }) if (probe.ok) return true // 404 = named server not yet registered in the proxy (transient during spawn) @@ -73,7 +90,7 @@ export class JupyterApiClient implements IJupyterApi { { method: 'PUT', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, body: JSON.stringify({ name: file.name, path: file.name, @@ -87,6 +104,10 @@ export class JupyterApiClient implements IJupyterApi { throw new Error(`Upload failed: ${res.status} ${res.statusText}`) } } + + private authHeaders(): Record { + return this.jupyterToken ? { Authorization: `token ${this.jupyterToken}` } : {} + } } function sleep(ms: number): Promise { diff --git a/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts index 83319af..546d154 100644 --- a/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts @@ -8,7 +8,7 @@ import type { IJupyterApi } from '../../core/ports/IJupyterApi' * • uploadFile() logs and resolves immediately */ export class MockJupyterApiClient implements IJupyterApi { - triggerSpawn(_token: string, userId: string, serverName: string): void { + async triggerSpawn(_token: string, userId: string, serverName: string): Promise { console.info( `[MockJupyterApiClient] triggerSpawn(userId="${userId}", serverName="${serverName}")`, ) From 8b29242e91f7645e42f373abaac8b0b55dffccc3 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 May 2026 10:20:53 -0700 Subject: [PATCH 08/16] Refactor DataUploadDialog to improve workspace ID handling; ensure workspaceId is resolved correctly for uploads --- .../idp-arc/frontend/src/components/DataUploadDialog.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx index ad943f1..5c96d44 100644 --- a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx +++ b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx @@ -81,14 +81,16 @@ export default function DataUploadDialog({ open, onClose }: DataUploadDialogProp setForm((prev) => ({ ...prev, step: 'uploading' })) abortRef.current = false - const numericId = typeof workspaceId === 'string' ? parseInt(workspaceId, 10) : workspaceId - const selectedWorkspace = workspaces.find(w => w.id === workspaceId) + const selectedWorkspace = workspaces.find(w => String(w.id) === String(workspaceId)) const newWorkspaceName = [behavioralTask, protocol].filter(Boolean).join(' — ') || 'New Workspace' + const resolvedId = selectedWorkspace + ? (typeof selectedWorkspace.id === 'string' ? parseInt(selectedWorkspace.id, 10) : selectedWorkspace.id) + : undefined await createAndUpload( { workspaceName: selectedWorkspace?.name ?? newWorkspaceName, - workspaceId: !isNaN(numericId as number) ? (numericId as number) : undefined, + workspaceId: resolvedId, file, userId: tokenParsed?.sub as string, }, From 99de9df6572b0ba337e730847e34b15c79dd80fa Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 May 2026 14:57:26 -0700 Subject: [PATCH 09/16] Improve error handling and logging in JupyterApiClient; enhance fetch calls for token and chkclogin --- .../frontend/src/infra/jupyterApiClient.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 842a64c..d398a64 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -23,29 +23,40 @@ export class JupyterApiClient implements IJupyterApi { // Step 1: chkclogin — Nginx injects accessToken as Cookie header so JupyterHub // validates it and sets the jupyterhub-hub-login session cookie. // redirect:'manual' stops at the 302; the browser still stores Set-Cookie from it. - await fetch( + const chkRes = await fetch( `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}`, { credentials: 'include', redirect: 'manual' }, - ).catch(() => {}) + ).catch((err) => { console.warn('[JupyterApiClient] chkclogin error:', err); return null }) + console.info('[JupyterApiClient] chkclogin status:', chkRes?.status, 'type:', chkRes?.type) // Step 2: Obtain a JupyterHub API token using the hub session cookie. // This token bypasses the per-server OAuth flow that cannot complete through the // proxy (Keycloak callback URL is hardcoded to lab.v2dev.opensourcebrain.org). + // redirect:'manual' avoids silently following an auth redirect to an HTML page + // that would make res.ok=true but break JSON parsing. try { const res = await fetch( `${this.jupyterBase}/hub/api/users/${userId}/tokens`, { method: 'POST', credentials: 'include', + redirect: 'manual', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note: 'idp-arc upload' }), }, ) + console.info('[JupyterApiClient] token fetch status:', res.status, 'type:', res.type) if (res.ok) { const data = await res.json() as { token: string } this.jupyterToken = data.token + console.info('[JupyterApiClient] hub token obtained') + } else { + const body = await res.text().catch(() => '(unreadable)') + console.warn('[JupyterApiClient] token fetch failed:', res.status, body) } - } catch {} + } catch (err) { + console.warn('[JupyterApiClient] token fetch threw:', err) + } // Step 3: Spawn the named server. redirect:'manual' stops before the // spawn-pending polling loop; waitUntilReady handles readiness independently. @@ -71,9 +82,11 @@ export class JupyterApiClient implements IJupyterApi { headers: this.authHeaders(), }) if (probe.ok) return true + console.info('[JupyterApiClient] probe status:', probe.status, 'token set:', !!this.jupyterToken) // 404 = named server not yet registered in the proxy (transient during spawn) - // 502/503 = server starting up; anything else is a definitive failure - if (probe.status !== 503 && probe.status !== 502 && probe.status !== 404) break + // 403 = server is up but per-server auth failed (token not obtained yet) + // 502/503 = server starting up + if (probe.status !== 503 && probe.status !== 502 && probe.status !== 404 && probe.status !== 403) break } catch { // Network / CORS error or redirect — keep retrying } From 6caa2f04e1c5c7a9d7b9e1e6b57d7321ce01bb5a Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 May 2026 18:18:15 -0700 Subject: [PATCH 10/16] Refactor JupyterApiClient to streamline spawn process; remove unused token handling and improve error handling for server readiness checks. --- .../frontend/src/infra/jupyterApiClient.ts | 63 +++++-------------- 1 file changed, 17 insertions(+), 46 deletions(-) diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index d398a64..74d90ae 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -13,8 +13,6 @@ import type { IJupyterApi } from '../core/ports/IJupyterApi' * Use-cases only see IJupyterApi — they have no idea these browser APIs exist. */ export class JupyterApiClient implements IJupyterApi { - private jupyterToken: string | null = null - constructor( private readonly jupyterBase: string, // e.g. "/jupyter-proxy" ) {} @@ -23,42 +21,12 @@ export class JupyterApiClient implements IJupyterApi { // Step 1: chkclogin — Nginx injects accessToken as Cookie header so JupyterHub // validates it and sets the jupyterhub-hub-login session cookie. // redirect:'manual' stops at the 302; the browser still stores Set-Cookie from it. - const chkRes = await fetch( + await fetch( `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}`, { credentials: 'include', redirect: 'manual' }, - ).catch((err) => { console.warn('[JupyterApiClient] chkclogin error:', err); return null }) - console.info('[JupyterApiClient] chkclogin status:', chkRes?.status, 'type:', chkRes?.type) - - // Step 2: Obtain a JupyterHub API token using the hub session cookie. - // This token bypasses the per-server OAuth flow that cannot complete through the - // proxy (Keycloak callback URL is hardcoded to lab.v2dev.opensourcebrain.org). - // redirect:'manual' avoids silently following an auth redirect to an HTML page - // that would make res.ok=true but break JSON parsing. - try { - const res = await fetch( - `${this.jupyterBase}/hub/api/users/${userId}/tokens`, - { - method: 'POST', - credentials: 'include', - redirect: 'manual', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ note: 'idp-arc upload' }), - }, - ) - console.info('[JupyterApiClient] token fetch status:', res.status, 'type:', res.type) - if (res.ok) { - const data = await res.json() as { token: string } - this.jupyterToken = data.token - console.info('[JupyterApiClient] hub token obtained') - } else { - const body = await res.text().catch(() => '(unreadable)') - console.warn('[JupyterApiClient] token fetch failed:', res.status, body) - } - } catch (err) { - console.warn('[JupyterApiClient] token fetch threw:', err) - } + ).catch(() => {}) - // Step 3: Spawn the named server. redirect:'manual' stops before the + // Step 2: Spawn the named server. redirect:'manual' stops before the // spawn-pending polling loop; waitUntilReady handles readiness independently. await fetch( `${this.jupyterBase}/hub/spawn/${userId}/${serverName}`, @@ -73,20 +41,27 @@ export class JupyterApiClient implements IJupyterApi { abortRef: { current: boolean }, ): Promise { const contentsUrl = `${this.jupyterBase}/user/${userId}/${serverName}/api/contents/` + // Non-API URL used to complete the per-server OAuth dance when the server is up + // but the per-server cookie hasn't been established yet. + const serverUrl = `${this.jupyterBase}/user/${userId}/${serverName}/` while (!abortRef.current && Date.now() < deadlineMs) { try { const probe = await fetch(contentsUrl, { credentials: 'include', redirect: 'error', // treat auth redirects as "not ready" rather than looping - headers: this.authHeaders(), }) if (probe.ok) return true - console.info('[JupyterApiClient] probe status:', probe.status, 'token set:', !!this.jupyterToken) - // 404 = named server not yet registered in the proxy (transient during spawn) - // 403 = server is up but per-server auth failed (token not obtained yet) - // 502/503 = server starting up - if (probe.status !== 503 && probe.status !== 502 && probe.status !== 404 && probe.status !== 403) break + if (probe.status === 403) { + // Server is running but the per-server OAuth cookie is missing. + // Fetch the HTML endpoint with redirect:'follow': Nginx proxy_redirect rewrites + // all OAuth redirects back through our proxy, so the per-server + // jupyterhub-user-{name}-{server} cookie is stored for our host. + await fetch(serverUrl, { credentials: 'include', redirect: 'follow' }).catch(() => {}) + } else if (probe.status !== 503 && probe.status !== 502 && probe.status !== 404) { + // Definitive non-retryable failure + break + } } catch { // Network / CORS error or redirect — keep retrying } @@ -103,7 +78,7 @@ export class JupyterApiClient implements IJupyterApi { { method: 'PUT', credentials: 'include', - headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name, path: file.name, @@ -117,10 +92,6 @@ export class JupyterApiClient implements IJupyterApi { throw new Error(`Upload failed: ${res.status} ${res.statusText}`) } } - - private authHeaders(): Record { - return this.jupyterToken ? { Authorization: `token ${this.jupyterToken}` } : {} - } } function sleep(ms: number): Promise { From 7f94b87ae4f95a0a12fc18cc560082d24e4be0d1 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 May 2026 18:54:09 -0700 Subject: [PATCH 11/16] #IDP-41 - Enhance JupyterApiClient to include base domain for cookie handling; update configuration for larger request body size in Nginx. --- applications/idp-arc/deploy/resources/default.conf | 1 + applications/idp-arc/frontend/src/app/container.ts | 2 +- applications/idp-arc/frontend/src/infra/jupyterApiClient.ts | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index 8afaa59..6af57c2 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -22,6 +22,7 @@ server { # them server-side. Set-Cookie domains are rewritten so session cookies are # stored for this host and carried on subsequent polling / upload requests. location /jupyter-proxy/ { + client_max_body_size 500m; proxy_pass https://lab.v2dev.opensourcebrain.org/; proxy_http_version 1.1; diff --git a/applications/idp-arc/frontend/src/app/container.ts b/applications/idp-arc/frontend/src/app/container.ts index a5131aa..8e0b183 100644 --- a/applications/idp-arc/frontend/src/app/container.ts +++ b/applications/idp-arc/frontend/src/app/container.ts @@ -42,7 +42,7 @@ export const authClient = new KeycloakAuthClient({ }) const workspaceApi = new WorkspaceApiClient(WORKSPACES_API, WORKSPACES_LIST_URL) -const jupyterApi = new JupyterApiClient(JUPYTER_BASE) +const jupyterApi = new JupyterApiClient(JUPYTER_BASE, BASE_DOMAIN) // ─── Use-cases (injected with their concrete dependencies) ──────────────────── diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 74d90ae..3024329 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -15,9 +15,12 @@ import type { IJupyterApi } from '../core/ports/IJupyterApi' export class JupyterApiClient implements IJupyterApi { constructor( private readonly jupyterBase: string, // e.g. "/jupyter-proxy" + private readonly baseDomain: string, // e.g. "v2dev.opensourcebrain.org" ) {} async triggerSpawn(token: string, userId: string, serverName: string): Promise { + // Set the session cookie so JupyterHub can authenticate the user + document.cookie = `accessToken=${token};path=/;domain=.${this.baseDomain};SameSite=Lax;Secure` // Step 1: chkclogin — Nginx injects accessToken as Cookie header so JupyterHub // validates it and sets the jupyterhub-hub-login session cookie. // redirect:'manual' stops at the 302; the browser still stores Set-Cookie from it. From 29e4112bc4d7be0e23c8db44f7f17d27115705b8 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 May 2026 19:14:12 -0700 Subject: [PATCH 12/16] #IDP-41 : Enhance JupyterApiClient to include X-XSRFToken in headers for PUT requests; add getXsrfToken function to retrieve the token from cookies. --- applications/idp-arc/deploy/resources/default.conf | 8 +++++--- .../idp-arc/frontend/src/infra/jupyterApiClient.ts | 10 +++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index 6af57c2..2a062ef 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -46,9 +46,11 @@ server { # # Directive 1: /hub or /hub/… → /jupyter-proxy/ (hub cookies go everywhere) proxy_cookie_path ~^/hub(/.*)?$ /jupyter-proxy/; - # Directive 2: everything else → prefix with /jupyter-proxy/ - # The (?!hub|jupyter-proxy) guard prevents double-transformation. - proxy_cookie_path ~^/(?!hub|jupyter-proxy)(.*) /jupyter-proxy/$1; + # Directive 2: everything else (user-server paths like /user/...) → Path=/ + # Mapping to root makes these cookies (including _xsrf set by JupyterLab) + # accessible via document.cookie so JavaScript can read the XSRF token and + # include it as X-XSRFToken on PUT/POST requests. + proxy_cookie_path ~^/(?!hub|jupyter-proxy)(.*) /; # Rewrite Location redirect headers to keep subsequent requests in the proxy. # Use explicit https://$host to avoid Nginx constructing an http://:8080 URL diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 3024329..37b1e7f 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -81,7 +81,10 @@ export class JupyterApiClient implements IJupyterApi { { method: 'PUT', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-XSRFToken': getXsrfToken(), + }, body: JSON.stringify({ name: file.name, path: file.name, @@ -97,6 +100,11 @@ export class JupyterApiClient implements IJupyterApi { } } +function getXsrfToken(): string { + const match = document.cookie.match(/(?:^|;)\s*_xsrf=([^;]+)/) + return match ? decodeURIComponent(match[1]) : '' +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } From 04436589a2cb4ddc0320aca3fa2139f7c064c01a Mon Sep 17 00:00:00 2001 From: jrmartin Date: Tue, 12 May 2026 20:02:11 -0700 Subject: [PATCH 13/16] #IDP-41 - Enhance JupyterApiClient to store and utilize _xsrf cookie value as X-XSRFToken; update Nginx configuration to expose the token for JavaScript access. This to avoid zip upload issues with this cookie --- applications/idp-arc/deploy/resources/default.conf | 10 ++++++++++ .../idp-arc/frontend/src/infra/jupyterApiClient.ts | 12 ++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index 2a062ef..50db923 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -5,6 +5,14 @@ # Two-case map: if the URL has accessToken, use only that cookie (browser has # no JupyterHub cookies yet at this point anyway); otherwise forward the # browser's existing cookies unchanged (for polling and upload requests). +# Extract the _xsrf cookie value from the incoming Cookie header so JavaScript +# can read it via the X-XSRF-Token response header (document.cookie won't work +# when the cookie is stored at a sub-path like /jupyter-proxy/user/...). +map $http_cookie $xsrf_val { + "~(?:^|;)\s*_xsrf=([^;]+)" $1; + default ""; +} + map $arg_accessToken $proxy_cookie { "" $http_cookie; default "accessToken=$arg_accessToken"; @@ -23,6 +31,8 @@ server { # stored for this host and carried on subsequent polling / upload requests. location /jupyter-proxy/ { client_max_body_size 500m; + # Expose the _xsrf cookie value so JavaScript can use it as X-XSRFToken. + add_header X-XSRF-Token $xsrf_val always; proxy_pass https://lab.v2dev.opensourcebrain.org/; proxy_http_version 1.1; diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 37b1e7f..a2163ee 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -13,6 +13,11 @@ import type { IJupyterApi } from '../core/ports/IJupyterApi' * Use-cases only see IJupyterApi — they have no idea these browser APIs exist. */ export class JupyterApiClient implements IJupyterApi { + // Captured from the first successful contents probe; Nginx echoes the _xsrf + // cookie value as X-XSRF-Token so we can read it even when the cookie path + // makes it inaccessible via document.cookie. + private xsrfToken = '' + constructor( private readonly jupyterBase: string, // e.g. "/jupyter-proxy" private readonly baseDomain: string, // e.g. "v2dev.opensourcebrain.org" @@ -54,7 +59,10 @@ export class JupyterApiClient implements IJupyterApi { credentials: 'include', redirect: 'error', // treat auth redirects as "not ready" rather than looping }) - if (probe.ok) return true + if (probe.ok) { + this.xsrfToken = probe.headers.get('X-XSRF-Token') ?? getXsrfToken() + return true + } if (probe.status === 403) { // Server is running but the per-server OAuth cookie is missing. // Fetch the HTML endpoint with redirect:'follow': Nginx proxy_redirect rewrites @@ -83,7 +91,7 @@ export class JupyterApiClient implements IJupyterApi { credentials: 'include', headers: { 'Content-Type': 'application/json', - 'X-XSRFToken': getXsrfToken(), + 'X-XSRFToken': this.xsrfToken || getXsrfToken(), }, body: JSON.stringify({ name: file.name, From 01dcc0f1ce10df80e0f3db7db6f533d01310642a Mon Sep 17 00:00:00 2001 From: jrmartin Date: Wed, 13 May 2026 16:44:46 -0700 Subject: [PATCH 14/16] Refactor Nginx configuration to improve _xsrf cookie handling; ensure correct cookie paths for hub and user-server requests to prevent conflicts. --- .../idp-arc/deploy/resources/default.conf | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index 50db923..5102fc8 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -5,12 +5,13 @@ # Two-case map: if the URL has accessToken, use only that cookie (browser has # no JupyterHub cookies yet at this point anyway); otherwise forward the # browser's existing cookies unchanged (for polling and upload requests). -# Extract the _xsrf cookie value from the incoming Cookie header so JavaScript -# can read it via the X-XSRF-Token response header (document.cookie won't work -# when the cookie is stored at a sub-path like /jupyter-proxy/user/...). +# Extract the _xsrf cookie value so JavaScript can read it as X-XSRF-Token. +# The browser may carry multiple _xsrf cookies (hub token at /jupyter-proxy/hub/ +# and user-server token at /). The greedy .* captures the LAST occurrence, which +# is always the user-server's token — the one JupyterLab actually validates. map $http_cookie $xsrf_val { - "~(?:^|;)\s*_xsrf=([^;]+)" $1; - default ""; + "~.*_xsrf=([^;]+)" $1; + default ""; } map $arg_accessToken $proxy_cookie { @@ -46,20 +47,18 @@ server { # Rewrite Set-Cookie so the browser stores session cookies for this host. proxy_cookie_domain lab.v2dev.opensourcebrain.org $host; - # Rewrite cookie Path so session cookies reach both /hub and /user endpoints. + # Rewrite cookie paths so each cookie reaches only the endpoints that need it. # - # JupyterHub sets the auth session cookie with Path=/hub (hub.base_url). - # After prefixing to /jupyter-proxy/hub it would NOT be sent to - # /jupyter-proxy/user/… — causing 403 on the Contents API. - # Fix: expand any hub-scoped path to the full proxy root so the cookie is - # sent for ALL /jupyter-proxy/… requests. - # - # Directive 1: /hub or /hub/… → /jupyter-proxy/ (hub cookies go everywhere) - proxy_cookie_path ~^/hub(/.*)?$ /jupyter-proxy/; + # Directive 1: /hub or /hub/… → /jupyter-proxy/hub/ + # Hub-session cookies (jupyterhub-hub-login, hub-side _xsrf, session-id) are + # scoped to /jupyter-proxy/hub/ so they are sent to hub endpoints (chkclogin, + # spawn, OAuth redirects) but NOT to /jupyter-proxy/user/… requests. + # Keeping them away from user-server requests prevents a dual-_xsrf conflict + # where the hub's stale token would shadow the user-server's live token. + proxy_cookie_path ~^/hub(/.*)?$ /jupyter-proxy/hub/; # Directive 2: everything else (user-server paths like /user/...) → Path=/ - # Mapping to root makes these cookies (including _xsrf set by JupyterLab) - # accessible via document.cookie so JavaScript can read the XSRF token and - # include it as X-XSRFToken on PUT/POST requests. + # The per-server OAuth cookie and the user-server's _xsrf land at / so the + # browser sends them to all /jupyter-proxy/… requests (poll + upload). proxy_cookie_path ~^/(?!hub|jupyter-proxy)(.*) /; # Rewrite Location redirect headers to keep subsequent requests in the proxy. From 28db906c16028a37eedce6ab1f0d8ec69b9503d2 Mon Sep 17 00:00:00 2001 From: jrmartin Date: Wed, 13 May 2026 17:23:27 -0700 Subject: [PATCH 15/16] Refactor Nginx cookie path handling to ensure session cookies are sent to both /hub and /user endpoints, preventing 403 errors on the Contents API. --- .../idp-arc/deploy/resources/default.conf | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index 5102fc8..20c1c8e 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -47,18 +47,22 @@ server { # Rewrite Set-Cookie so the browser stores session cookies for this host. proxy_cookie_domain lab.v2dev.opensourcebrain.org $host; - # Rewrite cookie paths so each cookie reaches only the endpoints that need it. + # Rewrite cookie Path so session cookies reach both /hub and /user endpoints. # - # Directive 1: /hub or /hub/… → /jupyter-proxy/hub/ - # Hub-session cookies (jupyterhub-hub-login, hub-side _xsrf, session-id) are - # scoped to /jupyter-proxy/hub/ so they are sent to hub endpoints (chkclogin, - # spawn, OAuth redirects) but NOT to /jupyter-proxy/user/… requests. - # Keeping them away from user-server requests prevents a dual-_xsrf conflict - # where the hub's stale token would shadow the user-server's live token. - proxy_cookie_path ~^/hub(/.*)?$ /jupyter-proxy/hub/; + # JupyterHub sets the auth session cookie with Path=/hub (hub.base_url). + # After prefixing to /jupyter-proxy/hub it would NOT be sent to + # /jupyter-proxy/user/… — causing 403 on the Contents API. + # Fix: expand any hub-scoped path to the full proxy root so the cookie is + # sent for ALL /jupyter-proxy/… requests. + # NOTE: hub cookies (incl. hub-side _xsrf) also reach /user/… this way; the + # Nginx map uses a greedy .* to select the LAST _xsrf in the Cookie header, + # which is always the user-server's token that JupyterLab validates against. + # + # Directive 1: /hub or /hub/… → /jupyter-proxy/ (hub cookies go everywhere) + proxy_cookie_path ~^/hub(/.*)?$ /jupyter-proxy/; # Directive 2: everything else (user-server paths like /user/...) → Path=/ - # The per-server OAuth cookie and the user-server's _xsrf land at / so the - # browser sends them to all /jupyter-proxy/… requests (poll + upload). + # Mapping to root makes these cookies (including _xsrf set by JupyterLab) + # sent to all /jupyter-proxy/… requests (poll + upload). proxy_cookie_path ~^/(?!hub|jupyter-proxy)(.*) /; # Rewrite Location redirect headers to keep subsequent requests in the proxy. From 914df01cfa21ef6ebbea700aa72222d4d601547c Mon Sep 17 00:00:00 2001 From: jrmartin Date: Wed, 13 May 2026 20:32:44 -0700 Subject: [PATCH 16/16] Add diagnostic logging to uploadFile method for JupyterLab reachability check --- .../idp-arc/frontend/src/infra/jupyterApiClient.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index a2163ee..f2bd18b 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -83,6 +83,18 @@ export class JupyterApiClient implements IJupyterApi { } async uploadFile(userId: string, serverName: string, file: File): Promise { + // Diagnostic: verify JupyterLab is reachable immediately before upload + const contentsUrl = `${this.jupyterBase}/user/${userId}/${serverName}/api/contents/` + try { + const probe = await fetch(contentsUrl, { credentials: 'include', redirect: 'error' }) + console.log('[upload-probe] status:', probe.status, 'ok:', probe.ok) + console.log('[upload-probe] X-XSRF-Token header:', probe.headers.get('X-XSRF-Token')) + console.log('[upload-probe] xsrfToken in memory:', this.xsrfToken) + console.log('[upload-probe] document.cookie _xsrf:', document.cookie.match(/(?:^|;)\s*_xsrf=([^;]*)/)?.[0] ?? '(not found)') + } catch (e) { + console.log('[upload-probe] fetch threw (redirect or network):', e) + } + const content = await readFileAsBase64(file) const res = await fetch( `${this.jupyterBase}/user/${userId}/${serverName}/api/contents/${encodeURIComponent(file.name)}`,