feat(deploy): a Railway deployment of the analysed cases, behind one credential
Three services -- Postgres, API, UI -- with the API on Railway's private network only, so the UI's /api proxy is the single public entry point and there is no CORS. The pipeline cannot run there. Nextflow shells out to `docker run` for VEP and bcftools, and Railway gives you a container, not a Docker daemon. Rather than leave a button that always fails, cases are annotated locally and copied up by scripts/seed-remote.sh, and PUBLIC_PIPELINE_ENABLED=false hides the analyse/score actions and the create-case form. DATABASE_IDLE_CONNECTIONS=false is what makes idling work. Railway decides a service is idle from its *outbound* traffic and sleeps it after ~5-10 minutes; a pooled database connection is outbound traffic, so SQLAlchemy's default pool would have kept the API awake and billable for ever. Setting it false switches to NullPool, which costs a connection per request -- nothing at demo traffic, the wrong trade under real load, hence the flag rather than a rewrite. BASIC_AUTH_USER / BASIC_AUTH_PASSWORD put one shared credential in front of the site. Nothing deployed is patient data, so this stops the URL being wandered into rather than protecting anyone's privacy; unset, the site is open, which is what local development wants. Compared in constant time, and both halves of the credential are checked even when the first fails.
This commit is contained in:
+3
-2
@@ -1,5 +1,6 @@
|
||||
# .env holds the local dev API URL; the image reads PUBLIC_API_URL at runtime instead.
|
||||
.env
|
||||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
# .env sets PUBLIC_API_URL for local development. Baked into the image it would override the
|
||||
# same-origin /api proxy the deployment relies on, so it must not travel.
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const env: Record<string, string | undefined> = {};
|
||||
vi.mock('$env/dynamic/private', () => ({ env }));
|
||||
|
||||
const { handle } = await import('./hooks.server');
|
||||
|
||||
const credential = (user: string, password: string) =>
|
||||
`Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
||||
|
||||
function run(authorization?: string) {
|
||||
const event = {
|
||||
request: new Request('https://demo.example/cases', {
|
||||
headers: authorization ? { authorization } : {}
|
||||
})
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return handle({ event, resolve: async () => new Response('ok') } as any);
|
||||
}
|
||||
|
||||
describe('basic auth', () => {
|
||||
beforeEach(() => {
|
||||
env.BASIC_AUTH_USER = 'demo';
|
||||
env.BASIC_AUTH_PASSWORD = 'letmein';
|
||||
});
|
||||
|
||||
it('is off when no credential is configured, so local development is unaffected', async () => {
|
||||
env.BASIC_AUTH_USER = undefined;
|
||||
env.BASIC_AUTH_PASSWORD = undefined;
|
||||
expect((await run()).status).toBe(200);
|
||||
});
|
||||
|
||||
it('challenges an anonymous request', async () => {
|
||||
const res = await run();
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers.get('www-authenticate')).toContain('Basic realm="rarelens"');
|
||||
});
|
||||
|
||||
it('lets the right credential through', async () => {
|
||||
expect((await run(credential('demo', 'letmein'))).status).toBe(200);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wrong password', credential('demo', 'nope')],
|
||||
['wrong user', credential('someone', 'letmein')],
|
||||
['password that is a prefix of the real one', credential('demo', 'letme')],
|
||||
['not basic at all', 'Bearer letmein'],
|
||||
['no colon in the decoded value', `Basic ${Buffer.from('demo').toString('base64')}`],
|
||||
['empty', '']
|
||||
])('rejects %s', async (_label, header) => {
|
||||
expect((await run(header)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* One shared credential in front of the whole site, for the deployed demo.
|
||||
*
|
||||
* Everything here is public, openly licensed data and there is no patient information, so this is
|
||||
* not protecting anyone's privacy. It keeps the URL from being wandered into or indexed while a
|
||||
* handful of people are looking at it, which is the actual requirement. Unset the variables and
|
||||
* the site is open, which is what local development wants.
|
||||
*/
|
||||
const REALM = 'rarelens';
|
||||
|
||||
/** Constant time, and safe when the two differ in length (timingSafeEqual throws on that). */
|
||||
function matches(given: string, expected: string): boolean {
|
||||
const a = Buffer.from(given);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function authorised(header: string | null, user: string, password: string): boolean {
|
||||
if (!header?.startsWith('Basic ')) return false;
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
||||
const separator = decoded.indexOf(':');
|
||||
if (separator < 0) return false;
|
||||
// Compare both halves even when the first fails, so the reply time says nothing about which did.
|
||||
const okUser = matches(decoded.slice(0, separator), user);
|
||||
const okPassword = matches(decoded.slice(separator + 1), password);
|
||||
return okUser && okPassword;
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const user = env.BASIC_AUTH_USER;
|
||||
const password = env.BASIC_AUTH_PASSWORD;
|
||||
if (!user || !password) return resolve(event);
|
||||
|
||||
if (!authorised(event.request.headers.get('authorization'), user, password)) {
|
||||
return new Response('Authentication required', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': `Basic realm="${REALM}", charset="UTF-8"` }
|
||||
});
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Assembly, type Case, type PhenotypeTerm } from '$lib/api';
|
||||
import PhenotypePicker from '$lib/components/PhenotypePicker.svelte';
|
||||
@@ -38,6 +39,8 @@
|
||||
}
|
||||
|
||||
const status = (c: Case) => c.latest_job?.status ?? 'not analysed';
|
||||
// Nothing can be analysed where the pipeline cannot run, so do not offer to create a case there.
|
||||
const canAnalyse = env.PUBLIC_PIPELINE_ENABLED !== 'false';
|
||||
</script>
|
||||
|
||||
<h1>rarelens</h1>
|
||||
@@ -46,6 +49,7 @@
|
||||
candidate carrying the evidence for its rank. Research demo, not a diagnostic tool.
|
||||
</p>
|
||||
|
||||
{#if canAnalyse}
|
||||
<h2>New case</h2>
|
||||
<div class="newcase">
|
||||
<div class="row">
|
||||
@@ -63,6 +67,7 @@
|
||||
<button onclick={add} disabled={!name || !vcfUri}>Create case</button>
|
||||
</div>
|
||||
{#if error}<p role="alert">Could not create the case: {error}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<h2>Cases</h2>
|
||||
{#if loadError}
|
||||
@@ -70,7 +75,13 @@
|
||||
{:else if !loaded}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if cases.length === 0}
|
||||
<div class="empty">No cases yet. Create one above, then run the annotation pipeline on it.</div>
|
||||
<div class="empty">
|
||||
{#if canAnalyse}
|
||||
No cases yet. Create one above, then run the annotation pipeline on it.
|
||||
{:else}
|
||||
No cases loaded.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="caselist">
|
||||
{#each cases as c (c.id)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { api, type Candidate, type CandidatePage, type Case, type Job, type VariantDetail } from '$lib/api';
|
||||
import { poll } from '$lib/poll';
|
||||
import { formatElapsed, latestStep } from '$lib/progress';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { missingEvidenceNote, needsScoring, plural } from '$lib/candidates';
|
||||
import CandidateRow from '$lib/components/CandidateRow.svelte';
|
||||
import Funnel from '$lib/components/Funnel.svelte';
|
||||
@@ -35,6 +36,9 @@
|
||||
// not need a five-minute re-run of VEP to get its score.
|
||||
const unscored = $derived(!!page && page.items.length > 0 && needsScoring(page.items, page.evidence));
|
||||
const evidenceNote = $derived(page ? missingEvidenceNote(page.evidence) : null);
|
||||
// The deployed demo has no Nextflow and no Docker daemon to run VEP in, so the pipeline cannot
|
||||
// run there. Hide the buttons rather than leave them to fail: the cases are already analysed.
|
||||
const canAnalyse = env.PUBLIC_PIPELINE_ENABLED !== 'false';
|
||||
|
||||
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
|
||||
$effect(() => {
|
||||
@@ -160,9 +164,11 @@
|
||||
</p>
|
||||
{:else}
|
||||
<p class="actions">
|
||||
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
|
||||
{#if unscored}
|
||||
<button class="quiet" onclick={scoreThenLoad}>Score variants</button>
|
||||
{#if canAnalyse}
|
||||
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
|
||||
{#if unscored}
|
||||
<button class="quiet" onclick={scoreThenLoad}>Score variants</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if page && page.funnel.total > 0}
|
||||
<a class="reportlink" href="/cases/{data.caseId}/report">Case report →</a>
|
||||
@@ -219,7 +225,12 @@
|
||||
</div>
|
||||
{:else if page && !running}
|
||||
<div class="empty">
|
||||
No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is
|
||||
left against the phenotype.
|
||||
{#if canAnalyse}
|
||||
No results yet. "Analyse case" runs VEP over the VCF, scores each variant, then ranks what is
|
||||
left against the phenotype.
|
||||
{:else}
|
||||
No results for this case. This deployment shows cases that were analysed beforehand; running
|
||||
the pipeline needs Nextflow and a Docker daemon, which it does not have.
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user