From c53f74b7613acb486817e9d3f704701e2f6ff1a6 Mon Sep 17 00:00:00 2001 From: Brandon Corfman Date: Wed, 27 May 2026 16:58:07 -0400 Subject: [PATCH] Cloud OAuth setup with invite-only --- .github/workflows/deploy-frontend-pages.yml | 74 ++++++++ .gitignore | 3 + .plans/test-deploy-gh-pages-railway.md | 162 ++++++++++++++++++ package.json | 3 +- .../20260527072000_invites/migration.sql | 21 +++ prisma/schema.prisma | 16 ++ scripts/create-invite.ts | 57 ++++++ server/src/server/repositories/memory.ts | 52 +++++- server/src/server/repositories/prisma.ts | 79 ++++++++- server/src/server/routes/auth.ts | 52 ++++-- server/src/server/services/authService.ts | 27 ++- server/src/server/types.ts | 20 ++- server/src/settings.ts | 16 +- src/cloud/api.ts | 26 ++- src/editor/CloudAccountPanel.tsx | 22 ++- tests/cloud/api.test.ts | 23 ++- tests/server/auth.test.ts | 75 +++++++- tests/server/games.test.ts | 4 + tests/server/invite-only-auth.test.ts | 121 +++++++++++++ 19 files changed, 821 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/deploy-frontend-pages.yml create mode 100644 .plans/test-deploy-gh-pages-railway.md create mode 100644 prisma/migrations/20260527072000_invites/migration.sql create mode 100644 scripts/create-invite.ts create mode 100644 tests/server/invite-only-auth.test.ts diff --git a/.github/workflows/deploy-frontend-pages.yml b/.github/workflows/deploy-frontend-pages.yml new file mode 100644 index 0000000..fb072bf --- /dev/null +++ b/.github/workflows/deploy-frontend-pages.yml @@ -0,0 +1,74 @@ +name: Deploy Frontend (GitHub Pages) + +on: + workflow_run: + workflows: + - PhaserForge CI + types: + - completed + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' || + ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository + ) + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Always deploy from trusted `main`, never from an arbitrary SHA. + ref: main + + - name: Verify main SHA (workflow_run) + if: github.event_name == 'workflow_run' + run: | + set -euo pipefail + current="$(git rev-parse HEAD)" + expected="${{ github.event.workflow_run.head_sha }}" + if [ "$current" != "$expected" ]; then + echo "Checked out main at $current, but workflow_run head_sha is $expected; refusing to deploy." >&2 + exit 1 + fi + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Build + env: + VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }} + run: npm run build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + diff --git a/.gitignore b/.gitignore index 325adf8..97da435 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ dist-ssr .codegraph/ playwright-report/ test-results/ + +# Private local notes (never commit) +.plans/private/ diff --git a/.plans/test-deploy-gh-pages-railway.md b/.plans/test-deploy-gh-pages-railway.md new file mode 100644 index 0000000..50820b8 --- /dev/null +++ b/.plans/test-deploy-gh-pages-railway.md @@ -0,0 +1,162 @@ +# Test Deploy: GitHub Pages (Frontend) + Railway (Backend) with Sessions + +Goal: deploy a test/staging version ASAP where the frontend is hosted on GitHub Pages and the API/auth backend is hosted on Railway, with cookie-backed sessions working cross-origin. + +Concrete URLs for this plan: +- Frontend (GitHub Pages): `https://bcorfman.github.io/phaserforge` +- Backend (Railway): `https://phaseractions-studio-production.up.railway.app` + +## Summary +- Add a GitHub Actions workflow to deploy `dist/` to GitHub Pages on green `main`. +- Make the frontend configurable for a remote API base URL (Pages → Railway) so `/api/...` calls work outside local dev. +- Configure backend cookies for cross-site credentialed requests (Pages and Railway are different origins), and redirect GitHub OAuth back to the Pages frontend. +- Set required Railway env vars for CORS, cookies, proxying, database, and OAuth. +- Verify with unit tests + required Chromium smoke E2E (GUI touched). + +## Implementation changes + +### 1) Frontend: API base URL + OAuth start URL +Problem: +- `src/cloud/api.ts` currently calls `fetch('/api/...')`, which only works when the frontend and backend are same-origin or when dev proxying is in place (Vite `server.proxy`). +- `src/editor/CloudAccountPanel.tsx` hardcodes `href="/api/v1/auth/github/start?returnTo=/"`, which also only works same-origin. + +Change: +- Introduce a build-time env var `VITE_API_BASE_URL` (example: `https://phaseractions-studio-production.up.railway.app`). +- Update `src/cloud/api.ts` to build absolute request URLs: + - `const base = import.meta.env.VITE_API_BASE_URL ?? ''` + - `const url = new URL(path, base).toString()` + - `fetch(url, { credentials: 'include', ... })` + - Keep headers behavior unchanged, but ensure the `content-type` header is only added when `init.body` exists (current behavior is fine). +- Update `src/editor/CloudAccountPanel.tsx` GitHub button: + - Replace `href="/api/v1/auth/github/start?returnTo=/"` with: + - `const apiBase = import.meta.env.VITE_API_BASE_URL` + - `const returnTo = import.meta.env.BASE_URL` (Vite base path; for project pages this will be `/phaserforge/` at runtime) + - `href=\`\${apiBase}/api/v1/auth/github/start?returnTo=\${encodeURIComponent(returnTo)}\`` + +Acceptance criteria: +- When opened from Pages, the browser requests go to `https://phaseractions-studio-production.up.railway.app/api/...`. +- Clicking “Login with GitHub” initiates OAuth via the Railway backend and returns to the Pages frontend. + +### 2) Backend: cross-site cookies + OAuth redirect to frontend +Problem: +- Current cookies are set with `sameSite: 'lax'`. Cross-origin `fetch(..., { credentials: 'include' })` generally requires cookies to be `SameSite=None; Secure`. +- OAuth callback currently redirects to `returnTo` as a relative path on the backend origin; we need to redirect the browser back to the Pages frontend origin. + +Change settings contract (server): +- Extend `server/src/settings.ts`: + - Add `cookieSameSite: 'lax' | 'none'` from env `COOKIE_SAMESITE` (default `'lax'`). + - Add `frontendBaseUrl?: string` from env `FRONTEND_BASE_URL` (required when GitHub OAuth is enabled; also required when `cookieSameSite='none'` for cross-site deploy). + +Change cookie options: +- Update cookie writes in: + - `server/src/server/services/authService.ts` (session cookie) + - `server/src/server/routes/auth.ts` (csrf cookie, oauth state cookie, return-to cookie) +- Cookie policy: + - If `cookieSameSite === 'none'`: + - set `sameSite: 'none'` + - set `secure: true` (ignore `COOKIE_SECURE` env and hard-require secure in this mode) + - Else: + - keep `sameSite: 'lax'` + - set `secure: settings.cookieSecure` (existing behavior) +- Keep `httpOnly` unchanged per cookie type (csrf cookie must stay readable by JS; session and oauth state must remain `httpOnly`). + +OAuth redirect policy (decision-complete): +- `GET /api/v1/auth/github/start?returnTo=` + - `returnTo` must be a string starting with `/`. + - Store this path as today (cookie). +- `GET /api/v1/auth/github/callback` + - After session creation, redirect to `new URL(returnToPath, settings.frontendBaseUrl).toString()`. + - Validate `settings.frontendBaseUrl` is configured; if missing, return `400 { error: 'oauth_not_configured' }`. + - Validate that the final redirect URL’s origin equals the configured `FRONTEND_BASE_URL` origin (prevents open redirect); if mismatch, redirect to the frontend base URL root path instead. + +Acceptance criteria: +- From Pages origin, `fetch(..., { credentials: 'include' })` persists the session cookie on the Railway origin, and subsequent `/api/v1/auth/me` returns the logged-in user. +- OAuth completes and lands back on `https://bcorfman.github.io/phaserforge/` without manual navigation. + +### 3) Backend: CORS allowlist + proxy correctness +Problem: +- Credentialed cross-origin requests require: + - `Access-Control-Allow-Origin: ` (not `*`) + - `Access-Control-Allow-Credentials: true` +- Cookies marked `Secure` behind Railway require correct proxy settings. + +Change: +- No code changes required if env is set correctly; confirm the policy is implemented by `corsAllowlistMiddleware` in `server/src/server/app.ts`. +- On Railway set: + - `CORS_ALLOW_ORIGINS=https://bcorfman.github.io` + - `TRUST_PROXY=true` + +Acceptance criteria: +- Browser preflight/OPTIONS succeeds. +- Actual API requests include cookies and succeed without CORS errors. + +### 4) GitHub Actions: deploy frontend to Pages on green main +Add a new workflow `.github/workflows/deploy-frontend-pages.yml`: +- Triggers: + - `workflow_run` on “PhaserForge CI” `completed`, gated to: + - conclusion `success` + - event `push` + - branch `main` + - same repo + - `workflow_dispatch` +- Permissions: + - `contents: read` + - `pages: write` + - `id-token: write` +- Steps: + - checkout `main` + - setup node `24` with npm cache + - `npm ci` + - build: + - `VITE_API_BASE_URL=https://phaseractions-studio-production.up.railway.app npm run build` + - upload `dist/` as Pages artifact + - deploy with `actions/deploy-pages` + +GitHub repository settings: +- Settings → Pages → Source: **GitHub Actions**. +- Settings → Actions → Variables: + - `VITE_API_BASE_URL=https://phaseractions-studio-production.up.railway.app` + +Acceptance criteria: +- A push to `main` that passes CI produces a Pages deployment at `https://bcorfman.github.io/phaserforge`. + +### 5) Railway: required env vars for this deployment +Set these Railway service variables (exact values): +- `PUBLIC_BASE_URL=https://phaseractions-studio-production.up.railway.app` +- `FRONTEND_BASE_URL=https://bcorfman.github.io/phaserforge` +- `CORS_ALLOW_ORIGINS=https://bcorfman.github.io` +- `COOKIE_SAMESITE=none` +- `TRUST_PROXY=true` +- `DATABASE_URL` (Railway Postgres plugin should provide this automatically) +- GitHub OAuth (if enabling GitHub login): + - `GITHUB_CLIENT_ID=` + - `GITHUB_CLIENT_SECRET=` + +Notes: +- Do not set `COOKIE_DOMAIN` for this pairing; let the cookie be scoped to the Railway host only. +- With `COOKIE_SAMESITE=none`, the implementation hard-requires `secure: true` for cookies (HTTPS). + +### 6) GitHub OAuth app: callback URL +Configure GitHub OAuth app: +- Callback URL: `https://phaseractions-studio-production.up.railway.app/api/v1/auth/github/callback` + +## Test plan (must be non-flaky) +Because frontend UI files will be touched (`src/editor/**`), run: +- Unit tests: `npm run test:unit` +- Local E2E smoke (Chromium only): `npm run test:e2e -- --project=chromium --grep @smoke` + +Manual deployed smoke: +1) Open `https://bcorfman.github.io/phaserforge` +2) In Cloud panel: + - Click “Log in” with a known password account; confirm it stays logged in after refresh. + - Create/save a cloud game; refresh; confirm it still lists. + - Log out; refresh; confirm signed out. +3) GitHub OAuth: + - Click “Login with GitHub”; approve; confirm you land back on Pages and are signed in. + +## Definition of done +- GitHub Pages deploy workflow exists and successfully deploys on green `main`. +- Railway backend accepts credentialed cross-origin requests from `https://bcorfman.github.io`. +- Sessions persist and `me/games` APIs work from the Pages frontend. +- Unit + Chromium smoke E2E pass with zero flakes. + diff --git a/package.json b/package.json index 14db24c..fb643bd 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "test:unit": "vitest run", "test:e2e": "node scripts/playwright-no-deprecation.cjs test", "test:e2e:ui": "node scripts/playwright-no-deprecation.cjs test --ui", - "test:all": "npm run test:unit && npm run test:e2e" + "test:all": "npm run test:unit && npm run test:e2e", + "invite:create": "node --import tsx scripts/create-invite.ts" }, "dependencies": { "@prisma/adapter-pg": "^7.8.0", diff --git a/prisma/migrations/20260527072000_invites/migration.sql b/prisma/migrations/20260527072000_invites/migration.sql new file mode 100644 index 0000000..58b5a86 --- /dev/null +++ b/prisma/migrations/20260527072000_invites/migration.sql @@ -0,0 +1,21 @@ +CREATE TABLE "Invite" ( + "id" TEXT NOT NULL, + "email" CITEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "usedByUserId" TEXT, + + CONSTRAINT "Invite_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash"); +CREATE INDEX "Invite_email_idx" ON "Invite"("email"); +CREATE INDEX "Invite_expiresAt_idx" ON "Invite"("expiresAt"); + +ALTER TABLE "Invite" + ADD CONSTRAINT "Invite_usedByUserId_fkey" + FOREIGN KEY ("usedByUserId") REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4b08976..ceec1d5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { oauthAccounts OAuthAccount[] sessions Session[] games Game[] + usedInvites Invite[] @relation("InviteUsedBy") } model OAuthAccount { @@ -44,6 +45,21 @@ model Session { @@index([userId]) } +model Invite { + id String @id + email String @db.Citext + tokenHash String @unique + createdAt DateTime @default(now()) + expiresAt DateTime + usedAt DateTime? + usedByUserId String? + + usedByUser User? @relation("InviteUsedBy", fields: [usedByUserId], references: [id], onDelete: SetNull) + + @@index([email]) + @@index([expiresAt]) +} + model Game { id String @id userId String diff --git a/scripts/create-invite.ts b/scripts/create-invite.ts new file mode 100644 index 0000000..1b7fe29 --- /dev/null +++ b/scripts/create-invite.ts @@ -0,0 +1,57 @@ +import { createPrismaClient } from '../server/src/db/prismaClient'; +import { loadSettingsFromEnv } from '../server/src/settings'; +import { randomToken, sha256Base64Url } from '../server/src/security/crypto'; +import { newId } from '../server/src/security/ids'; +import { createPrismaRepositories } from '../server/src/server/repositories/prisma'; + +function usage() { + // eslint-disable-next-line no-console + console.log('Usage: node --import tsx scripts/create-invite.ts '); +} + +async function main() { + const email = process.argv[2]?.trim(); + if (!email) { + usage(); + process.exitCode = 2; + return; + } + + const settings = loadSettingsFromEnv(process.env); + const prisma = createPrismaClient(process.env.DATABASE_URL); + if (!prisma) { + // eslint-disable-next-line no-console + console.error('DATABASE_URL is required to create invites.'); + process.exitCode = 2; + return; + } + + const repositories = createPrismaRepositories(prisma); + const token = randomToken(32); + const tokenHash = sha256Base64Url(token); + const now = Date.now(); + const expiresAt = new Date(now + settings.inviteTtlMs).toISOString(); + + await repositories.invites.create({ + id: newId('inv'), + email, + tokenHash, + createdAt: new Date(now).toISOString(), + expiresAt, + usedAt: null, + usedByUserId: null, + }); + + // eslint-disable-next-line no-console + console.log(`Invite created for ${email}`); + // eslint-disable-next-line no-console + console.log(`Invite code: ${token}`); + if (settings.frontendBaseUrl) { + // eslint-disable-next-line no-console + console.log(`Frontend: ${settings.frontendBaseUrl}`); + } + + await prisma.$disconnect(); +} + +void main(); diff --git a/server/src/server/repositories/memory.ts b/server/src/server/repositories/memory.ts index 72a06ed..e7cfa6a 100644 --- a/server/src/server/repositories/memory.ts +++ b/server/src/server/repositories/memory.ts @@ -2,6 +2,8 @@ import type { CloudGame, CloudGameMeta, GameRepository, + InviteRecord, + InviteRepository, OAuthAccountRecord, OAuthRepository, Repositories, @@ -96,6 +98,54 @@ class MemorySessionRepository implements SessionRepository { } } +class MemoryInviteRepository implements InviteRepository { + private byId = new Map(); + private byHash = new Map(); + + async findByTokenHash(tokenHash: string): Promise { + const id = this.byHash.get(tokenHash); + if (!id) return null; + return clone(this.byId.get(id) ?? null); + } + + async findUsableByTokenHash(tokenHash: string, nowIso: string): Promise { + const inv = await this.findByTokenHash(tokenHash); + if (!inv) return null; + if (inv.usedAt) return null; + if (new Date(inv.expiresAt).getTime() <= new Date(nowIso).getTime()) return null; + return inv; + } + + async findUsableByEmail(email: string, nowIso: string): Promise { + const key = email.toLowerCase(); + const now = new Date(nowIso).getTime(); + for (const inv of this.byId.values()) { + if (inv.email.toLowerCase() !== key) continue; + if (inv.usedAt) continue; + if (new Date(inv.expiresAt).getTime() <= now) continue; + return clone(inv); + } + return null; + } + + async create(invite: InviteRecord): Promise { + if (this.byHash.has(invite.tokenHash)) { + throw new Error('unique_token_hash_violation'); + } + const record: InviteRecord = { ...invite, email: invite.email.toLowerCase() }; + this.byId.set(record.id, record); + this.byHash.set(record.tokenHash, record.id); + return clone(record); + } + + async markUsed(id: string, userId: string, usedAtIso: string): Promise { + const existing = this.byId.get(id); + if (!existing) return; + existing.usedAt = usedAtIso; + existing.usedByUserId = userId; + } +} + class MemoryGameRepository implements GameRepository { private byId = new Map(); private byUser = new Map>(); @@ -159,7 +209,7 @@ export function createMemoryRepositories(): Repositories { users, oauth: new MemoryOAuthRepository(), sessions: new MemorySessionRepository(), + invites: new MemoryInviteRepository(), games: new MemoryGameRepository(), }; } - diff --git a/server/src/server/repositories/prisma.ts b/server/src/server/repositories/prisma.ts index abd0ac0..ebc7238 100644 --- a/server/src/server/repositories/prisma.ts +++ b/server/src/server/repositories/prisma.ts @@ -4,6 +4,8 @@ import type { CloudGame, CloudGameMeta, GameRepository, + InviteRecord, + InviteRepository, OAuthAccountRecord, OAuthRepository, Repositories, @@ -115,6 +117,80 @@ export function createPrismaRepositories(prisma: PrismaClient): Repositories { }, }; + const invites: InviteRepository = { + async findByTokenHash(tokenHash) { + const row = await prisma.invite.findUnique({ where: { tokenHash } }); + if (!row) return null; + return { + id: row.id, + email: row.email, + tokenHash: row.tokenHash, + createdAt: toIso(row.createdAt), + expiresAt: toIso(row.expiresAt), + usedAt: row.usedAt ? toIso(row.usedAt) : null, + usedByUserId: row.usedByUserId ?? null, + }; + }, + async findUsableByTokenHash(tokenHash, nowIso) { + const row = await prisma.invite.findFirst({ + where: { tokenHash, usedAt: null, expiresAt: { gt: new Date(nowIso) } }, + }); + if (!row) return null; + return { + id: row.id, + email: row.email, + tokenHash: row.tokenHash, + createdAt: toIso(row.createdAt), + expiresAt: toIso(row.expiresAt), + usedAt: null, + usedByUserId: row.usedByUserId ?? null, + }; + }, + async findUsableByEmail(email, nowIso) { + const row = await prisma.invite.findFirst({ + where: { email: email.toLowerCase(), usedAt: null, expiresAt: { gt: new Date(nowIso) } }, + }); + if (!row) return null; + return { + id: row.id, + email: row.email, + tokenHash: row.tokenHash, + createdAt: toIso(row.createdAt), + expiresAt: toIso(row.expiresAt), + usedAt: null, + usedByUserId: row.usedByUserId ?? null, + }; + }, + async create(invite) { + const row = await prisma.invite.create({ + data: { + id: invite.id, + email: invite.email.toLowerCase(), + tokenHash: invite.tokenHash, + createdAt: new Date(invite.createdAt), + expiresAt: new Date(invite.expiresAt), + usedAt: invite.usedAt ? new Date(invite.usedAt) : null, + usedByUserId: invite.usedByUserId, + }, + }); + return { + id: row.id, + email: row.email, + tokenHash: row.tokenHash, + createdAt: toIso(row.createdAt), + expiresAt: toIso(row.expiresAt), + usedAt: row.usedAt ? toIso(row.usedAt) : null, + usedByUserId: row.usedByUserId ?? null, + }; + }, + async markUsed(id, userId, usedAtIso) { + await prisma.invite.update({ + where: { id }, + data: { usedAt: new Date(usedAtIso), usedByUserId: userId }, + }); + }, + }; + const games: GameRepository = { async listByUserId(userId) { const rows = await prisma.game.findMany({ @@ -180,6 +256,5 @@ export function createPrismaRepositories(prisma: PrismaClient): Repositories { }, }; - return { users, oauth, sessions, games }; + return { users, oauth, sessions, invites, games }; } - diff --git a/server/src/server/routes/auth.ts b/server/src/server/routes/auth.ts index e158a80..bf3376f 100644 --- a/server/src/server/routes/auth.ts +++ b/server/src/server/routes/auth.ts @@ -10,6 +10,8 @@ import { newId } from '../../security/ids'; export function authRouter(settings: Settings, repositories: Repositories) { const router = express.Router(); + const cookieSameSite = settings.cookieSameSite === 'none' ? 'none' : 'lax'; + const cookieSecure = settings.cookieSameSite === 'none' ? true : settings.cookieSecure; const authLimiter = rateLimit({ windowMs: 60_000, @@ -22,8 +24,8 @@ export function authRouter(settings: Settings, repositories: Repositories) { const csrfToken = randomToken(24); res.cookie(settings.csrfCookieName, csrfToken, { httpOnly: false, - secure: settings.cookieSecure, - sameSite: 'lax', + secure: cookieSecure, + sameSite: cookieSameSite, path: '/', ...(settings.cookieDomain ? { domain: settings.cookieDomain } : {}), maxAge: 24 * 60 * 60 * 1000, @@ -34,8 +36,8 @@ export function authRouter(settings: Settings, repositories: Repositories) { function setOAuthStateCookie(res: express.Response, token: string) { res.cookie('pa_oauth_state', token, { httpOnly: true, - secure: settings.cookieSecure, - sameSite: 'lax', + secure: cookieSecure, + sameSite: cookieSameSite, path: '/', maxAge: 10 * 60 * 1000, }); @@ -48,8 +50,8 @@ export function authRouter(settings: Settings, repositories: Repositories) { function setReturnToCookie(res: express.Response, returnTo: string) { res.cookie('pa_return_to', returnTo, { httpOnly: true, - secure: settings.cookieSecure, - sameSite: 'lax', + secure: cookieSecure, + sameSite: cookieSameSite, path: '/', maxAge: 10 * 60 * 1000, }); @@ -77,7 +79,7 @@ export function authRouter(settings: Settings, repositories: Repositories) { } router.get('/github/start', authLimiter, (req, res) => { - if (!settings.githubOAuth || !settings.publicBaseUrl) { + if (!settings.githubOAuth || !settings.publicBaseUrl || !settings.frontendBaseUrl) { res.status(400).json({ error: 'oauth_not_configured' }); return; } @@ -103,7 +105,7 @@ export function authRouter(settings: Settings, repositories: Repositories) { }); router.get('/github/callback', authLimiter, async (req, res) => { - if (!settings.githubOAuth || !settings.publicBaseUrl) { + if (!settings.githubOAuth || !settings.publicBaseUrl || !settings.frontendBaseUrl) { res.status(400).json({ error: 'oauth_not_configured' }); return; } @@ -191,10 +193,23 @@ export function authRouter(settings: Settings, repositories: Repositories) { } else { const normalizedEmail = email.trim().toLowerCase(); const existingUser = await repositories.users.findByEmail(normalizedEmail); + let inviteToConsumeId: string | null = null; + if (!existingUser && settings.inviteOnly) { + const inv = await repositories.invites.findUsableByEmail(normalizedEmail, new Date().toISOString()); + if (!inv) { + res.status(403).json({ error: 'invite_required' }); + return; + } + inviteToConsumeId = inv.id; + } + userId = existingUser?.id ?? newId('user'); if (!existingUser) { await repositories.users.create({ id: userId, email: normalizedEmail, passwordHash: null, createdAt: new Date().toISOString() }); } + if (inviteToConsumeId) { + await repositories.invites.markUsed(inviteToConsumeId, userId, new Date().toISOString()); + } await repositories.oauth.create({ id: newId('oa'), userId, @@ -205,7 +220,16 @@ export function authRouter(settings: Settings, repositories: Repositories) { } await createSession(userId, res); - res.redirect(302, returnTo.startsWith('/') ? returnTo : '/'); + + let redirectUrl: string; + try { + const base = new URL(settings.frontendBaseUrl); + const target = new URL(returnTo.startsWith('/') ? returnTo : '/', base); + redirectUrl = target.origin === base.origin ? target.toString() : new URL(base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`, base).toString(); + } catch { + redirectUrl = '/'; + } + res.redirect(302, redirectUrl); }); router.post('/signup', authLimiter, async (req, res) => { @@ -217,7 +241,15 @@ export function authRouter(settings: Settings, repositories: Repositories) { const result = await signupWithPassword(settings, repositories, res, parsed.data); if (!result.ok) { - res.status(result.error === 'email_taken' ? 409 : 400).json({ error: result.error }); + if (result.error === 'email_taken') { + res.status(409).json({ error: result.error }); + return; + } + if (result.error === 'invite_required' || result.error === 'invite_invalid') { + res.status(403).json({ error: result.error }); + return; + } + res.status(400).json({ error: result.error }); return; } res.json({ user: result.user }); diff --git a/server/src/server/services/authService.ts b/server/src/server/services/authService.ts index c2d0739..bb95c18 100644 --- a/server/src/server/services/authService.ts +++ b/server/src/server/services/authService.ts @@ -11,7 +11,7 @@ const Email = z.string().trim().min(3).max(320).email(); const Password = z.string().min(8).max(200); export const AuthSchemas = { - signup: z.object({ email: Email, password: Password }), + signup: z.object({ email: Email, password: Password, inviteToken: z.string().trim().min(8).max(500).optional() }), login: z.object({ email: Email, password: Password }), }; @@ -20,10 +20,12 @@ export function normalizeEmail(email: string): string { } export function setSessionCookie(res: Response, settings: Settings, token: string) { + const sameSite = settings.cookieSameSite === 'none' ? 'none' : 'lax'; + const secure = settings.cookieSameSite === 'none' ? true : settings.cookieSecure; res.cookie(settings.cookieName, token, { httpOnly: true, - secure: settings.cookieSecure, - sameSite: 'lax', + secure, + sameSite, path: '/', ...(settings.cookieDomain ? { domain: settings.cookieDomain } : {}), }); @@ -37,14 +39,25 @@ export async function signupWithPassword( settings: Settings, repositories: Repositories, res: Response, - input: { email: string; password: string }, + input: { email: string; password: string; inviteToken?: string }, ) { const email = normalizeEmail(input.email); + const now = new Date().toISOString(); + + let inviteId: string | null = null; + if (settings.inviteOnly) { + const token = typeof input.inviteToken === 'string' ? input.inviteToken.trim() : ''; + if (!token) return { ok: false as const, error: 'invite_required' as const }; + const tokenHash = sha256Base64Url(token); + const invite = await repositories.invites.findUsableByTokenHash(tokenHash, now); + if (!invite || normalizeEmail(invite.email) !== email) return { ok: false as const, error: 'invite_invalid' as const }; + inviteId = invite.id; + } + const existing = await repositories.users.findByEmail(email); if (existing) return { ok: false as const, error: 'email_taken' as const }; const passwordHash = await hashPassword(input.password); - const now = new Date().toISOString(); const userId = newId('user'); try { @@ -54,6 +67,10 @@ export async function signupWithPassword( throw err; } + if (inviteId) { + await repositories.invites.markUsed(inviteId, userId, now); + } + await createSession(settings, repositories, res, userId); return { ok: true as const, user: { id: userId, email } }; diff --git a/server/src/server/types.ts b/server/src/server/types.ts index cfdfb30..5dc4d5b 100644 --- a/server/src/server/types.ts +++ b/server/src/server/types.ts @@ -34,6 +34,16 @@ export type OAuthAccountRecord = { createdAt: string; }; +export type InviteRecord = { + id: string; + email: string; + tokenHash: string; + createdAt: string; + expiresAt: string; + usedAt: string | null; + usedByUserId: string | null; +}; + export type UserRecord = { id: string; email: string; @@ -59,6 +69,14 @@ export type SessionRepository = { touchLastSeen(id: string, lastSeenAt: string): Promise; }; +export type InviteRepository = { + findByTokenHash(tokenHash: string): Promise; + findUsableByTokenHash(tokenHash: string, nowIso: string): Promise; + findUsableByEmail(email: string, nowIso: string): Promise; + create(invite: InviteRecord): Promise; + markUsed(id: string, userId: string, usedAtIso: string): Promise; +}; + export type GameRepository = { listByUserId(userId: string): Promise; create(game: CloudGame): Promise; @@ -75,6 +93,7 @@ export type Repositories = { users: UserRepository; oauth: OAuthRepository; sessions: SessionRepository; + invites: InviteRepository; games: GameRepository; }; @@ -82,4 +101,3 @@ export type CreateAppOptions = { settings: Settings; repositories?: Repositories; }; - diff --git a/server/src/settings.ts b/server/src/settings.ts index 28c52a7..e89414e 100644 --- a/server/src/settings.ts +++ b/server/src/settings.ts @@ -3,10 +3,14 @@ export type Settings = { cookieName: string; csrfCookieName: string; cookieSecure: boolean; + cookieSameSite: 'lax' | 'none'; cookieDomain?: string; sessionTtlMs: number; trustProxy: boolean; publicBaseUrl?: string; + frontendBaseUrl?: string; + inviteOnly: boolean; + inviteTtlMs: number; githubOAuth?: { clientId: string; clientSecret: string; @@ -22,18 +26,26 @@ export function loadSettingsFromEnv(env: NodeJS.ProcessEnv): Settings { const githubClientId = env.GITHUB_CLIENT_ID; const githubClientSecret = env.GITHUB_CLIENT_SECRET; + const cookieSameSite = (env.COOKIE_SAMESITE ?? 'lax').trim().toLowerCase() === 'none' ? 'none' : 'lax'; + const frontendBaseUrl = typeof env.FRONTEND_BASE_URL === 'string' ? env.FRONTEND_BASE_URL.trim().replace(/\/+$/, '') : undefined; + const inviteOnly = (env.INVITE_ONLY ?? 'false').trim().toLowerCase() === 'true'; + const inviteTtlMs = Number(env.INVITE_TTL_MS ?? 1000 * 60 * 60 * 24 * 7); + return { corsAllowOrigins, cookieName: env.COOKIE_NAME ?? 'pa_session', csrfCookieName: env.CSRF_COOKIE_NAME ?? 'pa_csrf', cookieSecure: (env.COOKIE_SECURE ?? 'false') === 'true', + cookieSameSite, sessionTtlMs: Number(env.SESSION_TTL_MS ?? 1000 * 60 * 60 * 24 * 30), trustProxy: (env.TRUST_PROXY ?? 'false') === 'true', - publicBaseUrl: env.PUBLIC_BASE_URL, + publicBaseUrl: typeof env.PUBLIC_BASE_URL === 'string' ? env.PUBLIC_BASE_URL.trim().replace(/\/+$/, '') : undefined, + frontendBaseUrl, + inviteOnly, + inviteTtlMs, githubOAuth: githubClientId && githubClientSecret ? { clientId: githubClientId, clientSecret: githubClientSecret } : undefined, }; } - diff --git a/src/cloud/api.ts b/src/cloud/api.ts index 59f8e5f..ac84217 100644 --- a/src/cloud/api.ts +++ b/src/cloud/api.ts @@ -4,6 +4,20 @@ export type CloudGame = CloudGameMeta & { yaml: string }; type Json = Record; +function getApiBaseUrl(): string | undefined { + const metaEnv = (import.meta as any)?.env as Record | undefined; + const fromMeta = typeof metaEnv?.VITE_API_BASE_URL === 'string' ? metaEnv.VITE_API_BASE_URL : undefined; + const fromProcess = + typeof process !== 'undefined' && typeof process.env?.VITE_API_BASE_URL === 'string' ? process.env.VITE_API_BASE_URL : undefined; + const url = (fromMeta ?? fromProcess)?.trim(); + return url ? url.replace(/\/+$/, '') : undefined; +} + +function resolveApiUrl(path: string): string { + const base = getApiBaseUrl(); + return base ? new URL(path, `${base}/`).toString() : path; +} + async function readJson(res: Response): Promise { const text = await res.text(); try { @@ -14,7 +28,8 @@ async function readJson(res: Response): Promise { } async function api(path: string, init: RequestInit = {}): Promise { - const res = await fetch(path, { + const url = resolveApiUrl(path); + const res = await fetch(url, { credentials: 'include', ...init, headers: { @@ -38,11 +53,16 @@ export async function fetchCsrfToken(): Promise { return json.csrfToken; } -export async function signup(email: string, password: string, csrfToken: string): Promise<{ user: AuthUser }> { +export async function signup( + email: string, + password: string, + csrfToken: string, + inviteToken?: string, +): Promise<{ user: AuthUser }> { const json = await api<{ user: AuthUser }>('/api/v1/auth/signup', { method: 'POST', headers: { 'x-csrf-token': csrfToken }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, ...(inviteToken ? { inviteToken } : {}) }), }); return json; } diff --git a/src/editor/CloudAccountPanel.tsx b/src/editor/CloudAccountPanel.tsx index d1b4e36..e369bb3 100644 --- a/src/editor/CloudAccountPanel.tsx +++ b/src/editor/CloudAccountPanel.tsx @@ -18,6 +18,7 @@ export function CloudAccountPanel({ const [user, setUser] = useState<{ id: string; email: string } | null>(null); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [inviteToken, setInviteToken] = useState(''); const [showPassword, setShowPassword] = useState(false); const [games, setGames] = useState>([]); const [selectedGameId, setSelectedGameId] = useState(''); @@ -25,6 +26,14 @@ export function CloudAccountPanel({ const [busy, setBusy] = useState(false); const githubEnabled = useMemo(() => true, []); + const githubStartHref = useMemo(() => { + const metaEnv = (import.meta as any)?.env as Record | undefined; + const apiBase = typeof metaEnv?.VITE_API_BASE_URL === 'string' ? metaEnv.VITE_API_BASE_URL.trim() : ''; + const baseUrl = typeof metaEnv?.BASE_URL === 'string' ? metaEnv.BASE_URL : '/'; + if (!apiBase) return '/api/v1/auth/github/start?returnTo=/'; + const normalized = apiBase.replace(/\/+$/, ''); + return `${normalized}/api/v1/auth/github/start?returnTo=${encodeURIComponent(baseUrl)}`; + }, []); useEffect(() => { let cancelled = false; @@ -77,11 +86,14 @@ export function CloudAccountPanel({ setBusy(true); try { const csrf = await ensureCsrf(); - const res = await signup(email, password, csrf); + const res = await signup(email, password, csrf, inviteToken.trim() || undefined); setUser(res.user); onStatus(`Signed in as ${res.user.email}`); } catch (err) { - onError(err instanceof Error ? err.message : 'Signup failed'); + const msg = err instanceof Error ? err.message : 'Signup failed'; + if (msg === 'invite_required') onError('Invite required to sign up.'); + else if (msg === 'invite_invalid') onError('Invite code is invalid or expired.'); + else onError(msg); } finally { setBusy(false); } @@ -201,6 +213,10 @@ export function CloudAccountPanel({ +
{githubEnabled && ( - +