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
3 changes: 3 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ JWT_SECRET=change-me-local-dev-only
RENDER_URL=http://localhost:5173

# VITE_API_URL= # leave unset; Vite proxies to local API
#
# Frontend (put in frontend/.env.local — Vite reads that folder when you run `pnpm dev:frontend`):
# VITE_GOOGLE_CLIENT_ID=your-web-client-id.apps.googleusercontent.com
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ Designing board games is hard. It requires **rapid iteration** and the ability t

Develop against **Docker Postgres** and **LocalStack** (S3/SQS). No real AWS calls. When you are ready, **push to `main`** and [GitHub Actions](.github/workflows/ci.yml) builds and deploys to ECS.

| Piece | Where it runs locally |
| ----- | --------------------- |
| Frontend + API + worker | Your machine |
| Database | Docker Postgres (`localhost:5433`) |
| S3 / SQS | LocalStack (`localhost:4566`) |
| Piece | Where it runs locally |
| ----------------------- | ---------------------------------- |
| Frontend + API + worker | Your machine |
| Database | Docker Postgres (`localhost:5433`) |
| S3 / SQS | LocalStack (`localhost:4566`) |

The API container can serve the built SPA in production (`NODE_ENV=production`). Until you add a stable URL (ALB, CloudFront, etc.), you may use the task public IP for smoke tests.

Expand Down Expand Up @@ -115,10 +115,10 @@ ECS tasks do **not** read `.env.production`; they get env from Terraform.

## Environment files

| File | Purpose |
| ---------------------------------------------------- | ------- |
| File | Purpose |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| [`.env.local.example`](.env.local.example) | Template → **`.env.local`** — local dev (Docker Postgres + LocalStack). Used by `pnpm dev:local`, `pnpm dev:api`, `pnpm migrate:deploy`. |
| [`.env.production.example`](.env.production.example) | Template → **`.env.production`** — **only** for `pnpm migrate:deploy:prod` against RDS. |
| [`.env.production.example`](.env.production.example) | Template → **`.env.production`** — **only** for `pnpm migrate:deploy:prod` against RDS. |

The API loads **`.env.local`** via `dotenv-cli` on `pnpm dev` scripts. The worker loads **`.env.local`** when run locally ([`worker/README.md`](worker/README.md)).

Expand Down
4 changes: 4 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"start": "node dist/index.js",
"lint": "eslint src/",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"prisma:generate": "dotenv -e ../.env.local -- prisma generate",
"prisma:migrate": "dotenv -e ../.env.local -- prisma migrate dev",
Expand Down Expand Up @@ -36,10 +37,13 @@
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.5",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^3.2.4",
"dotenv": "^16.4.7",
"dotenv-cli": "^8.0.0",
"eslint": "^9.17.0",
"prisma": "^6.2.0",
"supertest": "^7.2.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.57.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable: allow OAuth users without a password
ALTER TABLE "User" ALTER COLUMN "password_hash" DROP NOT NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Drop legacy apple_sub column if present (feature removed)
DROP INDEX IF EXISTS "User_apple_sub_key";
ALTER TABLE "User" DROP COLUMN IF EXISTS "apple_sub";
4 changes: 2 additions & 2 deletions api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ datasource db {
url = env("DATABASE_URL")
}

/// Auth: username + password (hashed); JWT issued separately.
/// Auth: unique `username` stores normalized email; password optional for Google-only accounts.
model User {
id String @id @default(uuid()) @db.Uuid
username String @unique
passwordHash String @map("password_hash")
passwordHash String? @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

Expand Down
19 changes: 19 additions & 0 deletions api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';

vi.mock('./lib/prisma.js', async () => {
const { prisma } = await import('./test/prisma-mock.js');
return { prisma };
});

import { createApp } from './app.js';

describe('createApp', () => {
const app = createApp();

it('GET /health', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok', service: 'cardgoose-api' });
});
});
62 changes: 62 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import cors from 'cors';
import express, { type Express } from 'express';
import { pinoHttp } from 'pino-http';
import { authRouter } from './routes/auth.js';
import { projectsRouter } from './routes/projects.js';
import { assetsRouter } from './routes/assets.js';
import { exportsRouter } from './routes/exports.js';
import { rootLogger } from './lib/logger.js';

