Skip to content

Commit 9582000

Browse files
authored
Switch to explicit env vars (#152)
* Switch to explicit env vars * Conditional env vars
1 parent 961e2ef commit 9582000

10 files changed

Lines changed: 124 additions & 24 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
# Both are required whenever the backend is enabled — the app refuses to start
2+
# without them. (In VERCEL=1 mode neither is needed.) ORIGIN must match the URL
3+
# you open in the browser exactly, or write requests fail with 403.
14
ADMIN_PASSWORD="your-secure-password-here"
5+
ORIGIN="http://localhost:5173"
6+
27
# Automated backups (optional) — same values as the fly secrets. Used locally
38
# by `pnpm data:pull-cloud` to restore your site from the bucket.
49
# BUCKET_NAME="my-site-backup"

ARCHITECTURE.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,13 +370,43 @@ This keeps the owner anchored to the exact page where they noticed something to
370370

371371
### Environment requirements
372372

373-
`ADMIN_PASSWORD` is required in full runtime mode.
373+
`ADMIN_PASSWORD` and `ORIGIN` are required whenever the backend is enabled.
374374

375375
Behavior rules:
376376

377-
- in static / `VERCEL=1` mode, authentication is disabled and `ADMIN_PASSWORD` is ignored
378-
- in full runtime mode, the app must fail to start if `ADMIN_PASSWORD` is missing
377+
- in full runtime mode, the app must fail to start if `ADMIN_PASSWORD` or
378+
`ORIGIN` is missing
379379
- the app must never silently grant access when `ADMIN_PASSWORD` is missing
380+
- in static / `VERCEL=1` mode there is no database, no login and no writing, so
381+
neither variable is needed and neither is checked
382+
383+
Variables consumed through SvelteKit are declared explicitly in `src/env.ts` via
384+
`defineEnvVars`, enabled by `experimental.explicitEnvironmentVariables` in
385+
`vite.config.ts`. This is the SvelteKit 3 model, opted into early. Server code
386+
imports named bindings from `$app/env/private` instead of `$env/dynamic/private`,
387+
and the latter now throws when imported.
388+
389+
A declared variable is required and non-empty unless its `schema` says otherwise.
390+
`VERCEL` and `NODE_ENV` are declared optional because each may legitimately be
391+
absent — `VERCEL` is unset outside Vercel, and its absence is what selects
392+
backend mode.
393+
394+
Values are validated both when the app is built and when it starts. Deploys build
395+
without secrets — the Dockerfile runs `pnpm build` long before the server's `.env`
396+
exists — so the validators for required variables consult `building` from
397+
`$app/env` and enforce only at startup. They also read `process.env.VERCEL`
398+
directly, which is what makes the requirement conditional: a validator is handed
399+
only its own value, but this file is evaluated when the server starts and can
400+
inspect the environment. Enforcing `ORIGIN` this way turns what was a silent
401+
`?? ''` fallback, surfacing later as a confusing `403` on write requests, into a
402+
named startup failure.
403+
404+
When `VERCEL=1` mode is eventually removed, `has_backend()` in `src/env.ts` goes
405+
away and both variables become unconditionally required.
406+
407+
`DATA_DIR` and the deployment/backup variables are read from `process.env` in
408+
plain modules and scripts, outside the SvelteKit env system, so they are not
409+
declared here.
380410

381411
### Remote function and route protection
382412

src/env.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { defineEnvVars } from '@sveltejs/kit/env';
2+
import { building } from '$app/env';
3+
4+
// In no-backend (VERCEL=1) mode there is no database, no login and no writing,
5+
// so neither secret is needed. A validator only receives its own value, but this
6+
// file is evaluated when the server starts and can read the environment directly.
7+
const has_backend = () => !globalThis.process?.env?.VERCEL;
8+
9+
// Variables are validated twice: once while the app is built, and again when it
10+
// starts. Deploys build without secrets — the Dockerfile runs `pnpm build` long
11+
// before the server's .env exists — so required variables are only enforced at
12+
// startup, never during the build.
13+
function required_with_backend(hint: string) {
14+
return {
15+
'~standard': {
16+
version: 1 as const,
17+
vendor: 'editable',
18+
types: undefined as unknown as { input: string | undefined; output: string },
19+
validate: (value: unknown) => {
20+
if (!building && has_backend() && !value) {
21+
return { issues: [{ message: `Value is missing. ${hint}` }] };
22+
}
23+
24+
return { value: (value ?? '') as string };
25+
}
26+
}
27+
};
28+
}
29+
30+
// Variables that may legitimately be absent need a validator saying so,
31+
// otherwise SvelteKit treats them as required non-empty strings.
32+
function optional<T>(parse: (value: string | undefined) => T) {
33+
return {
34+
'~standard': {
35+
version: 1 as const,
36+
vendor: 'editable',
37+
// Type-only carrier that SvelteKit reads to infer the exported type.
38+
// Standard Schema never touches it at runtime.
39+
types: undefined as unknown as { input: string | undefined; output: T },
40+
validate: (value: unknown) => ({ value: parse(value as string | undefined) })
41+
}
42+
};
43+
}
44+
45+
const where =
46+
'Set it in .env locally, via `fly secrets set` on Fly, or in the server .env on a VPS.';
47+
48+
export const variables = defineEnvVars({
49+
ADMIN_PASSWORD: {
50+
description:
51+
'Password for the admin login. Required whenever the backend is enabled — the app refuses to start without it. Unused in VERCEL=1 mode.',
52+
schema: required_with_backend(where)
53+
},
54+
ORIGIN: {
55+
description:
56+
'Public origin of the deployment, e.g. https://my-site.example.com. Must match the URL used in the browser exactly, or write requests fail with 403. Required whenever the backend is enabled.',
57+
schema: required_with_backend(where)
58+
},
59+
VERCEL: {
60+
description:
61+
'Set by Vercel. Its absence is what puts the app in backend mode, so it must stay optional.',
62+
schema: optional((value) => value)
63+
},
64+
NODE_ENV: {
65+
description: 'Used to decide whether session cookies are marked secure.',
66+
schema: optional((value) => value)
67+
}
68+
});

src/hooks.server.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { env } from '$env/dynamic/private';
1+
import { VERCEL } from '$app/env/private';
22
import type { Handle, ServerInit } from '@sveltejs/kit';
33
import {
44
admin_session_cookie_name,
@@ -10,11 +10,7 @@ import {
1010
} from '$lib/server/auth.js';
1111

1212
export const init: ServerInit = async () => {
13-
if (!env.VERCEL && !env.ADMIN_PASSWORD) {
14-
throw new Error('ADMIN_PASSWORD must be set');
15-
}
16-
17-
if (!env.VERCEL) {
13+
if (!VERCEL) {
1814
const { default: migrate } = await import('$lib/server/migrate.js');
1915
migrate();
2016
}
@@ -23,7 +19,7 @@ export const init: ServerInit = async () => {
2319
export const handle: Handle = async ({ event, resolve }) => {
2420
event.locals.is_admin = false;
2521

26-
if (!env.VERCEL) {
22+
if (!VERCEL) {
2723
const session_id = event.cookies.get(admin_session_cookie_name);
2824

2925
if (session_id) {

src/lib/server/auth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
22
import type { DatabaseSync } from 'node:sqlite';
33
import type { Cookies } from '@sveltejs/kit';
44
import { error } from '@sveltejs/kit';
5-
import { env } from '$env/dynamic/private';
5+
import { ADMIN_PASSWORD, NODE_ENV } from '$app/env/private';
66

77
export const admin_session_cookie_name = 'ew_admin_session';
88
export const session_duration_seconds = 14 * 24 * 60 * 60;
@@ -82,7 +82,7 @@ export function reset_login_throttle(db: DatabaseSync) {
8282
}
8383

8484
export function get_required_admin_password(): string {
85-
const admin_password = env.ADMIN_PASSWORD;
85+
const admin_password = ADMIN_PASSWORD;
8686
if (!admin_password) {
8787
throw new Error('ADMIN_PASSWORD must be set');
8888
}
@@ -99,7 +99,7 @@ export function clear_admin_session_cookie(cookies: Cookies) {
9999
path: '/',
100100
httpOnly: true,
101101
sameSite: 'lax',
102-
secure: env.NODE_ENV === 'production',
102+
secure: NODE_ENV === 'production',
103103
maxAge: 0
104104
});
105105
}
@@ -109,7 +109,7 @@ export function set_admin_session_cookie(cookies: Cookies, session_id: string) {
109109
path: '/',
110110
httpOnly: true,
111111
sameSite: 'lax',
112-
secure: env.NODE_ENV === 'production',
112+
secure: NODE_ENV === 'production',
113113
maxAge: session_duration_seconds
114114
});
115115
}

src/routes/+layout.server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { env } from '$env/dynamic/private';
1+
import { ORIGIN, VERCEL } from '$app/env/private';
22
import { demo_doc } from '$lib/demo_doc.js';
33
import { extract_site_metadata } from '$lib/page_metadata.js';
44
import type { LayoutServerLoad } from './$types';
@@ -7,7 +7,7 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => {
77
// Re-derived after saving a page, so favicon and site name update live.
88
depends('app:site_metadata');
99

10-
const has_backend = !env.VERCEL;
10+
const has_backend = !VERCEL;
1111

1212
let site_metadata;
1313
if (has_backend) {
@@ -20,7 +20,7 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => {
2020
return {
2121
has_backend,
2222
is_admin: !!locals.is_admin,
23-
origin: env.ORIGIN ?? '',
23+
origin: ORIGIN,
2424
favicon: site_metadata.favicon
2525
};
2626
};

src/routes/+page.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { env } from '$env/dynamic/private';
1+
import { VERCEL } from '$app/env/private';
22
import type { PageServerLoad } from './$types';
33

44
// Deliberately no `await parent()` here: depending on layout data would rerun
55
// this load (and rebuild the editing session) whenever the layout is
66
// invalidated, e.g. for the favicon refresh after a save. has_backend and
77
// is_admin reach the page via the layout data merge.
88
export const load: PageServerLoad = async () => {
9-
if (env.VERCEL) {
9+
if (VERCEL) {
1010
return {
1111
document: null,
1212
slug: null

src/routes/[page_id]/+page.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { error, redirect } from '@sveltejs/kit';
22
import { dev } from '$app/environment';
3-
import { env } from '$env/dynamic/private';
3+
import { VERCEL } from '$app/env/private';
44
import { get_markdown_page } from '$lib/server/markdown/registry.js';
55
import { convert_markdown } from '$lib/server/markdown/convert.js';
66
import { compose_markdown_document } from '$lib/server/markdown/compose.js';
@@ -58,7 +58,7 @@ export const load: PageServerLoad = async ({ params }) => {
5858
* documents when a backend exists, the demo seed documents on static builds.
5959
*/
6060
async function get_shared_site_documents() {
61-
if (env.VERCEL) {
61+
if (VERCEL) {
6262
const { NAV_1, FOOTER_1 } = await import('$lib/demo_doc.js');
6363
return { nav_document: NAV_1, footer_document: FOOTER_1 };
6464
}

src/routes/new/+page.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { redirect } from '@sveltejs/kit';
2-
import { env } from '$env/dynamic/private';
2+
import { VERCEL } from '$app/env/private';
33
import type { PageServerLoad } from './$types';
44

55
// Deliberately no `await parent()` here — see routes/+page.server.ts.
66
export const load: PageServerLoad = async ({ locals }) => {
7-
if (env.VERCEL) {
7+
if (VERCEL) {
88
return {
99
shared_documents: null
1010
};

vite.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export default defineConfig({
1313
sveltekit({
1414
adapter: adapter(),
1515
experimental: {
16-
remoteFunctions: true
16+
remoteFunctions: true,
17+
explicitEnvironmentVariables: true
1718
},
1819
// alias: {
1920
// svedit: '../svedit/src/lib/index.ts'

0 commit comments

Comments
 (0)