Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/deploy-frontend-pages.yml
Original file line number Diff line number Diff line change
@@ -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

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ dist-ssr
.codegraph/
playwright-report/
test-results/

# Private local notes (never commit)
.plans/private/
162 changes: 162 additions & 0 deletions .plans/test-deploy-gh-pages-railway.md
Original file line number Diff line number Diff line change
@@ -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=<path>`
- `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: <exact 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=<from GitHub OAuth app>`
- `GITHUB_CLIENT_SECRET=<from GitHub OAuth app>`

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.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions prisma/migrations/20260527072000_invites/migration.sql
Original file line number Diff line number Diff line change
@@ -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;

16 changes: 16 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ model User {
oauthAccounts OAuthAccount[]
sessions Session[]
games Game[]
usedInvites Invite[] @relation("InviteUsedBy")
}

model OAuthAccount {
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions scripts/create-invite.ts
Original file line number Diff line number Diff line change
@@ -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 <email>');
}

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();
Loading
Loading