/** Express app with all routes and middleware (does not listen). */
export function createApp(): Express {
const app = express();

app.use(
pinoHttp({
logger: rootLogger,
genReqId: () => randomUUID(),
customSuccessMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode}`,
customErrorMessage: (req, res, err) =>
`${req.method} ${req.url} ${res.statusCode} — ${err instanceof Error ? err.message : 'error'}`,
})
);

const corsOrigin = process.env.CORS_ORIGIN;
app.use(
cors({
origin: corsOrigin === '*' || !corsOrigin ? true : corsOrigin.split(',').map((s) => s.trim()),
credentials: true,
})
);
app.use(express.json());

app.get('/health', (_req, res) => {
res.json({ status: 'ok', service: 'cardgoose-api' });
});

app.use('/api/auth', authRouter);
app.use('/api/projects', projectsRouter);
app.use('/api', assetsRouter);
app.use('/api', exportsRouter);

const appDir = dirname(fileURLToPath(import.meta.url));
const publicDir = join(appDir, 'public');
if (existsSync(publicDir) && process.env.NODE_ENV === 'production') {
rootLogger.info({ publicDir }, 'Serving SPA from API container');
app.use(express.static(publicDir));
app.get('*', (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
if (req.path.startsWith('/api')) return next();
if (req.path === '/health') return next();
res.sendFile(join(publicDir, 'index.html'), (err) => {
if (err) next(err);
});
});
}

return app;
}
7 changes: 0 additions & 7 deletions api/src/health.test.ts

This file was deleted.

61 changes: 3 additions & 58 deletions api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,15 @@
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import cors from 'cors';
import express from 'express';
import { pinoHttp } from 'pino-http';
import { authRouter } from './routes/auth.js';
import { projectsRouter } from './routes/projects.js';
import { assetsRouter } from './routes/assets.js';
import { exportsRouter } from './routes/exports.js';
import { rootLogger } from './lib/logger.js';
import { createApp } from './app.js';
import { assertDevProfileIfSet } from './lib/devProfile.js';
import { ensureDevLocalStackBuckets } from './lib/s3.js';
import { rootLogger } from './lib/logger.js';

// Local env: `pnpm dev:api` / `pnpm dev:local` use dotenv-cli to load ../.env.local before imports.

const app = express();
const port = Number(process.env.PORT) || 3001;

app.use(
pinoHttp({
logger: rootLogger,
genReqId: () => randomUUID(),
customSuccessMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode}`,
customErrorMessage: (req, res, err) =>
`${req.method} ${req.url} ${res.statusCode} — ${err instanceof Error ? err.message : 'error'}`,
})
);

const corsOrigin = process.env.CORS_ORIGIN;
app.use(
cors({
origin: corsOrigin === '*' || !corsOrigin ? true : corsOrigin.split(',').map((s) => s.trim()),
credentials: true,
})
);
app.use(express.json());

app.get('/health', (_req, res) => {
res.json({ status: 'ok', service: 'cardgoose-api' });
});

app.use('/api/auth', authRouter);
app.use('/api/projects', projectsRouter);
app.use('/api', assetsRouter);
app.use('/api', exportsRouter);

const appDir = dirname(fileURLToPath(import.meta.url));
const publicDir = join(appDir, 'public');
if (existsSync(publicDir) && process.env.NODE_ENV === 'production') {
rootLogger.info({ publicDir }, 'Serving SPA from API container');
app.use(express.static(publicDir));
app.get('*', (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
if (req.path.startsWith('/api')) return next();
if (req.path === '/health') return next();
res.sendFile(join(publicDir, 'index.html'), (err) => {
if (err) next(err);
});
});
}

assertDevProfileIfSet();

