From 5561aafd64c54164421bfcc934245515c9cf0a1b Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Tue, 22 Sep 2026 16:41:14 +1000 Subject: [PATCH 01/17] build: point the everyday commands at the local stack `pnpm dev` ran against whatever `.env.local` held, which is the hosted project carrying the pre-redesign schema, so the obvious command failed with PGRST205 while the working one, `dev:local`, had to be learned. The default is now the local stack for dev, build and start alike, and the hosted credentials are read only by the explicit `:prod` variants. Build and start gained the same treatment because a production build against the local stack had no command at all: the publishable key is baked in at build time, so a build reading hosted values cannot be served against local Postgres. `preview:local` does both steps in one. The Supabase status parsing that dev-preview owned moves to supabase-environment.mjs, shared by the three preview entry points, and each of them now spawns the Next binary directly rather than recursing through a pnpm script that would re-read the ambient environment. --- README.md | 34 ++-- apps/web/.env.example | 15 +- apps/web/next-env.d.ts | 4 +- apps/web/package.json | 12 +- apps/web/scripts/local/build-preview.mjs | 29 ++++ apps/web/scripts/local/dev-preview.mjs | 107 +++++------- apps/web/scripts/local/production-preview.mjs | 59 +++++++ apps/web/scripts/local/start-preview.mjs | 45 +++++ .../scripts/local/supabase-environment.mjs | 60 +++++++ apps/web/scripts/paths.mjs | 3 + apps/web/tests/local-preview-seed.test.mjs | 159 ++++++++++++++++-- docs/catalogue-admin-rework.md | 7 +- package.json | 9 +- 13 files changed, 438 insertions(+), 105 deletions(-) create mode 100644 apps/web/scripts/local/build-preview.mjs create mode 100644 apps/web/scripts/local/production-preview.mjs create mode 100644 apps/web/scripts/local/start-preview.mjs create mode 100644 apps/web/scripts/local/supabase-environment.mjs diff --git a/README.md b/README.md index f56ac33c..7f3b6686 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,16 @@ pnpm install cp apps/web/.env.example apps/web/.env.local pnpm db:start # local Supabase stack pnpm db:reset # migrations plus demonstration fixtures -pnpm dev:local # http://127.0.0.1:3000 +pnpm dev # http://127.0.0.1:3000 +# Or build and run the production server against the same local stack: +pnpm build +pnpm start ``` Sign up at `/signup` and the local stack issues a session straight away. To run against a hosted Supabase project instead, configure its URL, publishable key -and your application origin in `apps/web/.env.local`, then use `pnpm dev`. +and your application origin in `apps/web/.env.local`, then use the explicit +`pnpm dev:prod`, `pnpm build:prod` and `pnpm start:prod` commands. The [environment template](apps/web/.env.example) explains the required settings, optional import credentials and map-service defaults. @@ -94,16 +98,22 @@ Level Security, and the service-role key never reaches the browser. ## Commands -| Command | What it does | -| ---------------- | ---------------------------------------------- | -| `pnpm dev:local` | Development server against local Supabase | -| `pnpm check` | Formatting, lint and strict types | -| `pnpm test` | Unit and component tests | -| `pnpm test:e2e` | Authenticated browser journeys | -| `pnpm db:reset` | Rebuild the local database and reseed fixtures | -| `pnpm db:test` | pgTAP database tests | -| `pnpm db:types` | Regenerate committed database types | -| `pnpm verify` | Local application delivery checks | +| Command | What it does | +| -------------------- | ------------------------------------------------------ | +| `pnpm dev` | Development server against local Supabase | +| `pnpm dev:prod` | Development server using configured hosted credentials | +| `pnpm build` | Production build against local Supabase | +| `pnpm start` | Built production server against local Supabase | +| `pnpm preview:local` | Build and start against local Supabase in one step | +| `pnpm build:prod` | Production build using configured hosted credentials | +| `pnpm start:prod` | Built server using configured hosted credentials | +| `pnpm check` | Formatting, lint and strict types | +| `pnpm test` | Unit and component tests | +| `pnpm test:e2e` | Authenticated browser journeys | +| `pnpm db:reset` | Rebuild the local database and reseed fixtures | +| `pnpm db:test` | pgTAP database tests | +| `pnpm db:types` | Regenerate committed database types | +| `pnpm verify` | Local application delivery checks | Run `pnpm verify` before opening a pull request. CI additionally runs database checks, authenticated browser journeys and a production dependency audit. See diff --git a/apps/web/.env.example b/apps/web/.env.example index 5b6c0b1a..a98cb357 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -5,9 +5,10 @@ # NEXT_PUBLIC_* values are browser-visible and baked into production builds. # Rebuild after changing them. Other values below are server-only. # -# Local setup: pnpm db:start, pnpm db:reset, then pnpm dev:local. -# dev:local reads the local Supabase URL and keys automatically and supplies the -# site URL. When using pnpm dev directly, fill in the required section yourself. +# Local setup: pnpm db:start, pnpm db:reset, then pnpm dev. +# dev, build, start and preview:local read the local Supabase URL and keys +# automatically and supply the site URL. Only the explicit :prod commands read +# the hosted values below. # Optional overrides stay commented out until needed. # ============================================================================= @@ -21,14 +22,14 @@ # project's Connect dialog. Keep this URL and the public key on the same project. NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 -# Browser-safe publishable key. Required when using pnpm dev directly. -# dev:local supplies this from Supabase; never substitute a server secret here. +# Browser-safe publishable key. Required by the explicit :prod commands. +# Local commands supply this from Supabase; never substitute a server secret. NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= # Public application origin for authentication redirects and cookie settings. # Use HTTPS outside localhost/127.0.0.1, with no path, query or fragment. # Match the allowed authentication URLs in your Supabase configuration. -# dev:local overrides this with http://127.0.0.1:3000. +# Local commands override this with http://127.0.0.1:3000. NEXT_PUBLIC_SITE_URL=http://localhost:3000 # ============================================================================= @@ -38,7 +39,7 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Configure them when checking detailed ANU source material. # Privileged Supabase server key for local preview and browser test user setup. -# dev:local supplies the local server key automatically. +# Local commands supply the local server key automatically. # Use a key from the same project as NEXT_PUBLIC_SUPABASE_URL. Keep it server-only. # Used by: scripts/local/dev-preview.mjs and playwright/fixtures.ts. # SUPABASE_SECRET_KEY= diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index ce4e94a6..a419cbe4 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 5f60fd2f..560128f1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,16 +5,20 @@ "type": "module", "scripts": { "predev": "node scripts/copy-maplibre-worker.mjs", - "dev": "next dev --webpack", + "dev": "node scripts/local/dev-preview.mjs", + "dev:prod": "node scripts/copy-maplibre-worker.mjs && next dev --webpack", "prebuild": "node scripts/copy-maplibre-worker.mjs", - "build": "next build --webpack", - "start": "next start", + "build": "node scripts/local/build-preview.mjs", + "build:next": "next build --webpack", + "build:prod": "node scripts/copy-maplibre-worker.mjs && next build --webpack", + "start": "node scripts/local/start-preview.mjs", + "start:prod": "next start", "lint": "eslint . --ignore-pattern .next", "lint:fix": "pnpm lint --fix", "typecheck": "tsc --noEmit --incremental false", "test:unit": "vitest run --project unit --project component", "test:catalogue-db": "vitest run --project database", - "test:build:auth": "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:4318 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:9 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_test pnpm build", + "test:build:auth": "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:4318 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:9 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_test pnpm build:prod", "test:auth-access": "COURSEMAP_TEST_PROFILE=access playwright test", "test:e2e:build": "node scripts/local/build-e2e.mjs", "test:e2e": "COURSEMAP_TEST_PROFILE=authenticated playwright test", diff --git a/apps/web/scripts/local/build-preview.mjs b/apps/web/scripts/local/build-preview.mjs new file mode 100644 index 00000000..03280c1c --- /dev/null +++ b/apps/web/scripts/local/build-preview.mjs @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function buildLocalProduction({ + environment = createLocalApplicationEnvironment(), + runCommand = spawnSync, +} = {}) { + return runCommand("pnpm", ["run", "build:next"], { + cwd: appRoot, + env: environment, + stdio: "inherit", + }).status; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + let status; + try { + status = buildLocalProduction(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + process.exit(status ?? 1); +} diff --git a/apps/web/scripts/local/dev-preview.mjs b/apps/web/scripts/local/dev-preview.mjs index 16796241..7cd38fba 100644 --- a/apps/web/scripts/local/dev-preview.mjs +++ b/apps/web/scripts/local/dev-preview.mjs @@ -1,72 +1,53 @@ -import { appRoot, repositoryRoot } from "../paths.mjs"; -import { spawn, spawnSync } from "node:child_process"; - -export function parseSupabaseEnvironment(output) { - const values = new Map(); - for (const line of output.split(/\r?\n/)) { - const match = line.match(/^([A-Z_]+)=(?:"([^"]*)"|(.*))$/); - if (match) values.set(match[1], match[2] ?? match[3] ?? ""); - } - - return { - apiUrl: values.get("API_URL"), - databaseUrl: values.get("DB_URL"), - publishableKey: values.get("PUBLISHABLE_KEY") ?? values.get("ANON_KEY"), - secretKey: values.get("SECRET_KEY") ?? values.get("SERVICE_ROLE_KEY"), - }; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startLocalDevelopmentPreview({ + environment = createLocalApplicationEnvironment(), + spawnCommand = spawn, +} = {}) { + return spawnCommand( + process.execPath, + [ + nextCliPath, + "dev", + "--webpack", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); } -function readSupabaseEnvironment() { - const result = spawnSync("supabase", ["status", "-o", "env"], { - cwd: repositoryRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.status !== 0) { - console.error( - "Local Supabase is unavailable. Run `pnpm db:start` before `pnpm dev:local`.", - ); - process.exit(result.status ?? 1); +function run() { + let child; + try { + child = startLocalDevelopmentPreview(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); } - const { apiUrl, databaseUrl, publishableKey, secretKey } = - parseSupabaseEnvironment(result.stdout); - if (!apiUrl || !publishableKey || !secretKey) { - console.error( - "Supabase did not return its local API URL, public key and server key.", - ); - process.exit(1); + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); } - return { apiUrl, databaseUrl, publishableKey, secretKey }; + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); } -const { apiUrl, databaseUrl, publishableKey, secretKey } = - readSupabaseEnvironment(); -const child = spawn( - "pnpm", - ["run", "dev", "--hostname", "127.0.0.1", "--port", "3000"], - { - cwd: appRoot, - env: { - ...process.env, - NEXT_PUBLIC_SITE_URL: "http://127.0.0.1:3000", - // The import pipeline and admin workspace connect to Postgres directly. - COURSEMAP_DATABASE_URL: databaseUrl, - NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: publishableKey, - NEXT_PUBLIC_SUPABASE_URL: apiUrl, - SUPABASE_SECRET_KEY: secretKey, - }, - stdio: "inherit", - }, -); - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); } - -child.on("exit", (code, signal) => { - if (signal) process.kill(process.pid, signal); - else process.exit(code ?? 1); -}); diff --git a/apps/web/scripts/local/production-preview.mjs b/apps/web/scripts/local/production-preview.mjs new file mode 100644 index 00000000..cd931755 --- /dev/null +++ b/apps/web/scripts/local/production-preview.mjs @@ -0,0 +1,59 @@ +import { spawn, spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startLocalProductionPreview({ + environment = createLocalApplicationEnvironment(), + runBuild = spawnSync, + spawnServer = spawn, +} = {}) { + const build = runBuild("pnpm", ["run", "build"], { + cwd: appRoot, + env: environment, + stdio: "inherit", + }); + if (build.status !== 0) { + return { child: null, exitCode: build.status ?? 1 }; + } + + const child = spawnServer( + process.execPath, + [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); + return { child, exitCode: null }; +} + +function run() { + let result; + try { + result = startLocalProductionPreview(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + + if (!result.child) process.exit(result.exitCode ?? 1); + const child = result.child; + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); + } + + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); +} diff --git a/apps/web/scripts/local/start-preview.mjs b/apps/web/scripts/local/start-preview.mjs new file mode 100644 index 00000000..1c19599a --- /dev/null +++ b/apps/web/scripts/local/start-preview.mjs @@ -0,0 +1,45 @@ +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { appRoot, nextCliPath } from "../paths.mjs"; +import { createLocalApplicationEnvironment } from "./supabase-environment.mjs"; + +export function startBuiltLocalProduction({ + environment = createLocalApplicationEnvironment(), + spawnCommand = spawn, +} = {}) { + return spawnCommand( + process.execPath, + [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + { + cwd: appRoot, + env: environment, + stdio: "inherit", + }, + ); +} + +function run() { + let child; + try { + child = startBuiltLocalProduction(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); + } + + child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(); +} diff --git a/apps/web/scripts/local/supabase-environment.mjs b/apps/web/scripts/local/supabase-environment.mjs new file mode 100644 index 00000000..3765c3bb --- /dev/null +++ b/apps/web/scripts/local/supabase-environment.mjs @@ -0,0 +1,60 @@ +import { spawnSync } from "node:child_process"; +import { repositoryRoot } from "../paths.mjs"; + +export function parseSupabaseEnvironment(output) { + const values = new Map(); + for (const line of output.split(/\r?\n/)) { + const match = line.match(/^([A-Z_]+)=(?:"([^"]*)"|(.*))$/); + if (match) values.set(match[1], match[2] ?? match[3] ?? ""); + } + + return { + apiUrl: values.get("API_URL"), + databaseUrl: values.get("DB_URL"), + publishableKey: values.get("PUBLISHABLE_KEY") ?? values.get("ANON_KEY"), + secretKey: values.get("SECRET_KEY") ?? values.get("SERVICE_ROLE_KEY"), + }; +} + +export function readLocalSupabaseEnvironment({ runCommand = spawnSync } = {}) { + const result = runCommand("supabase", ["status", "-o", "env"], { + cwd: repositoryRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error( + "Local Supabase is unavailable. Run `pnpm db:start` before starting a local preview.", + ); + } + + const environment = parseSupabaseEnvironment(result.stdout); + if ( + !environment.apiUrl || + !environment.databaseUrl || + !environment.publishableKey || + !environment.secretKey + ) { + throw new Error( + "Supabase did not return its local API URL, database URL, public key and server key.", + ); + } + + return environment; +} + +export function createLocalApplicationEnvironment({ + baseEnvironment = process.env, + supabaseEnvironment = readLocalSupabaseEnvironment(), +} = {}) { + return { + ...baseEnvironment, + NEXT_PUBLIC_SITE_URL: "http://127.0.0.1:3000", + // The import pipeline and admin workspace connect to Postgres directly. + COURSEMAP_DATABASE_URL: supabaseEnvironment.databaseUrl, + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: supabaseEnvironment.publishableKey, + NEXT_PUBLIC_SUPABASE_URL: supabaseEnvironment.apiUrl, + SUPABASE_SECRET_KEY: supabaseEnvironment.secretKey, + }; +} diff --git a/apps/web/scripts/paths.mjs b/apps/web/scripts/paths.mjs index d3dade29..c0a46d32 100644 --- a/apps/web/scripts/paths.mjs +++ b/apps/web/scripts/paths.mjs @@ -1,5 +1,8 @@ import { fileURLToPath } from "node:url"; export const appRoot = fileURLToPath(new URL("../", import.meta.url)); +export const nextCliPath = fileURLToPath( + new URL("../node_modules/next/dist/bin/next", import.meta.url), +); export const repositoryRoot = fileURLToPath( new URL("../../../", import.meta.url), ); diff --git a/apps/web/tests/local-preview-seed.test.mjs b/apps/web/tests/local-preview-seed.test.mjs index 2ff68524..422dd2cb 100644 --- a/apps/web/tests/local-preview-seed.test.mjs +++ b/apps/web/tests/local-preview-seed.test.mjs @@ -1,4 +1,4 @@ -import { repositoryRoot } from "../scripts/paths.mjs"; +import { nextCliPath, repositoryRoot } from "../scripts/paths.mjs"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { readFile } from "node:fs/promises"; @@ -9,11 +9,14 @@ import { resetLocalPreview, } from "../scripts/local/reset-preview.mjs"; import { seedLocalPreview } from "../scripts/local/seed-preview.mjs"; - -const devPreviewSource = new URL( - "../scripts/local/dev-preview.mjs", - import.meta.url, -); +import { buildLocalProduction } from "../scripts/local/build-preview.mjs"; +import { startLocalDevelopmentPreview } from "../scripts/local/dev-preview.mjs"; +import { startLocalProductionPreview } from "../scripts/local/production-preview.mjs"; +import { startBuiltLocalProduction } from "../scripts/local/start-preview.mjs"; +import { + createLocalApplicationEnvironment, + parseSupabaseEnvironment, +} from "../scripts/local/supabase-environment.mjs"; test("keeps predictable preview credentials out of Supabase's default seed", async () => { const defaultSeed = await readFile( @@ -42,12 +45,146 @@ test("the local preview publishes every selectable academic structure kind", asy assert.match(previewSeed, /set published_version_id = snapshots\.id/u); }); -test("passes the local server key to durable import workers", async () => { - const source = await readFile(devPreviewSource, "utf8"); +test("passes the local server key to durable import workers", () => { + const supabaseEnvironment = parseSupabaseEnvironment( + [ + 'API_URL="http://127.0.0.1:54321"', + 'DB_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"', + 'ANON_KEY="public-key"', + 'SERVICE_ROLE_KEY="server-key"', + ].join("\n"), + ); + const environment = createLocalApplicationEnvironment({ + baseEnvironment: { KEEP_ME: "yes" }, + supabaseEnvironment, + }); + + assert.equal(environment.KEEP_ME, "yes"); + assert.equal( + environment.NEXT_PUBLIC_SUPABASE_URL, + supabaseEnvironment.apiUrl, + ); + assert.equal(environment.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, "public-key"); + assert.equal(environment.SUPABASE_SECRET_KEY, "server-key"); + assert.equal( + environment.COURSEMAP_DATABASE_URL, + supabaseEnvironment.databaseUrl, + ); +}); + +test("builds and starts the production preview with the same local environment", () => { + const calls = []; + const child = new EventEmitter(); + const environment = { LOCAL_PREVIEW: "true" }; + + const result = startLocalProductionPreview({ + environment, + runBuild(executable, args, options) { + calls.push({ executable, args, options }); + return { status: 0 }; + }, + spawnServer(executable, args, options) { + calls.push({ executable, args, options }); + return child; + }, + }); + + assert.equal(result.child, child); + assert.equal(result.exitCode, null); + assert.deepEqual(calls, [ + { + executable: "pnpm", + args: ["run", "build"], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }, + { + executable: process.execPath, + args: [nextCliPath, "start", "--hostname", "127.0.0.1", "--port", "3000"], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }, + ]); +}); + +test("the standalone build and start commands also inject the local environment", () => { + const calls = []; + const environment = { LOCAL_PREVIEW: "true" }; + const child = new EventEmitter(); - assert.match(source, /values\.get\("SECRET_KEY"\)/u); - assert.match(source, /values\.get\("SERVICE_ROLE_KEY"\)/u); - assert.match(source, /SUPABASE_SECRET_KEY: secretKey/u); + const buildStatus = buildLocalProduction({ + environment, + runCommand(executable, args, options) { + calls.push({ executable, args, options }); + return { status: 0 }; + }, + }); + const server = startBuiltLocalProduction({ + environment, + spawnCommand(executable, args, options) { + calls.push({ executable, args, options }); + return child; + }, + }); + + assert.equal(buildStatus, 0); + assert.equal(server, child); + assert.deepEqual( + calls.map(({ args, options }) => ({ args, environment: options.env })), + [ + { args: ["run", "build:next"], environment }, + { + args: [ + nextCliPath, + "start", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + environment, + }, + ], + ); +}); + +test("development starts Next directly so stopping it cannot orphan a server", () => { + let command; + const child = new EventEmitter(); + const environment = { LOCAL_PREVIEW: "true" }; + + const server = startLocalDevelopmentPreview({ + environment, + spawnCommand(executable, args, options) { + command = { executable, args, options }; + return child; + }, + }); + + assert.equal(server, child); + assert.deepEqual(command, { + executable: process.execPath, + args: [ + nextCliPath, + "dev", + "--webpack", + "--hostname", + "127.0.0.1", + "--port", + "3000", + ], + options: { + cwd: new URL("../", import.meta.url).pathname, + env: environment, + stdio: "inherit", + }, + }); }); test("runs the preview fixture through the verified local database client", async () => { diff --git a/docs/catalogue-admin-rework.md b/docs/catalogue-admin-rework.md index 5a7dcd96..a71360cd 100644 --- a/docs/catalogue-admin-rework.md +++ b/docs/catalogue-admin-rework.md @@ -189,10 +189,9 @@ Publish. ## Working locally -- Use `pnpm dev:local`. `apps/web/.env.local` points at the hosted project, - which still carries the pre-redesign schema, so `pnpm dev` fails with - `PGRST205` against tables this branch removed. None of the fixes above are - live there; they travel with the cutover after A8. +- Use `pnpm dev`, `pnpm build` and `pnpm start` for the local stack. The + explicit `:prod` variants are the only commands that read hosted credentials + from `apps/web/.env.local`. - `pnpm db:reset` drops the storage buckets and does not recreate them, so imports then fail with an opaque gateway error. Recreate `course-import-artifacts` from `supabase/config.toml`. diff --git a/package.json b/package.json index 52398f36..91943280 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,14 @@ }, "packageManager": "pnpm@12.3.4", "scripts": { - "dev": "turbo run dev", - "dev:local": "node apps/web/scripts/local/dev-preview.mjs", + "dev": "pnpm --filter @coursemap/web dev", + "dev:local": "pnpm --filter @coursemap/web dev", + "dev:prod": "pnpm --filter @coursemap/web dev:prod", + "preview:local": "node apps/web/scripts/local/production-preview.mjs", "build": "turbo run build", + "build:prod": "pnpm --filter @coursemap/web build:prod", + "start": "pnpm --filter @coursemap/web start", + "start:prod": "pnpm --filter @coursemap/web start:prod", "lint": "turbo run lint", "typecheck": "turbo run typecheck", "format": "prettier --write .", From b047884deb236cd898af52d1effffb2b6d717575 Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Tue, 22 Sep 2026 16:41:23 +1000 Subject: [PATCH 02/17] feat: let sign-in work before its JavaScript arrives The sign-in form only existed as a submit handler, so a password typed before hydration finished posted nowhere and the page appeared to swallow it. The form now posts to /auth/password, which signs in on the server and redirects, and the client handler still takes over once it is live. The route only accepts same-origin posts, takes a single value for each field, and sends a failed attempt back to /login?error=invalid-login rather than naming which half was wrong. Both handler paths reach the same message, so the server-rendered failure and the client one read alike. /auth/password and /auth/sign-in join the handler paths that safeInternalRedirect refuses as a destination, so a next= parameter cannot aim the redirect back into the sign-in machinery. --- apps/web/app/auth/password/route.ts | 81 ++++++++++++++++++++++ apps/web/app/auth/sign-in/sign-in-form.tsx | 11 ++- apps/web/app/login/page.tsx | 10 ++- apps/web/lib/auth/redirect.ts | 2 + apps/web/tests/auth-redirect.test.mjs | 2 + apps/web/tests/sign-in-form.test.tsx | 30 ++++++++ 6 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/auth/password/route.ts create mode 100644 apps/web/tests/sign-in-form.test.tsx diff --git a/apps/web/app/auth/password/route.ts b/apps/web/app/auth/password/route.ts new file mode 100644 index 00000000..0d15cac3 --- /dev/null +++ b/apps/web/app/auth/password/route.ts @@ -0,0 +1,81 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { safeInternalRedirect } from "@/lib/auth/redirect"; +import { + getSiteOriginForRequest, + getSupabaseConfig, +} from "@/lib/supabase/config"; +import { createRequestClient } from "@/lib/supabase/request"; + +function noStore(response: NextResponse) { + response.headers.set( + "Cache-Control", + "private, no-cache, no-store, must-revalidate, max-age=0", + ); + response.headers.set("Expires", "0"); + response.headers.set("Pragma", "no-cache"); + return response; +} + +function loginRedirect(origin: string, next: string) { + const url = new URL("/login", origin); + url.searchParams.set("next", next); + url.searchParams.set("error", "invalid-login"); + return noStore(NextResponse.redirect(url, 303)); +} + +export async function POST(request: NextRequest) { + const siteOrigin = getSiteOriginForRequest( + request.nextUrl, + request.headers.get("x-forwarded-host") ?? request.headers.get("host"), + request.headers.get("x-forwarded-proto"), + ); + if (!siteOrigin || !getSupabaseConfig()) { + return new NextResponse("Coursemap authentication is not configured.", { + status: 503, + headers: { "Cache-Control": "private, no-store" }, + }); + } + + if (request.headers.get("origin") !== siteOrigin) { + return new NextResponse("Invalid request origin.", { + status: 403, + headers: { "Cache-Control": "private, no-store" }, + }); + } + + const formData = await request.formData(); + const emails = formData.getAll("email"); + const passwords = formData.getAll("password"); + const nextValues = formData.getAll("next"); + const email = emails.length === 1 ? emails[0] : null; + const password = passwords.length === 1 ? passwords[0] : null; + const next = safeInternalRedirect( + nextValues.length === 1 && typeof nextValues[0] === "string" + ? nextValues[0] + : null, + ); + + if ( + typeof email !== "string" || + typeof password !== "string" || + !email.trim() || + email.length > 254 || + password.length < 8 || + password.length > 128 + ) { + return loginRedirect(siteOrigin, next); + } + + const response = noStore( + NextResponse.redirect(new URL(next, siteOrigin), 303), + ); + const { supabase, applyTo } = createRequestClient(request, response); + const { error } = await supabase.auth.signInWithPassword({ + email: email.trim(), + password, + }); + + if (error) return applyTo(loginRedirect(siteOrigin, next)); + return response; +} diff --git a/apps/web/app/auth/sign-in/sign-in-form.tsx b/apps/web/app/auth/sign-in/sign-in-form.tsx index 24b5c87e..fac9032c 100644 --- a/apps/web/app/auth/sign-in/sign-in-form.tsx +++ b/apps/web/app/auth/sign-in/sign-in-form.tsx @@ -13,14 +13,16 @@ import { createClient } from "@/lib/supabase/browser"; export function SignInForm({ next, configured, + initialError = null, }: { next: string; configured: boolean; + initialError?: string | null; }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(initialError); const submit = async (event: FormEvent) => { event.preventDefault(); @@ -56,7 +58,12 @@ export function SignInForm({ }; return ( -
+