diff --git a/Dockerfile b/Dockerfile index f52e8bc..11b1de8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -155,7 +155,7 @@ RUN --mount=type=secret,id=ghtoken,env=NODE_AUTH_TOKEN \ # --------------------------------------------------------------------------- # Stage 3: bake the finished sibyl dist into the trex backend. # --------------------------------------------------------------------------- -FROM ghcr.io/ohdsi/trexsql:latest@sha256:bdeea44d964b2eddf9346aacfc8090311a9cba1b2f1be5a59742445c9bd5cf64 +FROM ghcr.io/ohdsi/trexsql:latest@sha256:6c3ec02c884766fd52b27c34b3dcd1a12cd385efa74edbfba03d620018794733 # --- R runtime for hades / Strategus --------------------------------------- # The trexsql base ships the hades DuckDB extension but NOT R, so hades_execute diff --git a/central/template.yaml b/central/template.yaml index f7e4e6d..5a4fdf0 100644 --- a/central/template.yaml +++ b/central/template.yaml @@ -210,7 +210,11 @@ Resources: PreTokenGenFunction: Type: AWS::Serverless::Function Properties: - Handler: index.preTokenGen + # esbuild emits the bundle named after the entry point (preTokenGen.mjs), + # so the handler module must be `preTokenGen`, not `index`. Using + # `index.preTokenGen` makes the runtime fail with "Cannot find module 'index'", + # which Cognito surfaces as UserLambdaValidationException on token generation. + Handler: preTokenGen.preTokenGen CodeUri: api/ Metadata: BuildMethod: esbuild diff --git a/central/web/src/auth/session.ts b/central/web/src/auth/session.ts index 429e0a2..e777cde 100644 --- a/central/web/src/auth/session.ts +++ b/central/web/src/auth/session.ts @@ -76,7 +76,14 @@ export async function handleCallback(search: string): Promise { if (!code) throw new Error('missing authorization code'); if (state !== localStorage.getItem(STATE_KEY)) throw new Error('state mismatch'); - const verifier = localStorage.getItem(VERIFIER_KEY) ?? ''; + // Consume the PKCE verifier up-front so a duplicate or reloaded callback can't + // re-submit the same single-use code (Cognito rejects reuse as invalid_grant, + // which previously surfaced as the opaque "token exchange failed"). + const verifier = localStorage.getItem(VERIFIER_KEY); + if (!verifier) throw new Error('no pending sign-in — start login again'); + localStorage.removeItem(VERIFIER_KEY); + localStorage.removeItem(STATE_KEY); + const body = new URLSearchParams({ grant_type: 'authorization_code', client_id: config.clientId, @@ -89,8 +96,9 @@ export async function handleCallback(search: string): Promise { headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, }); - if (!res.ok) throw new Error('token exchange failed'); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`token exchange failed (${res.status}): ${detail}`); + } storeTokens((await res.json()) as TokenResponse); - localStorage.removeItem(VERIFIER_KEY); - localStorage.removeItem(STATE_KEY); } diff --git a/docker-compose.yml b/docker-compose.yml index 64c0b14..8381150 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,7 +64,7 @@ services: container_name: sibyl-trex-init # Pinned to the same multi-arch index digest as the Dockerfile FROM (= # trexsql:latest); each host pulls its native variant (amd64 or arm64). - image: ghcr.io/ohdsi/trexsql:latest@sha256:a57e5d3eadcb73b6f0b70cef28c0e42d2a0a7e0256310bb1eabc2a8ccd4375ae + image: ghcr.io/ohdsi/trexsql:latest@sha256:6c3ec02c884766fd52b27c34b3dcd1a12cd385efa74edbfba03d620018794733 entrypoint: /usr/local/bin/trex-init environment: TREX_SECRETS_DIR: /shared diff --git a/plugins/metadata-api/migrations/V3__notebook_graphql_grants.sql b/plugins/metadata-api/migrations/V3__notebook_graphql_grants.sql new file mode 100644 index 0000000..07bc182 --- /dev/null +++ b/plugins/metadata-api/migrations/V3__notebook_graphql_grants.sql @@ -0,0 +1,30 @@ +-- Expose the `notebook` schema to PostGraphile. PostGraphile connects as the +-- unprivileged `authenticator` role and SET ROLEs to anon/authenticated/ +-- service_role per request, so it only introspects objects those roles can see. +-- Plugin-created schemas get no grants by default (only core/schema/ +-- V3__graphql_trexdb_grants.sql grants `trexdb`), so without this the study +-- editor's createNotebookAnalysisDefinition mutation is absent from the GraphQL +-- schema and saving a study fails with HTTP 400 "Cannot query field +-- createNotebookAnalysisDefinition on type Mutation". +-- +-- notebook.* has no RLS and holds no secrets, so authenticated gets full table +-- CRUD (unscoped) and service_role gets ALL. Mirrors the non-sensitive-table +-- pattern in core/schema/V3__graphql_trexdb_grants.sql. + +GRANT USAGE ON SCHEMA notebook TO anon, authenticated, service_role, authenticator; + +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA notebook TO authenticated; +GRANT USAGE ON ALL SEQUENCES IN SCHEMA notebook TO authenticated; + +GRANT ALL ON ALL TABLES IN SCHEMA notebook TO service_role; +GRANT ALL ON ALL SEQUENCES IN SCHEMA notebook TO service_role; + +-- Future tables/sequences in the schema inherit the same grants. +ALTER DEFAULT PRIVILEGES IN SCHEMA notebook + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO authenticated; +ALTER DEFAULT PRIVILEGES IN SCHEMA notebook + GRANT USAGE ON SEQUENCES TO authenticated; +ALTER DEFAULT PRIVILEGES IN SCHEMA notebook + GRANT ALL ON TABLES TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA notebook + GRANT ALL ON SEQUENCES TO service_role; diff --git a/plugins/results-viewer/shinylive-app/app.R b/plugins/results-viewer/shinylive-app/app.R index 24187a4..8a1ecaa 100644 --- a/plugins/results-viewer/shinylive-app/app.R +++ b/plugins/results-viewer/shinylive-app/app.R @@ -699,49 +699,51 @@ ui <- tagList( } "))), tags$script(HTML(" - if (window.top && window.top !== window) { - window.top.postMessage({type: 'SHINYLIVE_READY'}, '*'); - } + // This inline script runs at HTML parse time, BEFORE Shiny's JS API exists. + // So we must NOT touch Shiny.* here: at parse time `typeof Shiny` is + // undefined, which previously meant (a) Shiny.setInputValue threw and + // (b) the APP_READY handler was never registered, so the host overlay never + // cleared. Instead we buffer incoming data and defer all Shiny work until + // shiny:connected (with a poll fallback), then flush + signal readiness. var __resultFiles = {}; var __resultDb = ''; + var __pending = null; + function __rvFlush() { + if (typeof Shiny === 'undefined' || typeof Shiny.setInputValue !== 'function' || !__pending) return; + if (__pending.k === 'db') Shiny.setInputValue('result_db', __pending.v, {priority: 'event'}); + else Shiny.setInputValue('result_files', __pending.v, {priority: 'event'}); + __pending = null; + } + function __rvAck() { if (window.top !== window) window.top.postMessage({type: 'DATA_RECEIVED'}, '*'); } window.addEventListener('message', function(event) { var d = event.data; if (!d) return; if (d.type === 'RESULT_DB_BEGIN') { __resultDb = ''; return; } if (d.type === 'RESULT_DB_CHUNK') { __resultDb += d.content; return; } - if (d.type === 'RESULT_DB_END') { - Shiny.setInputValue('result_db', __resultDb, {priority: 'event'}); - if (window.top !== window) window.top.postMessage({type: 'DATA_RECEIVED'}, '*'); - return; - } - if (d.type === 'RESULT_FILES') { - Shiny.setInputValue('result_files', d.files, {priority: 'event'}); - if (window.top !== window) window.top.postMessage({type: 'DATA_RECEIVED'}, '*'); - return; - } - if (d.type === 'RESULT_FILES_BEGIN') { - __resultFiles = {}; - return; - } - if (d.type === 'RESULT_FILES_CHUNK') { - __resultFiles[d.name] = d.content; - return; - } - if (d.type === 'RESULT_FILES_END') { - Shiny.setInputValue('result_files', __resultFiles, {priority: 'event'}); - if (window.top !== window) window.top.postMessage({type: 'DATA_RECEIVED'}, '*'); - return; - } + if (d.type === 'RESULT_DB_END') { __pending = {k: 'db', v: __resultDb}; __rvFlush(); __rvAck(); return; } + if (d.type === 'RESULT_FILES') { __pending = {k: 'files', v: d.files}; __rvFlush(); __rvAck(); return; } + if (d.type === 'RESULT_FILES_BEGIN') { __resultFiles = {}; return; } + if (d.type === 'RESULT_FILES_CHUNK') { __resultFiles[d.name] = d.content; return; } + if (d.type === 'RESULT_FILES_END') { __pending = {k: 'files', v: __resultFiles}; __rvFlush(); __rvAck(); return; } }); - // Relay R's APP_READY custom message to the parent Vue host so it can - // hide its loading overlay once tables are in DuckDB. - if (typeof Shiny !== 'undefined') { - Shiny.addCustomMessageHandler('APP_READY', function(payload) { - if (window.top && window.top !== window) { - window.top.postMessage({type: 'APP_READY', tables: payload && payload.tables}, '*'); - } - }); + function __rvReady() { + if (window.__rvReadyDone) return; window.__rvReadyDone = true; + try { + Shiny.addCustomMessageHandler('APP_READY', function(payload) { + if (window.top && window.top !== window) { + window.top.postMessage({type: 'APP_READY', tables: payload && payload.tables}, '*'); + } + }); + } catch (e) {} + __rvFlush(); + if (window.top && window.top !== window) window.top.postMessage({type: 'SHINYLIVE_READY'}, '*'); } + document.addEventListener('shiny:connected', __rvReady); + var __rvTries = 0; + var __rvPoll = setInterval(function() { + if (typeof Shiny !== 'undefined' && typeof Shiny.setInputValue === 'function') { clearInterval(__rvPoll); __rvReady(); } + else if (++__rvTries > 1200) { clearInterval(__rvPoll); } + }, 500); ")) ) diff --git a/plugins/sibyl/src/components/NavBar.vue b/plugins/sibyl/src/components/NavBar.vue index 6b9414d..2511c4c 100644 --- a/plugins/sibyl/src/components/NavBar.vue +++ b/plugins/sibyl/src/components/NavBar.vue @@ -53,6 +53,28 @@ data-test="nav-settings" @click="ui.toggleSettings()" /> + + + + + + Sign out + + + @@ -60,19 +82,32 @@