void (async () => {
await ensureDevLocalStackBuckets();
const app = createApp();
app.listen(port, '0.0.0.0', () => {
rootLogger.info(
{
Expand Down
69 changes: 69 additions & 0 deletions api/src/lib/csv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { assertHttpsCsvUrl, parseCsvLine, parseCsvText } from './csv.js';

describe('parseCsvLine', () => {
it('splits simple commas', () => {
expect(parseCsvLine('a,b,c')).toEqual(['a', 'b', 'c']);
});

it('handles quoted fields with commas', () => {
expect(parseCsvLine('"a,b",c')).toEqual(['a,b', 'c']);
});

it('escapes doubled quotes', () => {
expect(parseCsvLine('"a""b",x')).toEqual(['a"b', 'x']);
});

it('toggles quote state', () => {
expect(parseCsvLine('"hello",world')).toEqual(['hello', 'world']);
});
});

describe('parseCsvText', () => {
it('returns empty for empty input', () => {
expect(parseCsvText('')).toEqual({ headers: [], rows: [] });
});

it('parses header and rows', () => {
const r = parseCsvText('Name,Score\nAlice,10\nBob,20');
expect(r.headers).toEqual(['Name', 'Score']);
expect(r.rows).toEqual([
{ Name: 'Alice', Score: '10' },
{ Name: 'Bob', Score: '20' },
]);
});

it('handles CRLF', () => {
const r = parseCsvText('A\r\n1');
expect(r.headers).toEqual(['A']);
expect(r.rows).toEqual([{ A: '1' }]);
});

it('skips empty header cells when building headers array', () => {
const r = parseCsvText('A,,B\n1,2,3');
expect(r.headers).toEqual(['A', 'B']);
});
});

describe('assertHttpsCsvUrl', () => {
it('accepts valid https URL', () => {
expect(assertHttpsCsvUrl('https://example.com/x.csv')).toBe('https://example.com/x.csv');
});

it('trims whitespace', () => {
expect(assertHttpsCsvUrl(' https://example.com/x ')).toBe('https://example.com/x');
});

it('rejects http', () => {
expect(() => assertHttpsCsvUrl('http://example.com')).toThrow('Only https://');
});

it('rejects invalid URL', () => {
expect(() => assertHttpsCsvUrl('not a url')).toThrow('Invalid URL');
});

it('rejects long URL', () => {
const long = `https://example.com/${'x'.repeat(2100)}`;
expect(() => assertHttpsCsvUrl(long)).toThrow('URL too long');
});
});
59 changes: 59 additions & 0 deletions api/src/lib/devProfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { assertDevProfileIfSet } from './devProfile.js';

describe('assertDevProfileIfSet', () => {
const env = { ...process.env };

afterEach(() => {
process.env = { ...env };
vi.unstubAllGlobals();
});

it('no-ops in production', () => {
process.env.NODE_ENV = 'production';
process.env.CARDGOOSE_DEV_PROFILE = 'fully-local';
expect(() => assertDevProfileIfSet()).not.toThrow();
});

it('no-ops when profile not fully-local', () => {
process.env.NODE_ENV = 'development';
process.env.CARDGOOSE_DEV_PROFILE = '';
expect(() => assertDevProfileIfSet()).not.toThrow();
});

it('throws when DB not local:5433', () => {
process.env.NODE_ENV = 'development';
process.env.CARDGOOSE_DEV_PROFILE = 'fully-local';
process.env.DATABASE_URL = 'postgresql://x@remote:5432/db';
process.env.AWS_ENDPOINT_URL = 'http://127.0.0.1:4566';
process.env.SQS_QUEUE_URL = 'http://localhost:4566/queue';
expect(() => assertDevProfileIfSet()).toThrow(/5433/);
});

it('throws when AWS endpoint not LocalStack', () => {
process.env.NODE_ENV = 'development';
process.env.CARDGOOSE_DEV_PROFILE = 'fully-local';
process.env.DATABASE_URL = 'postgresql://u@127.0.0.1:5433/db';
process.env.AWS_ENDPOINT_URL = 'https://s3.amazonaws.com';
process.env.SQS_QUEUE_URL = 'http://localhost:4566/q';
expect(() => assertDevProfileIfSet()).toThrow(/LocalStack/);
});

it('throws when SQS points at real AWS', () => {
process.env.NODE_ENV = 'development';
process.env.CARDGOOSE_DEV_PROFILE = 'fully-local';
process.env.DATABASE_URL = 'postgresql://u@127.0.0.1:5433/db';
process.env.AWS_ENDPOINT_URL = 'http://127.0.0.1:4566';
process.env.SQS_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123/x';
expect(() => assertDevProfileIfSet()).toThrow(/amazonaws/);
});

it('passes with valid fully-local env', () => {
process.env.NODE_ENV = 'development';
process.env.CARDGOOSE_DEV_PROFILE = 'fully-local';
process.env.DATABASE_URL = 'postgresql://u@localhost:5433/db';
process.env.AWS_ENDPOINT_URL = 'http://127.0.0.1:4566';
process.env.SQS_QUEUE_URL = 'http://localhost:4566/q';
expect(() => assertDevProfileIfSet()).not.toThrow();
});
});
21 changes: 21 additions & 0 deletions api/src/lib/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import jwt from 'jsonwebtoken';
import { describe, expect, it } from 'vitest';
import { signToken, verifyToken } from './jwt.js';

describe('jwt', () => {
it('round-trips payload', () => {
const t = signToken({ sub: 'id1', username: 'u@x.com' });
expect(verifyToken(t)).toEqual({ sub: 'id1', username: 'u@x.com' });
});

it('rejects tampered token', () => {
const t = signToken({ sub: 'id1', username: 'u@x.com' });
const bad = `${t.slice(0, -4)}xxxx`;
expect(() => verifyToken(bad)).toThrow();
});

it('rejects token with wrong shape', () => {
const t = jwt.sign({ sub: 'only' }, process.env.JWT_SECRET!);
expect(() => verifyToken(t)).toThrow('Invalid token payload');
});
});
Loading
Loading