From 45085947177277e7b969ba4c9ced6c4649b8fa22 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Fri, 20 Feb 2026 18:01:52 +1100 Subject: [PATCH 01/25] linked supabase postgresql --- .env.example | 6 + apps/backend/migrations/001_init.sql | 31 + apps/backend/package.json | 12 +- apps/backend/src/env.ts | 14 +- apps/backend/src/index.ts | 6 +- package-lock.json | 865 ++++++++++++++++++--------- 6 files changed, 639 insertions(+), 295 deletions(-) create mode 100644 apps/backend/migrations/001_init.sql diff --git a/.env.example b/.env.example index 3a3ded2..1f56b4a 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,9 @@ FRONTEND_PORT=5173 # Frontend proxy target VITE_BACKEND_TARGET=http://localhost:3000 + +# Database +DATABASE_URL=URL_GOES_HERE +SESSION_SECRET=REPLACE_ME_WITH_RANDOM +SESSION_COOKIE_NAME=gs_session +SESSION_TTL_DAYS=30 \ No newline at end of file diff --git a/apps/backend/migrations/001_init.sql b/apps/backend/migrations/001_init.sql new file mode 100644 index 0000000..2b2549c --- /dev/null +++ b/apps/backend/migrations/001_init.sql @@ -0,0 +1,31 @@ +CREATE extension IF NOT EXISTS "pgcrypto"; + +CREATE TABLE IF NOT EXISTS rooms ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL +); + +CREATE TABLE IF NOT EXISTS members ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + room_id uuid NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('creator', 'member')), + display_name TEXT NULL, + joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS idx_members_room_id ON members(room_id); + +CREATE TABLE IF NOT EXISTS member_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + member_id uuid NOT NULL REFERENCES members(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_member_id ON member_sessions(member_id); \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index 09f4072..c2952e9 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -15,15 +15,21 @@ }, "dependencies": { "@gameswipe/shared": "*", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.3.1", - "express": "^4.19.2" + "express": "^4.22.1", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", + "pg": "^8.18.0", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.0.0", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "@types/node": "^22.0.0", + "@types/express": "^4.17.25", + "@types/node": "^22.19.11", "@types/supertest": "^2.0.16", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 0c82bcc..da8e10d 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -1,6 +1,18 @@ import path from "node:path"; import dotenv from "dotenv"; +import { z } from "zod"; dotenv.config({ - path: path.resolve(process.cwd(), "../../.env") + path: path.resolve(__dirname, "../../..", ".env"), + override: true }); + +const EnvSchema = z.object({ + PORT: z.coerce.number().default(3000), + DATABASE_URL: z.string().min(1), + SESSION_SECRET: z.string().min(16), + SESSION_COOKIE_NAME: z.string().default("gs_session"), + SESSION_TTL_DAYS: z.coerce.number().default(30) +}); + +export const env = EnvSchema.parse(process.env); \ No newline at end of file diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index b77f17c..1f45e67 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,6 +1,6 @@ import express from "express"; import cors from "cors"; -import "./env"; +import { env } from "./env"; const app = express(); @@ -15,7 +15,9 @@ app.get("/health", (_req, res) => { }); }); -const port = Number(process.env.PORT ?? 3000); + +console.log("Loaded PORT =", env.PORT); +const port = Number(env.PORT ?? 3000); app.listen(port, () => { console.log(`Backend listening on http://localhost:${port}`); diff --git a/package-lock.json b/package-lock.json index 522e3df..f7e1531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,15 +29,21 @@ "version": "1.0.0", "dependencies": { "@gameswipe/shared": "*", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.3.1", - "express": "^4.19.2" + "express": "^4.22.1", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", + "pg": "^8.18.0", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.0.0", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "@types/node": "^22.0.0", + "@types/express": "^4.17.25", + "@types/node": "^22.19.11", "@types/supertest": "^2.0.16", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", @@ -50,57 +56,6 @@ "vitest": "^2.0.0" } }, - "apps/backend/node_modules/accepts": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "apps/backend/node_modules/body-parser": { - "version": "2.2.2", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/content-disposition": { - "version": "1.0.1", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/cookie-signature": { - "version": "1.2.2", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "apps/backend/node_modules/cors": { "version": "2.8.6", "license": "MIT", @@ -116,237 +71,6 @@ "url": "https://opencollective.com/express" } }, - "apps/backend/node_modules/express": { - "version": "5.2.1", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/finalhandler": { - "version": "2.1.1", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/fresh": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "apps/backend/node_modules/iconv-lite": { - "version": "0.7.2", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/is-promise": { - "version": "4.0.0", - "license": "MIT" - }, - "apps/backend/node_modules/media-typer": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "apps/backend/node_modules/merge-descriptors": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "apps/backend/node_modules/mime-db": { - "version": "1.54.0", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "apps/backend/node_modules/mime-types": { - "version": "3.0.2", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/negotiator": { - "version": "1.0.0", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "apps/backend/node_modules/path-to-regexp": { - "version": "8.3.0", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/qs": { - "version": "6.15.0", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "apps/backend/node_modules/raw-body": { - "version": "3.0.2", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "apps/backend/node_modules/router": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "apps/backend/node_modules/send": { - "version": "1.2.1", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/serve-static": { - "version": "2.2.1", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/backend/node_modules/type-is": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "apps/frontend": { "name": "@gameswipe/frontend", "version": "0.1.0", @@ -2056,6 +1780,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -2086,6 +1820,7 @@ "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2668,6 +2403,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2779,6 +2527,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -2992,6 +2746,45 @@ "node": ">=6.0.0" } }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3283,6 +3076,18 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -3308,6 +3113,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", @@ -3437,6 +3261,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3539,6 +3364,16 @@ "node": ">=6" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -4268,6 +4103,85 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4327,6 +4241,39 @@ "node": ">=16.0.0" } }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4425,6 +4372,15 @@ "node": ">= 0.6" } }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4725,6 +4681,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4803,6 +4768,18 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4871,6 +4848,15 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5539,21 +5525,49 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5563,7 +5577,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -5637,6 +5650,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -5807,6 +5829,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -5942,6 +5965,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -5959,6 +5988,96 @@ "node": ">= 14.16" } }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6019,6 +6138,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6132,7 +6290,6 @@ "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -6160,6 +6317,21 @@ "node": ">= 0.6" } }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6402,6 +6574,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6475,6 +6667,60 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6655,6 +6901,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7137,6 +7392,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -7351,6 +7619,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8168,6 +8445,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -8209,6 +8487,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", From 27f92394facc6b6ab9cfa8d4e3c0dab88a6551cd Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Fri, 20 Feb 2026 18:39:52 +1100 Subject: [PATCH 02/25] Added some helper functions for rooms and auth + changes lint --- apps/backend/package.json | 1 + apps/backend/src/auth/session.ts | 10 +++++++++ apps/backend/src/db.ts | 6 ++++++ apps/backend/src/errors.ts | 14 ++++++++++++ apps/backend/src/middleware/auth.ts | 0 apps/backend/src/middleware/errorHandler.ts | 24 +++++++++++++++++++++ apps/backend/src/utils/roomCode.ts | 14 ++++++++++++ eslint.config.mjs | 15 +++++++++++++ package-lock.json | 14 ++++++++++++ package.json | 1 + 10 files changed, 99 insertions(+) create mode 100644 apps/backend/src/auth/session.ts create mode 100644 apps/backend/src/db.ts create mode 100644 apps/backend/src/errors.ts create mode 100644 apps/backend/src/middleware/auth.ts create mode 100644 apps/backend/src/middleware/errorHandler.ts create mode 100644 apps/backend/src/utils/roomCode.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index c2952e9..8178b5b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -30,6 +30,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.25", "@types/node": "^22.19.11", + "@types/pg": "^8.16.0", "@types/supertest": "^2.0.16", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", diff --git a/apps/backend/src/auth/session.ts b/apps/backend/src/auth/session.ts new file mode 100644 index 0000000..633ee9d --- /dev/null +++ b/apps/backend/src/auth/session.ts @@ -0,0 +1,10 @@ +import crypto from "crypto"; +import { env } from "../env"; + +export function newSessionToken(): string { + return crypto.randomBytes(32).toString("base64url"); +} + +export function hashToken(token: string): string { + return crypto.createHmac("sha256", env.SESSION_SECRET).update(token).digest("hex"); +} \ No newline at end of file diff --git a/apps/backend/src/db.ts b/apps/backend/src/db.ts new file mode 100644 index 0000000..8af2ef3 --- /dev/null +++ b/apps/backend/src/db.ts @@ -0,0 +1,6 @@ +import { Pool } from "pg"; +import { env } from "./env" + +export const pool = new Pool({ + connectionString: env.DATABASE_URL +}); \ No newline at end of file diff --git a/apps/backend/src/errors.ts b/apps/backend/src/errors.ts new file mode 100644 index 0000000..64ce773 --- /dev/null +++ b/apps/backend/src/errors.ts @@ -0,0 +1,14 @@ +export type ApiErrorCode = | "INVALID_REQUEST" | "UNAUTHORISED" | "FORBIDDEN" | "ROOM_NOT_FOUND" | "ROOM_GONE" | "INTERNAL_ERROR"; + +export class ApiError extends Error { + public readonly status: number; + public readonly code: ApiErrorCode; + public readonly details?: unknown; + + constructor(status: number, code: ApiErrorCode, message: string, details?: unknown) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} \ No newline at end of file diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/src/middleware/errorHandler.ts b/apps/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..fe357e7 --- /dev/null +++ b/apps/backend/src/middleware/errorHandler.ts @@ -0,0 +1,24 @@ +import { Request, Response, NextFunction } from "express"; +import { ApiError } from "../errors"; + + +export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { + if (err instanceof ApiError) { + res.status(err.status).json({ + error: { + code: err.code, + message: err.message, + details: err.details ?? null + } + }); + return; + } + + res.status(500).json({ + error: { + code: "INTERNAL_ERROR", + message: "Unexpected Server Error", + details: null + } + }); +} \ No newline at end of file diff --git a/apps/backend/src/utils/roomCode.ts b/apps/backend/src/utils/roomCode.ts new file mode 100644 index 0000000..550df8a --- /dev/null +++ b/apps/backend/src/utils/roomCode.ts @@ -0,0 +1,14 @@ +import crypto from "crypto" + +const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +export function generateRoomCode(length = 6): string { + const bytes = crypto.randomBytes(length); + let out = ""; + + for (const byte of bytes) { + out += ALPHABET[byte % ALPHABET.length]; + } + + return out; +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 4bf0c4d..d75c334 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -66,5 +66,20 @@ export default [ ...reactHooks.configs.recommended.rules, "react-refresh/only-export-components": ["warn", { allowConstantExport: true }] } + }, + + { + files: ["**/*.{ts,tsx}"], + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ] + } } + ]; diff --git a/package-lock.json b/package-lock.json index f7e1531..31463b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ ], "devDependencies": { "@eslint/js": "^9.39.2", + "@types/pg": "^8.16.0", "concurrently": "^9.2.1", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", @@ -44,6 +45,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.25", "@types/node": "^22.19.11", + "@types/pg": "^8.16.0", "@types/supertest": "^2.0.16", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", @@ -1886,6 +1888,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", diff --git a/package.json b/package.json index 4034711..6ca1151 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "homepage": "https://github.com/Coding-Kitties-Club/GameSwipe#readme", "devDependencies": { "@eslint/js": "^9.39.2", + "@types/pg": "^8.16.0", "concurrently": "^9.2.1", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", From a79c9146574131700a07e6cf53d489353ab5d6a3 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Fri, 20 Feb 2026 19:04:35 +1100 Subject: [PATCH 03/25] Added an auth checking helper --- apps/backend/src/middleware/auth.ts | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index e69de29..e75d980 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -0,0 +1,42 @@ +import { Request, Response, NextFunction } from "express"; +import { pool } from "../db"; +import { env } from "../env"; +import { ApiError } from "../errors"; +import { hashToken } from "../auth/session"; + +export type AuthedRequest = Request & { auth?: { memberId: string } }; + +type SessionRow = { member_id: string }; + +export async function requireSession(req: AuthedRequest, _res: Response, next: NextFunction) { + try { + const cookies: Record = (req as unknown as { cookies?: Record }).cookies ?? {}; + + const token = cookies[env.SESSION_COOKIE_NAME]; + if (typeof token !== "string" || token.length === 0) { + return next(new ApiError(401, "UNAUTHORISED", "Missing Session Cookie")); + } + + const tokenHash = hashToken(token); + + const result = await pool.query( + ` + SELECT member_id + FROM member_sessions + WHERE token_hash = $1 + AND revoked_at IS NULL + AND expires_at > now() + `, + [tokenHash] + ); + + if (result.rows.length === 0) { + return next(new ApiError(401, "UNAUTHORISED", "Invalid or Expired Session")); + } + + req.auth = { memberId: result.rows[0]!.member_id }; + next(); + } catch (err) { + return next(err); + } +} \ No newline at end of file From a55ceae6a7a435214049eedbe76b1bf4794256aa Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Fri, 20 Feb 2026 19:56:49 +1100 Subject: [PATCH 04/25] added creating a room --- apps/backend/src/routes/health.ts | 7 ++ apps/backend/src/routes/rooms.ts | 112 +++++++++++++++++++++++++ apps/backend/src/utils/asyncHandler.ts | 7 ++ 3 files changed, 126 insertions(+) create mode 100644 apps/backend/src/routes/health.ts create mode 100644 apps/backend/src/routes/rooms.ts create mode 100644 apps/backend/src/utils/asyncHandler.ts diff --git a/apps/backend/src/routes/health.ts b/apps/backend/src/routes/health.ts new file mode 100644 index 0000000..9ec201e --- /dev/null +++ b/apps/backend/src/routes/health.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; + +export const healthRouter = Router(); + +healthRouter.get("/health", (_req, res) => { + res.json({ ok: true }); +}); \ No newline at end of file diff --git a/apps/backend/src/routes/rooms.ts b/apps/backend/src/routes/rooms.ts new file mode 100644 index 0000000..db737be --- /dev/null +++ b/apps/backend/src/routes/rooms.ts @@ -0,0 +1,112 @@ +import { Router, Response } from "express"; +import { z } from "zod"; +import { pool } from "../db"; +import { ApiError } from "../errors"; +import { generateRoomCode } from "../utils/roomCode"; +import { env } from "../env"; +import { newSessionToken, hashToken } from "../auth/session"; +import { requireSession, AuthedRequest } from "../middleware/auth"; +import { asyncHandler } from "../utils/asyncHandler"; + +export const roomsRouter = Router(); + +const CreateRoomSchema = z.object({ + expiresInHours: z.number().int().positive().max(168).optional() +}); + +const JoinRoomsSchema = z.object({ + code: z.string().min(3).max(32), + displayName: z.string().min(1).max(48).optional() +}); + +function setSessionCookie(res: Response, token: string) { + res.cookie(env.SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: false, + maxAge: env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000 + }); +} + +async function createSession(memberId: string) { + const token = newSessionToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000); + + await pool.query( + `INSERT INTO member_sessions (member_id, token_hash, expires_at) values ($1, $2, $3)`, + [memberId, tokenHash, expiresAt.toISOString()] + ); + + return token; +} + +type RoomRow = { id: string; code: string; expires_at: string }; + +function isPgErrorWithCode(err: unknown): err is { code: string } { + if (typeof err !== "object" || err === null) return false; + + const rec = err as Record; + return typeof rec.code === "string"; +} + + +roomsRouter.post("/rooms", asyncHandler(async (req, res) => { + const body = CreateRoomSchema.parse(req.body ?? {}); + const hours = body.expiresInHours ?? 24; + const expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000); + + await pool.query("begin"); + try { + let roomRow: RoomRow | null = null; + + for (let i = 0; i < 8; i++) { + const code = generateRoomCode(6); + try { + const r = await pool.query<{ id: string; code: string; expires_at: string }>( + `INSERT INTO rooms (code, expires_at) + VALUES ($1, $2) + RETURNING id, code, expires_at`, + [code, expiresAt.toISOString()] + ); + roomRow = r.rows[0]!; + break; + } catch (e: unknown) { + if (isPgErrorWithCode(e) && e.code === "23505") continue; + throw e; + } + } + + if (!roomRow) { + throw new ApiError(500, "INTERNAL_ERROR", "Failed to allocate unique room code"); + } + + const member = await pool.query<{ id: string; role: "creator" | "member" }>( + `INSERT INTO members (room_id, role) + VALUES ($1, 'creator') + RETURNING id, role`, + [roomRow.id] + ); + + const token = await createSession(member.rows[0]!.id); + setSessionCookie(res, token); + + await pool.query("commit"); + + res.status(201).json({ + room: { + id: roomRow.id, + code: roomRow.code, + expiresAt: roomRow.expires_at, + }, + member: { + id: member.rows[0]?.id, + role: member.rows[0]?.role, + }, + }); + } catch (e) { + await pool.query("rollback"); + throw e; + } +}) +); \ No newline at end of file diff --git a/apps/backend/src/utils/asyncHandler.ts b/apps/backend/src/utils/asyncHandler.ts new file mode 100644 index 0000000..619b123 --- /dev/null +++ b/apps/backend/src/utils/asyncHandler.ts @@ -0,0 +1,7 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; + +export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise): RequestHandler { + return (req, res, next) => { + void fn(req, res, next).catch(next); + }; +} \ No newline at end of file From 23e6f728f3c93030f00c94577e1af8570a2d76fa Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Fri, 20 Feb 2026 22:59:22 +1100 Subject: [PATCH 05/25] added join route --- apps/backend/src/routes/rooms.ts | 205 ++++++++++++++++++++++++++----- 1 file changed, 171 insertions(+), 34 deletions(-) diff --git a/apps/backend/src/routes/rooms.ts b/apps/backend/src/routes/rooms.ts index db737be..24ae6b4 100644 --- a/apps/backend/src/routes/rooms.ts +++ b/apps/backend/src/routes/rooms.ts @@ -1,12 +1,13 @@ -import { Router, Response } from "express"; +import { Router, type Response } from "express"; import { z } from "zod"; import { pool } from "../db"; import { ApiError } from "../errors"; import { generateRoomCode } from "../utils/roomCode"; import { env } from "../env"; import { newSessionToken, hashToken } from "../auth/session"; -import { requireSession, AuthedRequest } from "../middleware/auth"; +import { requireSession, type AuthedRequest } from "../middleware/auth"; import { asyncHandler } from "../utils/asyncHandler"; +import type { PoolClient } from "pg"; export const roomsRouter = Router(); @@ -14,12 +15,12 @@ const CreateRoomSchema = z.object({ expiresInHours: z.number().int().positive().max(168).optional() }); -const JoinRoomsSchema = z.object({ +const JoinRoomSchema = z.object({ code: z.string().min(3).max(32), displayName: z.string().min(1).max(48).optional() }); -function setSessionCookie(res: Response, token: string) { +function setSessionCookie(res: Response, token: string): void { res.cookie(env.SESSION_COOKIE_NAME, token, { httpOnly: true, sameSite: "lax", @@ -28,50 +29,86 @@ function setSessionCookie(res: Response, token: string) { }); } -async function createSession(memberId: string) { - const token = newSessionToken(); - const tokenHash = hashToken(token); - const expiresAt = new Date(Date.now() + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000); +type RoomRow = { + id: string; + code: string; + expires_at: string; + deleted_at: string | null; +}; - await pool.query( - `INSERT INTO member_sessions (member_id, token_hash, expires_at) values ($1, $2, $3)`, - [memberId, tokenHash, expiresAt.toISOString()] - ); +type MemberRole = "creator" | "member"; - return token; -} +type MemberRow = { + id: string; + role: MemberRole; + display_name: string | null; +}; -type RoomRow = { id: string; code: string; expires_at: string }; +type SessionInsertRow = { + id: string; +}; function isPgErrorWithCode(err: unknown): err is { code: string } { if (typeof err !== "object" || err === null) return false; - const rec = err as Record; return typeof rec.code === "string"; } +async function createSession(client: PoolClient, memberId: string): Promise { + const token = newSessionToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000); + + const result = await client.query( + ` + INSERT INTO member_sessions (member_id, token_hash, expires_at) + VALUES ($1, $2, $3) + RETURNING id + `, + [memberId, tokenHash, expiresAt.toISOString()] + ); + + const row = result.rows[0]; + if (!row) { + throw new ApiError(500, "INTERNAL_ERROR", "Session insert did not return a row"); + } + + return token; +} roomsRouter.post("/rooms", asyncHandler(async (req, res) => { const body = CreateRoomSchema.parse(req.body ?? {}); const hours = body.expiresInHours ?? 24; const expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000); - await pool.query("begin"); + const client = await pool.connect(); try { - let roomRow: RoomRow | null = null; + await client.query("BEGIN"); - for (let i = 0; i < 8; i++) { + let roomRow: Pick | null = null; + + for (let attempt = 0; attempt < 8; attempt++) { const code = generateRoomCode(6); + try { - const r = await pool.query<{ id: string; code: string; expires_at: string }>( - `INSERT INTO rooms (code, expires_at) - VALUES ($1, $2) - RETURNING id, code, expires_at`, + const insertedRoom = await client.query>( + ` + INSERT INTO rooms (code, expires_at) + VALUES ($1, $2) + RETURNING id, code, expires_at + `, [code, expiresAt.toISOString()] ); - roomRow = r.rows[0]!; + + const row = insertedRoom.rows[0]; + if (!row) { + throw new ApiError(500, "INTERNAL_ERROR", "Room insert did not return a row"); + } + + roomRow = row; break; } catch (e: unknown) { + // Room code collision if (isPgErrorWithCode(e) && e.code === "23505") continue; throw e; } @@ -81,17 +118,24 @@ roomsRouter.post("/rooms", asyncHandler(async (req, res) => { throw new ApiError(500, "INTERNAL_ERROR", "Failed to allocate unique room code"); } - const member = await pool.query<{ id: string; role: "creator" | "member" }>( - `INSERT INTO members (room_id, role) - VALUES ($1, 'creator') - RETURNING id, role`, + const insertedMember = await client.query>( + ` + INSERT INTO members (room_id, role) + VALUES ($1, 'creator') + RETURNING id, role + `, [roomRow.id] ); - const token = await createSession(member.rows[0]!.id); + const memberRow = insertedMember.rows[0]; + if (!memberRow) { + throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); + } + + const token = await createSession(client, memberRow.id); setSessionCookie(res, token); - await pool.query("commit"); + await client.query("COMMIT"); res.status(201).json({ room: { @@ -100,13 +144,106 @@ roomsRouter.post("/rooms", asyncHandler(async (req, res) => { expiresAt: roomRow.expires_at, }, member: { - id: member.rows[0]?.id, - role: member.rows[0]?.role, + id: memberRow.id, + role: memberRow.role, }, }); - } catch (e) { - await pool.query("rollback"); + } catch (e: unknown) { + try { + await client.query("ROLLBACK"); + } catch { + // ignore rollback errors + } throw e; + } finally { + client.release(); } }) +); + +roomsRouter.post( + "/rooms/join", + asyncHandler(async (req, res) => { + const body = JoinRoomSchema.parse(req.body ?? {}); + const code = body.code.toUpperCase(); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const roomResult = await client.query( + ` + SELECT id, code, expires_at, deleted_at + FROM rooms + WHERE code = $1 + `, + [code] + ); + + const roomRow = roomResult.rows[0]; + if (!roomRow) { + throw new ApiError(404, "ROOM_NOT_FOUND", "Room code not found"); + } + + if (roomRow.deleted_at !== null) { + throw new ApiError(410, "ROOM_GONE", "Room has been deleted"); + } + + if (new Date(roomRow.expires_at).getTime() <= Date.now()) { + throw new ApiError(410, "ROOM_GONE", "Room has expired"); + } + + const memberInsert = await client.query( + ` + INSERT INTO members (room_id, role, display_name) + VALUES ($1, 'member', $2) + RETURNING id, role, display_name + `, + [roomRow.id, body.displayName ?? null] + ); + + const memberRow = memberInsert.rows[0]; + if (!memberRow) { + throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); + } + + const token = await createSession(client, memberRow.id); + setSessionCookie(res, token); + + await client.query("COMMIT"); + + res.json({ + room: { + id: roomRow.id, + code: roomRow.code, + expiresAt: roomRow.expires_at, + }, + member: { + id: memberRow.id, + role: memberRow.role, + displayName: memberRow.display_name, + }, + session: { + token, + }, + }); + } catch (e: unknown) { + try { + await client.query("ROLLBACK"); + } catch { + // ignore rollback errors + } + throw e; + } finally { + client.release(); + } + }) +); + +roomsRouter.get( + "/rooms/:roomId", + requireSession, + asyncHandler(async (_req: AuthedRequest, _res) => { + throw new ApiError(501, "INTERNAL_ERROR", "Not implemented yet: GET /rooms/:roomId"); + }) ); \ No newline at end of file From 81e4e0b120ea88a5dc14c60b9db3b48aa4b24538 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Fri, 20 Feb 2026 23:13:21 +1100 Subject: [PATCH 06/25] added room get and delete --- apps/backend/src/routes/rooms.ts | 310 ++++++++++++++++++++----------- 1 file changed, 202 insertions(+), 108 deletions(-) diff --git a/apps/backend/src/routes/rooms.ts b/apps/backend/src/routes/rooms.ts index 24ae6b4..a70483f 100644 --- a/apps/backend/src/routes/rooms.ts +++ b/apps/backend/src/routes/rooms.ts @@ -20,12 +20,16 @@ const JoinRoomSchema = z.object({ displayName: z.string().min(1).max(48).optional() }); +const RoomIdParamSchema = z.object({ + roomId: z.string().uuid() +}); + function setSessionCookie(res: Response, token: string): void { res.cookie(env.SESSION_COOKIE_NAME, token, { httpOnly: true, sameSite: "lax", secure: false, - maxAge: env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000 + maxAge: env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000, }); } @@ -34,6 +38,7 @@ type RoomRow = { code: string; expires_at: string; deleted_at: string | null; + created_at?: string; }; type MemberRole = "creator" | "member"; @@ -42,11 +47,11 @@ type MemberRow = { id: string; role: MemberRole; display_name: string | null; + joined_at?: string; + last_seen_at?: string | null; }; -type SessionInsertRow = { - id: string; -}; +type SessionInsertRow = { id: string }; function isPgErrorWithCode(err: unknown): err is { code: string } { if (typeof err !== "object" || err === null) return false; @@ -61,21 +66,30 @@ async function createSession(client: PoolClient, memberId: string): Promise( ` - INSERT INTO member_sessions (member_id, token_hash, expires_at) - VALUES ($1, $2, $3) - RETURNING id - `, + INSERT INTO member_sessions (member_id, token_hash, expires_at) + VALUES ($1, $2, $3) + RETURNING id + `, [memberId, tokenHash, expiresAt.toISOString()] ); const row = result.rows[0]; - if (!row) { - throw new ApiError(500, "INTERNAL_ERROR", "Session insert did not return a row"); - } + if (!row) throw new ApiError(500, "INTERNAL_ERROR", "Session insert did not return a row"); return token; } +function requireAuthedMemberId(req: AuthedRequest): string { + const memberId = req.auth?.memberId; + if (!memberId) throw new ApiError(401, "UNAUTHORISED", "Missing auth context"); + return memberId; +} + +function ensureRoomActive(room: RoomRow): void { + if (room.deleted_at !== null) throw new ApiError(410, "ROOM_GONE", "Room has been deleted"); + if (new Date(room.expires_at).getTime() <= Date.now()) throw new ApiError(410, "ROOM_GONE", "Room has expired"); +} + roomsRouter.post("/rooms", asyncHandler(async (req, res) => { const body = CreateRoomSchema.parse(req.body ?? {}); const hours = body.expiresInHours ?? 24; @@ -89,7 +103,6 @@ roomsRouter.post("/rooms", asyncHandler(async (req, res) => { for (let attempt = 0; attempt < 8; attempt++) { const code = generateRoomCode(6); - try { const insertedRoom = await client.query>( ` @@ -101,22 +114,18 @@ roomsRouter.post("/rooms", asyncHandler(async (req, res) => { ); const row = insertedRoom.rows[0]; - if (!row) { - throw new ApiError(500, "INTERNAL_ERROR", "Room insert did not return a row"); - } + if (!row) throw new ApiError(500, "INTERNAL_ERROR", "Room insert did not return a row"); roomRow = row; break; } catch (e: unknown) { - // Room code collision + // 23505 = unique_violation if (isPgErrorWithCode(e) && e.code === "23505") continue; throw e; } } - if (!roomRow) { - throw new ApiError(500, "INTERNAL_ERROR", "Failed to allocate unique room code"); - } + if (!roomRow) throw new ApiError(500, "INTERNAL_ERROR", "Failed to allocate unique room code"); const insertedMember = await client.query>( ` @@ -128,9 +137,7 @@ roomsRouter.post("/rooms", asyncHandler(async (req, res) => { ); const memberRow = insertedMember.rows[0]; - if (!memberRow) { - throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); - } + if (!memberRow) throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); const token = await createSession(client, memberRow.id); setSessionCookie(res, token); @@ -138,15 +145,8 @@ roomsRouter.post("/rooms", asyncHandler(async (req, res) => { await client.query("COMMIT"); res.status(201).json({ - room: { - id: roomRow.id, - code: roomRow.code, - expiresAt: roomRow.expires_at, - }, - member: { - id: memberRow.id, - role: memberRow.role, - }, + room: { id: roomRow.id, code: roomRow.code, expiresAt: roomRow.expires_at }, + member: { id: memberRow.id, role: memberRow.role }, }); } catch (e: unknown) { try { @@ -161,89 +161,183 @@ roomsRouter.post("/rooms", asyncHandler(async (req, res) => { }) ); -roomsRouter.post( - "/rooms/join", - asyncHandler(async (req, res) => { - const body = JoinRoomSchema.parse(req.body ?? {}); - const code = body.code.toUpperCase(); +roomsRouter.post("/rooms/join", asyncHandler(async (req, res) => { + const body = JoinRoomSchema.parse(req.body ?? {}); + const code = body.code.toUpperCase(); - const client = await pool.connect(); - try { - await client.query("BEGIN"); - - const roomResult = await client.query( - ` - SELECT id, code, expires_at, deleted_at - FROM rooms - WHERE code = $1 - `, - [code] - ); - - const roomRow = roomResult.rows[0]; - if (!roomRow) { - throw new ApiError(404, "ROOM_NOT_FOUND", "Room code not found"); - } + const client = await pool.connect(); + try { + await client.query("BEGIN"); - if (roomRow.deleted_at !== null) { - throw new ApiError(410, "ROOM_GONE", "Room has been deleted"); - } + const roomResult = await client.query( + ` + SELECT id, code, expires_at, deleted_at + FROM rooms + WHERE code = $1 + `, + [code] + ); - if (new Date(roomRow.expires_at).getTime() <= Date.now()) { - throw new ApiError(410, "ROOM_GONE", "Room has expired"); - } + const roomRow = roomResult.rows[0]; + if (!roomRow) throw new ApiError(404, "ROOM_NOT_FOUND", "Room code not found"); - const memberInsert = await client.query( - ` - INSERT INTO members (room_id, role, display_name) - VALUES ($1, 'member', $2) - RETURNING id, role, display_name - `, - [roomRow.id, body.displayName ?? null] - ); - - const memberRow = memberInsert.rows[0]; - if (!memberRow) { - throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); - } + ensureRoomActive(roomRow); - const token = await createSession(client, memberRow.id); - setSessionCookie(res, token); - - await client.query("COMMIT"); - - res.json({ - room: { - id: roomRow.id, - code: roomRow.code, - expiresAt: roomRow.expires_at, - }, - member: { - id: memberRow.id, - role: memberRow.role, - displayName: memberRow.display_name, - }, - session: { - token, - }, - }); - } catch (e: unknown) { - try { - await client.query("ROLLBACK"); - } catch { - // ignore rollback errors - } - throw e; - } finally { - client.release(); + const memberInsert = await client.query( + ` + INSERT INTO members (room_id, role, display_name) + VALUES ($1, 'member', $2) + RETURNING id, role, display_name + `, + [roomRow.id, body.displayName ?? null] + ); + + const memberRow = memberInsert.rows[0]; + if (!memberRow) throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); + + const token = await createSession(client, memberRow.id); + setSessionCookie(res, token); + + await client.query("COMMIT"); + + res.json({ + room: { id: roomRow.id, code: roomRow.code, expiresAt: roomRow.expires_at }, + member: { id: memberRow.id, role: memberRow.role, displayName: memberRow.display_name }, + session: { token }, + }); + } catch (e: unknown) { + try { + await client.query("ROLLBACK"); + } catch { + // ignore rollback errors } - }) + throw e; + } finally { + client.release(); + } +}) ); -roomsRouter.get( - "/rooms/:roomId", - requireSession, - asyncHandler(async (_req: AuthedRequest, _res) => { - throw new ApiError(501, "INTERNAL_ERROR", "Not implemented yet: GET /rooms/:roomId"); - }) +roomsRouter.get("/rooms/:roomId", requireSession, asyncHandler(async (req: AuthedRequest, res) => { + const { roomId } = RoomIdParamSchema.parse(req.params); + const memberId = requireAuthedMemberId(req); + + const meResult = await pool.query>( + ` + SELECT id, role + FROM members + WHERE id = $1 AND room_id = $2 + `, + [memberId, roomId] + ); + + const meRow = meResult.rows[0]; + if (!meRow) throw new ApiError(403, "FORBIDDEN", "Not a member of this room"); + + const roomResult = await pool.query>( + ` + SELECT id, code, created_at, expires_at, deleted_at + FROM rooms + WHERE id = $1 + `, + [roomId] + ); + + const roomRow = roomResult.rows[0]; + if (!roomRow) throw new ApiError(404, "ROOM_NOT_FOUND", "Room not found"); + + ensureRoomActive(roomRow); + + await pool.query(`UPDATE members SET last_seen_at = now() WHERE id = $1`, [memberId]); + + const membersResult = await pool.query>( + ` + SELECT id, role, display_name, joined_at, last_seen_at + FROM members + WHERE room_id = $1 + ORDER BY joined_at ASC + `, + [roomId] + ); + + res.json({ + room: { + id: roomRow.id, + code: roomRow.code, + createdAt: roomRow.created_at ?? null, + expiresAt: roomRow.expires_at, + }, + me: { memberId: meRow.id, role: meRow.role }, + members: membersResult.rows.map((m) => ({ + id: m.id, + role: m.role, + displayName: m.display_name, + joinedAt: m.joined_at ?? null, + lastSeenAt: m.last_seen_at ?? null, + })), + }); +}) +); + +roomsRouter.delete("/rooms/:roomId", requireSession, asyncHandler(async (req: AuthedRequest, res) => { + const { roomId } = RoomIdParamSchema.parse(req.params); + const memberId = requireAuthedMemberId(req); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const meResult = await client.query>( + ` + SELECT role + FROM members + WHERE id = $1 AND room_id = $2 + `, + [memberId, roomId] + ); + + const meRow = meResult.rows[0]; + if (!meRow) throw new ApiError(403, "FORBIDDEN", "Not a member of this room"); + if (meRow.role !== "creator") throw new ApiError(403, "FORBIDDEN", "Only the creator can delete the room"); + + const roomResult = await client.query>( + ` + SELECT deleted_at, expires_at + FROM rooms + WHERE id = $1 + `, + [roomId] + ); + + const roomRow = roomResult.rows[0]; + if (!roomRow) throw new ApiError(404, "ROOM_NOT_FOUND", "Room not found"); + if (roomRow.deleted_at !== null) throw new ApiError(410, "ROOM_GONE", "Room has already been deleted"); + + await client.query(`UPDATE rooms SET deleted_at = now() WHERE id = $1`, [roomId]); + + await client.query( + ` + UPDATE member_sessions ms + SET revoked_at = now() + FROM members m + WHERE m.id = ms.member_id + AND m.room_id = $1 + AND ms.revoked_at IS NULL + `, + [roomId] + ); + + await client.query("COMMIT"); + res.status(204).send(); + } catch (e: unknown) { + try { + await client.query("ROLLBACK"); + } catch { + // ignore rollback errors + } + throw e; + } finally { + client.release(); + } +}) ); \ No newline at end of file From 37f9cf7ab0ae216a9f6a9377310d48097d6ff13f Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Fri, 20 Feb 2026 23:21:20 +1100 Subject: [PATCH 07/25] fixed errors for requireSession --- apps/backend/src/middleware/auth.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index e75d980..9b74a17 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -1,4 +1,4 @@ -import { Request, Response, NextFunction } from "express"; +import type { Request, RequestHandler } from "express"; import { pool } from "../db"; import { env } from "../env"; import { ApiError } from "../errors"; @@ -8,13 +8,13 @@ export type AuthedRequest = Request & { auth?: { memberId: string } }; type SessionRow = { member_id: string }; -export async function requireSession(req: AuthedRequest, _res: Response, next: NextFunction) { - try { +export const requireSession: RequestHandler = (req, _res, next) => { + void (async () => { const cookies: Record = (req as unknown as { cookies?: Record }).cookies ?? {}; - const token = cookies[env.SESSION_COOKIE_NAME]; + if (typeof token !== "string" || token.length === 0) { - return next(new ApiError(401, "UNAUTHORISED", "Missing Session Cookie")); + throw new ApiError(401, "UNAUTHORISED", "Missing session cookie"); } const tokenHash = hashToken(token); @@ -30,13 +30,11 @@ export async function requireSession(req: AuthedRequest, _res: Response, next: N [tokenHash] ); - if (result.rows.length === 0) { - return next(new ApiError(401, "UNAUTHORISED", "Invalid or Expired Session")); + const row = result.rows[0]; + if (!row) { + throw new ApiError(401, "UNAUTHORISED", "Invalid or expired session"); } - req.auth = { memberId: result.rows[0]!.member_id }; - next(); - } catch (err) { - return next(err); - } -} \ No newline at end of file + (req as AuthedRequest).auth = { memberId: row.member_id }; + })().then(() => next()).catch(next); +}; \ No newline at end of file From ac9d1f1306ebe58bcb2a815edc085925e17a80d5 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Fri, 20 Feb 2026 23:42:56 +1100 Subject: [PATCH 08/25] added routers the the server --- apps/backend/src/app.ts | 31 +++++++++++++++++++++++++++++++ apps/backend/src/index.ts | 26 +++++--------------------- 2 files changed, 36 insertions(+), 21 deletions(-) create mode 100644 apps/backend/src/app.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts new file mode 100644 index 0000000..b0135b6 --- /dev/null +++ b/apps/backend/src/app.ts @@ -0,0 +1,31 @@ +import express from "express"; +import cookieParser from "cookie-parser"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import { healthRouter } from "./routes/health"; +import { roomsRouter } from "./routes/rooms"; +import { errorHandler } from "./middleware/errorHandler"; + +export function createApp() { + const app = express(); + + app.use(helmet()); + app.use(express.json()); + app.use(cookieParser()); + + app.use( + "/rooms/join", + rateLimit({ + windowMs: 60_000, + limit: 30, + standardHeaders: true, + legacyHeaders: false + }) + ); + + app.use(healthRouter); + app.use(roomsRouter); + + app.use(errorHandler); + return app; +} \ No newline at end of file diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 1f45e67..716dbdd 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,24 +1,8 @@ -import express from "express"; -import cors from "cors"; +import { createApp } from "./app"; import { env } from "./env"; -const app = express(); +const app = createApp(); -app.use(cors()); -app.use(express.json()); - -app.get("/health", (_req, res) => { - res.status(200).json({ - ok: true, - service: "gameswipe-backend", - time: new Date().toISOString() - }); -}); - - -console.log("Loaded PORT =", env.PORT); -const port = Number(env.PORT ?? 3000); - -app.listen(port, () => { - console.log(`Backend listening on http://localhost:${port}`); -}); +app.listen(env.PORT, () => { + console.log(`Backend listening on http://localhost:${env.PORT}`) +}); \ No newline at end of file From 600c3b765914c8ac765e064f0c32241d08180813 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 00:20:04 +1100 Subject: [PATCH 09/25] Fixed testing with curl --- apps/backend/src/db.ts | 5 ++++- apps/backend/src/middleware/errorHandler.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/db.ts b/apps/backend/src/db.ts index 8af2ef3..5fa0630 100644 --- a/apps/backend/src/db.ts +++ b/apps/backend/src/db.ts @@ -2,5 +2,8 @@ import { Pool } from "pg"; import { env } from "./env" export const pool = new Pool({ - connectionString: env.DATABASE_URL + connectionString: env.DATABASE_URL, + ssl: { + rejectUnauthorized: false + } }); \ No newline at end of file diff --git a/apps/backend/src/middleware/errorHandler.ts b/apps/backend/src/middleware/errorHandler.ts index fe357e7..f86d56f 100644 --- a/apps/backend/src/middleware/errorHandler.ts +++ b/apps/backend/src/middleware/errorHandler.ts @@ -3,6 +3,7 @@ import { ApiError } from "../errors"; export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { + if (err instanceof ApiError) { res.status(err.status).json({ error: { From e0b616a34e8edcb6fdcb9b21b2d1dd6b0bfc915b Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 00:41:55 +1100 Subject: [PATCH 10/25] added tests for rooms --- .env.example | 5 +- apps/backend/package.json | 4 +- apps/backend/test/db.ts | 16 ++ apps/backend/test/rooms.test.ts | 283 ++++++++++++++++++++++++++++++++ apps/backend/test/setup.ts | 19 +++ apps/backend/tsconfig.json | 10 +- apps/backend/tsconfig.test.json | 4 + apps/backend/vitest.config.ts | 10 +- eslint.config.mjs | 1 + package-lock.json | 4 +- 10 files changed, 345 insertions(+), 11 deletions(-) create mode 100644 apps/backend/test/db.ts create mode 100644 apps/backend/test/rooms.test.ts create mode 100644 apps/backend/test/setup.ts create mode 100644 apps/backend/tsconfig.test.json diff --git a/.env.example b/.env.example index 1f56b4a..bd141ac 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,7 @@ VITE_BACKEND_TARGET=http://localhost:3000 DATABASE_URL=URL_GOES_HERE SESSION_SECRET=REPLACE_ME_WITH_RANDOM SESSION_COOKIE_NAME=gs_session -SESSION_TTL_DAYS=30 \ No newline at end of file +SESSION_TTL_DAYS=30 + +# Testing +DATABASE_URL_TEST=URL_GOES_HERE_FOR_TESTING \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index 8178b5b..47c9e45 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -36,10 +36,10 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", "prettier": "^3.2.5", - "supertest": "^7.0.0", + "supertest": "^7.2.2", "tsx": "^4.0.0", "typescript": "^5.4.0", "typescript-eslint": "^8.0.0", - "vitest": "^2.0.0" + "vitest": "^2.1.9" } } diff --git a/apps/backend/test/db.ts b/apps/backend/test/db.ts new file mode 100644 index 0000000..75abd9d --- /dev/null +++ b/apps/backend/test/db.ts @@ -0,0 +1,16 @@ +import { pool } from "../src/db"; + +export async function resetDb(): Promise { + await pool.query(` + TRUNCATE TABLE + member_sessions, + members, + rooms + RESTART IDENTITY + CASCADE + `); +} + +export async function closeDb(): Promise { + await pool.end(); +} \ No newline at end of file diff --git a/apps/backend/test/rooms.test.ts b/apps/backend/test/rooms.test.ts new file mode 100644 index 0000000..b236cbe --- /dev/null +++ b/apps/backend/test/rooms.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import request from "supertest"; +import { createApp } from "../src/app"; +import { resetDb, closeDb } from "./db"; +import { pool } from "../src/db"; + +type CreateRoomResponse = { + room: { id: string; code: string; expiresAt: string }; + member: { id: string; role: "creator" | "member" }; +}; + +type JoinRoomResponse = { + room: { id: string; code: string; expiresAt: string }; + member: { id: string; role: "creator" | "member"; displayName: string | null }; + session?: { token: string }; +}; + +type GetRoomResponse = { + room: { id: string; code: string; createdAt: string | null; expiresAt: string }; + me: { memberId: string; role: "creator" | "member" }; + members: Array<{ + id: string; + role: "creator" | "member"; + displayName: string | null; + joinedAt: string | null; + lastSeenAt: string | null; + }>; +}; + +function assertCreateRoomBody(body: unknown): asserts body is CreateRoomResponse { + if (typeof body !== "object" || body === null) throw new Error("Expected object body"); + const rec = body as Record; + if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); + if (typeof rec.member !== "object" || rec.member === null) throw new Error("Missing member"); +} + +function assertJoinRoomBody(body: unknown): asserts body is JoinRoomResponse { + if (typeof body !== "object" || body === null) throw new Error("Expected object body"); + const rec = body as Record; + if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); + if (typeof rec.member !== "object" || rec.member === null) throw new Error("Missing member"); +} + +function assertGetRoomBody(body: unknown): asserts body is GetRoomResponse { + if (typeof body !== "object" || body === null) throw new Error("Expected object body"); + const rec = body as Record; + if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); + if (typeof rec.me !== "object" || rec.me === null) throw new Error("Missing me"); + if (!Array.isArray(rec.members)) throw new Error("Missing members array"); +} + +function assertErrorBody(body: unknown): asserts body is { error?: { code: string } } { + if (typeof body !== "object" || body === null) throw new Error("Expected object body"); +} + +describe("Rooms API", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await closeDb(); + }); + + it("POST /rooms creates a room + creator session cookie", async () => { + const agent = request.agent(app); + + const res = await agent + .post("/rooms") + .send({ expiresInHours: 24 }) + .set("Content-Type", "application/json"); + + expect(res.status).toBe(201); + assertCreateRoomBody(res.body); + + expect(typeof res.body.room.id).toBe("string"); + expect(typeof res.body.room.code).toBe("string"); + expect(res.body.member.role).toBe("creator"); + + + const setCookieHeader = res.headers["set-cookie"]; + const cookies = Array.isArray(setCookieHeader) + ? setCookieHeader + : typeof setCookieHeader === "string" + ? [setCookieHeader] + : []; + + expect(cookies.join(";")).toContain("gs_session="); + }); + + it("POST /rooms/join returns 404 for unknown code", async () => { + const res = await request(app) + .post("/rooms/join") + .send({ code: "ABC234", displayName: "Ryan" }) + .set("Content-Type", "application/json"); + + expect(res.status).toBe(404); + assertErrorBody(res.body); + expect(res.body?.error?.code).toBe("ROOM_NOT_FOUND"); + }); + + it("POST /rooms/join joins an existing room and sets cookie", async () => { + const creator = request.agent(app); + const createRes = await creator + .post("/rooms") + .send({ expiresInHours: 24 }) + .set("Content-Type", "application/json"); + + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const code = createRes.body.room.code; + + const joiner = request.agent(app); + const joinRes = await joiner + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + + expect(joinRes.status).toBe(200); + assertJoinRoomBody(joinRes.body); + + expect(joinRes.body.room.code).toBe(code); + expect(joinRes.body.member.role).toBe("member"); + expect(joinRes.body.member.displayName).toBe("Ryan"); + + const setCookieHeader = joinRes.headers["set-cookie"]; + const cookies = Array.isArray(setCookieHeader) + ? setCookieHeader + : typeof setCookieHeader === "string" + ? [setCookieHeader] + : []; + + expect(cookies.join(";")).toContain("gs_session="); + }); + + it("GET /rooms/:roomId requires session", async () => { + const res = await request(app).get("/rooms/00000000-0000-0000-0000-000000000000"); + expect(res.status).toBe(401); + assertErrorBody(res.body); + expect(res.body?.error?.code).toBe("UNAUTHORISED"); + }); + + it("GET /rooms/:roomId returns room + member list for an authenticated member", async () => { + const creator = request.agent(app); + + const createRes = await creator + .post("/rooms") + .send({ expiresInHours: 24 }) + .set("Content-Type", "application/json"); + + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const roomId = createRes.body.room.id; + const code = createRes.body.room.code; + + // Join a second member + const joiner = request.agent(app); + const joinRes = await joiner + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + expect(joinRes.status).toBe(200); + + // Creator fetches room + const res = await creator.get(`/rooms/${roomId}`); + expect(res.status).toBe(200); + assertGetRoomBody(res.body); + + expect(res.body.room.id).toBe(roomId); + expect(res.body.room.code).toBe(code); + expect(res.body.members.length).toBe(2); + + const roles = res.body.members.map((m) => m.role).sort(); + expect(roles).toEqual(["creator", "member"]); + }); + + it("GET /rooms/:roomId returns 410 when room expired", async () => { + const creator = request.agent(app); + + const createRes = await creator + .post("/rooms") + .send({ expiresInHours: 24 }) + .set("Content-Type", "application/json"); + + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + const roomId = createRes.body.room.id; + + // Force expire in DB + await pool.query(`UPDATE rooms SET expires_at = now() - interval '1 minute' WHERE id = $1`, [roomId]); + + const res = await creator.get(`/rooms/${roomId}`); + expect(res.status).toBe(410); + assertErrorBody(res.body); + expect(res.body?.error?.code).toBe("ROOM_GONE"); + }); + + it("DELETE /rooms/:roomId requires creator role", async () => { + const creator = request.agent(app); + const createRes = await creator + .post("/rooms") + .send({ expiresInHours: 24 }) + .set("Content-Type", "application/json"); + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const roomId = createRes.body.room.id; + const code = createRes.body.room.code; + + const joiner = request.agent(app); + const joinRes = await joiner + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + expect(joinRes.status).toBe(200); + + const delRes = await joiner.delete(`/rooms/${roomId}`); + expect(delRes.status).toBe(403); + assertErrorBody(delRes.body); + expect(delRes.body?.error?.code).toBe("FORBIDDEN"); + }); + + it("DELETE /rooms/:roomId deletes room and revokes sessions", async () => { + const creator = request.agent(app); + const createRes = await creator + .post("/rooms") + .send({ expiresInHours: 24 }) + .set("Content-Type", "application/json"); + + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const roomId = createRes.body.room.id; + const code = createRes.body.room.code; + + const joiner = request.agent(app); + const joinRes = await joiner + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + expect(joinRes.status).toBe(200); + + const delRes = await creator.delete(`/rooms/${roomId}`); + expect(delRes.status).toBe(204); + + // After deletion, room fetch should be 410 for creator + const resCreator = await creator.get(`/rooms/${roomId}`); + expect(resCreator.status).toBe(410); + + // Joiner session should now be revoked; should get 401 on protected endpoint + const resJoiner = await joiner.get(`/rooms/${roomId}`); + expect(resJoiner.status).toBe(401); + }); + + it("POST /rooms/join returns 410 if room deleted", async () => { + const creator = request.agent(app); + const createRes = await creator + .post("/rooms") + .send({ expiresInHours: 24 }) + .set("Content-Type", "application/json"); + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const roomId = createRes.body.room.id; + const code = createRes.body.room.code; + + const delRes = await creator.delete(`/rooms/${roomId}`); + expect(delRes.status).toBe(204); + + const joinRes = await request(app) + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + + expect(joinRes.status).toBe(410); + assertErrorBody(joinRes.body); + expect(joinRes.body?.error?.code).toBe("ROOM_GONE"); + }); +}); \ No newline at end of file diff --git a/apps/backend/test/setup.ts b/apps/backend/test/setup.ts new file mode 100644 index 0000000..d9403b6 --- /dev/null +++ b/apps/backend/test/setup.ts @@ -0,0 +1,19 @@ +import path from "node:path"; +import dotenv from "dotenv"; +import { beforeAll } from "vitest"; + +dotenv.config({ + path: path.resolve(__dirname, "../../..", ".env"), + override: true, +}); + +beforeAll(() => { + const testUrl = process.env.DATABASE_URL_TEST; + if (typeof testUrl !== "string" || testUrl.length === 0) { + throw new Error( + "DATABASE_URL_TEST is missing. Add DATABASE_URL_TEST to the repo root .env" + ); + } + + process.env.DATABASE_URL = testUrl; +}); \ No newline at end of file diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 1a0d024..f52cb0a 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -4,8 +4,12 @@ "module": "CommonJS", "outDir": "dist", "rootDir": "src", - "types": ["node"], + "types": [ + "node" + ], "esModuleInterop": true }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/apps/backend/tsconfig.test.json b/apps/backend/tsconfig.test.json new file mode 100644 index 0000000..ae64a0e --- /dev/null +++ b/apps/backend/tsconfig.test.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 99f4bce..1de1783 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - environment: "node" - } -}); + environment: "node", + setupFiles: ["./test/setup.ts"], + globals: false, + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index d75c334..fb31451 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ const repoRoot = __dirname; const tsProjects = [ path.join(repoRoot, "apps/backend/tsconfig.json"), + path.join(repoRoot, "apps/backend/tsconfig.test.json"), path.join(repoRoot, "apps/frontend/tsconfig.app.json"), path.join(repoRoot, "packages/shared/tsconfig.json") ]; diff --git a/package-lock.json b/package-lock.json index 31463b4..8673052 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,11 +51,11 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", "prettier": "^3.2.5", - "supertest": "^7.0.0", + "supertest": "^7.2.2", "tsx": "^4.0.0", "typescript": "^5.4.0", "typescript-eslint": "^8.0.0", - "vitest": "^2.0.0" + "vitest": "^2.1.9" } }, "apps/backend/node_modules/cors": { From a25ae05039d7799ed9f45ca61566bd09d2c2246c Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 00:51:17 +1100 Subject: [PATCH 11/25] fixed 410 error being a 401 and updated tests --- apps/backend/src/routes/rooms.ts | 3 ++- apps/backend/{src => test}/health.test.ts | 0 apps/backend/test/rooms.test.ts | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) rename apps/backend/{src => test}/health.test.ts (100%) diff --git a/apps/backend/src/routes/rooms.ts b/apps/backend/src/routes/rooms.ts index a70483f..b32567e 100644 --- a/apps/backend/src/routes/rooms.ts +++ b/apps/backend/src/routes/rooms.ts @@ -323,8 +323,9 @@ roomsRouter.delete("/rooms/:roomId", requireSession, asyncHandler(async (req: Au WHERE m.id = ms.member_id AND m.room_id = $1 AND ms.revoked_at IS NULL + AND ms.member_id <> $2 `, - [roomId] + [roomId, memberId] ); await client.query("COMMIT"); diff --git a/apps/backend/src/health.test.ts b/apps/backend/test/health.test.ts similarity index 100% rename from apps/backend/src/health.test.ts rename to apps/backend/test/health.test.ts diff --git a/apps/backend/test/rooms.test.ts b/apps/backend/test/rooms.test.ts index b236cbe..e66dabf 100644 --- a/apps/backend/test/rooms.test.ts +++ b/apps/backend/test/rooms.test.ts @@ -250,10 +250,14 @@ describe("Rooms API", () => { // After deletion, room fetch should be 410 for creator const resCreator = await creator.get(`/rooms/${roomId}`); expect(resCreator.status).toBe(410); + assertErrorBody(resCreator.body); + expect(resCreator.body?.error?.code).toBe("ROOM_GONE"); // Joiner session should now be revoked; should get 401 on protected endpoint const resJoiner = await joiner.get(`/rooms/${roomId}`); expect(resJoiner.status).toBe(401); + assertErrorBody(resJoiner.body); + expect(resJoiner.body?.error?.code).toBe("UNAUTHORISED"); }); it("POST /rooms/join returns 410 if room deleted", async () => { From b5b0c58fe102f6a25fe4f77994c78e8a1712e4b4 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 00:52:23 +1100 Subject: [PATCH 12/25] formatted code --- apps/backend/src/app.ts | 36 +- apps/backend/src/auth/session.ts | 6 +- apps/backend/src/db.ts | 12 +- apps/backend/src/env.ts | 2 +- apps/backend/src/errors.ts | 28 +- apps/backend/src/index.ts | 4 +- apps/backend/src/middleware/auth.ts | 43 +- apps/backend/src/middleware/errorHandler.ts | 36 +- apps/backend/src/routes/health.ts | 4 +- apps/backend/src/routes/rooms.ts | 346 +++++++------- apps/backend/src/utils/asyncHandler.ts | 12 +- apps/backend/src/utils/roomCode.ts | 18 +- apps/backend/test/db.ts | 6 +- apps/backend/test/rooms.test.ts | 496 ++++++++++---------- apps/backend/test/setup.ts | 18 +- apps/backend/tsconfig.json | 10 +- apps/backend/vitest.config.ts | 6 +- eslint.config.mjs | 7 +- 18 files changed, 541 insertions(+), 549 deletions(-) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index b0135b6..4a184d9 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,25 +7,25 @@ import { roomsRouter } from "./routes/rooms"; import { errorHandler } from "./middleware/errorHandler"; export function createApp() { - const app = express(); + const app = express(); - app.use(helmet()); - app.use(express.json()); - app.use(cookieParser()); + app.use(helmet()); + app.use(express.json()); + app.use(cookieParser()); - app.use( - "/rooms/join", - rateLimit({ - windowMs: 60_000, - limit: 30, - standardHeaders: true, - legacyHeaders: false - }) - ); + app.use( + "/rooms/join", + rateLimit({ + windowMs: 60_000, + limit: 30, + standardHeaders: true, + legacyHeaders: false + }) + ); - app.use(healthRouter); - app.use(roomsRouter); + app.use(healthRouter); + app.use(roomsRouter); - app.use(errorHandler); - return app; -} \ No newline at end of file + app.use(errorHandler); + return app; +} diff --git a/apps/backend/src/auth/session.ts b/apps/backend/src/auth/session.ts index 633ee9d..17157fc 100644 --- a/apps/backend/src/auth/session.ts +++ b/apps/backend/src/auth/session.ts @@ -2,9 +2,9 @@ import crypto from "crypto"; import { env } from "../env"; export function newSessionToken(): string { - return crypto.randomBytes(32).toString("base64url"); + return crypto.randomBytes(32).toString("base64url"); } export function hashToken(token: string): string { - return crypto.createHmac("sha256", env.SESSION_SECRET).update(token).digest("hex"); -} \ No newline at end of file + return crypto.createHmac("sha256", env.SESSION_SECRET).update(token).digest("hex"); +} diff --git a/apps/backend/src/db.ts b/apps/backend/src/db.ts index 5fa0630..355bc80 100644 --- a/apps/backend/src/db.ts +++ b/apps/backend/src/db.ts @@ -1,9 +1,9 @@ import { Pool } from "pg"; -import { env } from "./env" +import { env } from "./env"; export const pool = new Pool({ - connectionString: env.DATABASE_URL, - ssl: { - rejectUnauthorized: false - } -}); \ No newline at end of file + connectionString: env.DATABASE_URL, + ssl: { + rejectUnauthorized: false + } +}); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index da8e10d..7db7d97 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -15,4 +15,4 @@ const EnvSchema = z.object({ SESSION_TTL_DAYS: z.coerce.number().default(30) }); -export const env = EnvSchema.parse(process.env); \ No newline at end of file +export const env = EnvSchema.parse(process.env); diff --git a/apps/backend/src/errors.ts b/apps/backend/src/errors.ts index 64ce773..75d05d5 100644 --- a/apps/backend/src/errors.ts +++ b/apps/backend/src/errors.ts @@ -1,14 +1,20 @@ -export type ApiErrorCode = | "INVALID_REQUEST" | "UNAUTHORISED" | "FORBIDDEN" | "ROOM_NOT_FOUND" | "ROOM_GONE" | "INTERNAL_ERROR"; +export type ApiErrorCode = + | "INVALID_REQUEST" + | "UNAUTHORISED" + | "FORBIDDEN" + | "ROOM_NOT_FOUND" + | "ROOM_GONE" + | "INTERNAL_ERROR"; export class ApiError extends Error { - public readonly status: number; - public readonly code: ApiErrorCode; - public readonly details?: unknown; + public readonly status: number; + public readonly code: ApiErrorCode; + public readonly details?: unknown; - constructor(status: number, code: ApiErrorCode, message: string, details?: unknown) { - super(message); - this.status = status; - this.code = code; - this.details = details; - } -} \ No newline at end of file + constructor(status: number, code: ApiErrorCode, message: string, details?: unknown) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 716dbdd..3934eb9 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -4,5 +4,5 @@ import { env } from "./env"; const app = createApp(); app.listen(env.PORT, () => { - console.log(`Backend listening on http://localhost:${env.PORT}`) -}); \ No newline at end of file + console.log(`Backend listening on http://localhost:${env.PORT}`); +}); diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index 9b74a17..1656a96 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -9,32 +9,35 @@ export type AuthedRequest = Request & { auth?: { memberId: string } }; type SessionRow = { member_id: string }; export const requireSession: RequestHandler = (req, _res, next) => { - void (async () => { - const cookies: Record = (req as unknown as { cookies?: Record }).cookies ?? {}; - const token = cookies[env.SESSION_COOKIE_NAME]; + void (async () => { + const cookies: Record = + (req as unknown as { cookies?: Record }).cookies ?? {}; + const token = cookies[env.SESSION_COOKIE_NAME]; - if (typeof token !== "string" || token.length === 0) { - throw new ApiError(401, "UNAUTHORISED", "Missing session cookie"); - } + if (typeof token !== "string" || token.length === 0) { + throw new ApiError(401, "UNAUTHORISED", "Missing session cookie"); + } - const tokenHash = hashToken(token); + const tokenHash = hashToken(token); - const result = await pool.query( - ` + const result = await pool.query( + ` SELECT member_id FROM member_sessions WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > now() `, - [tokenHash] - ); - - const row = result.rows[0]; - if (!row) { - throw new ApiError(401, "UNAUTHORISED", "Invalid or expired session"); - } - - (req as AuthedRequest).auth = { memberId: row.member_id }; - })().then(() => next()).catch(next); -}; \ No newline at end of file + [tokenHash] + ); + + const row = result.rows[0]; + if (!row) { + throw new ApiError(401, "UNAUTHORISED", "Invalid or expired session"); + } + + (req as AuthedRequest).auth = { memberId: row.member_id }; + })() + .then(() => next()) + .catch(next); +}; diff --git a/apps/backend/src/middleware/errorHandler.ts b/apps/backend/src/middleware/errorHandler.ts index f86d56f..8273734 100644 --- a/apps/backend/src/middleware/errorHandler.ts +++ b/apps/backend/src/middleware/errorHandler.ts @@ -1,25 +1,23 @@ import { Request, Response, NextFunction } from "express"; import { ApiError } from "../errors"; - export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { + if (err instanceof ApiError) { + res.status(err.status).json({ + error: { + code: err.code, + message: err.message, + details: err.details ?? null + } + }); + return; + } - if (err instanceof ApiError) { - res.status(err.status).json({ - error: { - code: err.code, - message: err.message, - details: err.details ?? null - } - }); - return; + res.status(500).json({ + error: { + code: "INTERNAL_ERROR", + message: "Unexpected Server Error", + details: null } - - res.status(500).json({ - error: { - code: "INTERNAL_ERROR", - message: "Unexpected Server Error", - details: null - } - }); -} \ No newline at end of file + }); +} diff --git a/apps/backend/src/routes/health.ts b/apps/backend/src/routes/health.ts index 9ec201e..1f8b9f5 100644 --- a/apps/backend/src/routes/health.ts +++ b/apps/backend/src/routes/health.ts @@ -3,5 +3,5 @@ import { Router } from "express"; export const healthRouter = Router(); healthRouter.get("/health", (_req, res) => { - res.json({ ok: true }); -}); \ No newline at end of file + res.json({ ok: true }); +}); diff --git a/apps/backend/src/routes/rooms.ts b/apps/backend/src/routes/rooms.ts index b32567e..cc6fd0d 100644 --- a/apps/backend/src/routes/rooms.ts +++ b/apps/backend/src/routes/rooms.ts @@ -12,235 +12,242 @@ import type { PoolClient } from "pg"; export const roomsRouter = Router(); const CreateRoomSchema = z.object({ - expiresInHours: z.number().int().positive().max(168).optional() + expiresInHours: z.number().int().positive().max(168).optional() }); const JoinRoomSchema = z.object({ - code: z.string().min(3).max(32), - displayName: z.string().min(1).max(48).optional() + code: z.string().min(3).max(32), + displayName: z.string().min(1).max(48).optional() }); const RoomIdParamSchema = z.object({ - roomId: z.string().uuid() + roomId: z.string().uuid() }); function setSessionCookie(res: Response, token: string): void { - res.cookie(env.SESSION_COOKIE_NAME, token, { - httpOnly: true, - sameSite: "lax", - secure: false, - maxAge: env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000, - }); + res.cookie(env.SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: false, + maxAge: env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000 + }); } type RoomRow = { - id: string; - code: string; - expires_at: string; - deleted_at: string | null; - created_at?: string; + id: string; + code: string; + expires_at: string; + deleted_at: string | null; + created_at?: string; }; type MemberRole = "creator" | "member"; type MemberRow = { - id: string; - role: MemberRole; - display_name: string | null; - joined_at?: string; - last_seen_at?: string | null; + id: string; + role: MemberRole; + display_name: string | null; + joined_at?: string; + last_seen_at?: string | null; }; type SessionInsertRow = { id: string }; function isPgErrorWithCode(err: unknown): err is { code: string } { - if (typeof err !== "object" || err === null) return false; - const rec = err as Record; - return typeof rec.code === "string"; + if (typeof err !== "object" || err === null) return false; + const rec = err as Record; + return typeof rec.code === "string"; } async function createSession(client: PoolClient, memberId: string): Promise { - const token = newSessionToken(); - const tokenHash = hashToken(token); - const expiresAt = new Date(Date.now() + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000); + const token = newSessionToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000); - const result = await client.query( - ` + const result = await client.query( + ` INSERT INTO member_sessions (member_id, token_hash, expires_at) VALUES ($1, $2, $3) RETURNING id `, - [memberId, tokenHash, expiresAt.toISOString()] - ); + [memberId, tokenHash, expiresAt.toISOString()] + ); - const row = result.rows[0]; - if (!row) throw new ApiError(500, "INTERNAL_ERROR", "Session insert did not return a row"); + const row = result.rows[0]; + if (!row) throw new ApiError(500, "INTERNAL_ERROR", "Session insert did not return a row"); - return token; + return token; } function requireAuthedMemberId(req: AuthedRequest): string { - const memberId = req.auth?.memberId; - if (!memberId) throw new ApiError(401, "UNAUTHORISED", "Missing auth context"); - return memberId; + const memberId = req.auth?.memberId; + if (!memberId) throw new ApiError(401, "UNAUTHORISED", "Missing auth context"); + return memberId; } function ensureRoomActive(room: RoomRow): void { - if (room.deleted_at !== null) throw new ApiError(410, "ROOM_GONE", "Room has been deleted"); - if (new Date(room.expires_at).getTime() <= Date.now()) throw new ApiError(410, "ROOM_GONE", "Room has expired"); + if (room.deleted_at !== null) throw new ApiError(410, "ROOM_GONE", "Room has been deleted"); + if (new Date(room.expires_at).getTime() <= Date.now()) throw new ApiError(410, "ROOM_GONE", "Room has expired"); } -roomsRouter.post("/rooms", asyncHandler(async (req, res) => { +roomsRouter.post( + "/rooms", + asyncHandler(async (req, res) => { const body = CreateRoomSchema.parse(req.body ?? {}); const hours = body.expiresInHours ?? 24; const expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000); const client = await pool.connect(); try { - await client.query("BEGIN"); + await client.query("BEGIN"); - let roomRow: Pick | null = null; + let roomRow: Pick | null = null; - for (let attempt = 0; attempt < 8; attempt++) { - const code = generateRoomCode(6); - try { - const insertedRoom = await client.query>( - ` + for (let attempt = 0; attempt < 8; attempt++) { + const code = generateRoomCode(6); + try { + const insertedRoom = await client.query>( + ` INSERT INTO rooms (code, expires_at) VALUES ($1, $2) RETURNING id, code, expires_at `, - [code, expiresAt.toISOString()] - ); - - const row = insertedRoom.rows[0]; - if (!row) throw new ApiError(500, "INTERNAL_ERROR", "Room insert did not return a row"); - - roomRow = row; - break; - } catch (e: unknown) { - // 23505 = unique_violation - if (isPgErrorWithCode(e) && e.code === "23505") continue; - throw e; - } + [code, expiresAt.toISOString()] + ); + + const row = insertedRoom.rows[0]; + if (!row) throw new ApiError(500, "INTERNAL_ERROR", "Room insert did not return a row"); + + roomRow = row; + break; + } catch (e: unknown) { + // 23505 = unique_violation + if (isPgErrorWithCode(e) && e.code === "23505") continue; + throw e; } + } - if (!roomRow) throw new ApiError(500, "INTERNAL_ERROR", "Failed to allocate unique room code"); + if (!roomRow) throw new ApiError(500, "INTERNAL_ERROR", "Failed to allocate unique room code"); - const insertedMember = await client.query>( - ` + const insertedMember = await client.query>( + ` INSERT INTO members (room_id, role) VALUES ($1, 'creator') RETURNING id, role `, - [roomRow.id] - ); + [roomRow.id] + ); - const memberRow = insertedMember.rows[0]; - if (!memberRow) throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); + const memberRow = insertedMember.rows[0]; + if (!memberRow) throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); - const token = await createSession(client, memberRow.id); - setSessionCookie(res, token); + const token = await createSession(client, memberRow.id); + setSessionCookie(res, token); - await client.query("COMMIT"); + await client.query("COMMIT"); - res.status(201).json({ - room: { id: roomRow.id, code: roomRow.code, expiresAt: roomRow.expires_at }, - member: { id: memberRow.id, role: memberRow.role }, - }); + res.status(201).json({ + room: { id: roomRow.id, code: roomRow.code, expiresAt: roomRow.expires_at }, + member: { id: memberRow.id, role: memberRow.role } + }); } catch (e: unknown) { - try { - await client.query("ROLLBACK"); - } catch { - // ignore rollback errors - } - throw e; + try { + await client.query("ROLLBACK"); + } catch { + // ignore rollback errors + } + throw e; } finally { - client.release(); + client.release(); } -}) + }) ); -roomsRouter.post("/rooms/join", asyncHandler(async (req, res) => { +roomsRouter.post( + "/rooms/join", + asyncHandler(async (req, res) => { const body = JoinRoomSchema.parse(req.body ?? {}); const code = body.code.toUpperCase(); const client = await pool.connect(); try { - await client.query("BEGIN"); + await client.query("BEGIN"); - const roomResult = await client.query( - ` + const roomResult = await client.query( + ` SELECT id, code, expires_at, deleted_at FROM rooms WHERE code = $1 `, - [code] - ); + [code] + ); - const roomRow = roomResult.rows[0]; - if (!roomRow) throw new ApiError(404, "ROOM_NOT_FOUND", "Room code not found"); + const roomRow = roomResult.rows[0]; + if (!roomRow) throw new ApiError(404, "ROOM_NOT_FOUND", "Room code not found"); - ensureRoomActive(roomRow); + ensureRoomActive(roomRow); - const memberInsert = await client.query( - ` + const memberInsert = await client.query( + ` INSERT INTO members (room_id, role, display_name) VALUES ($1, 'member', $2) RETURNING id, role, display_name `, - [roomRow.id, body.displayName ?? null] - ); + [roomRow.id, body.displayName ?? null] + ); - const memberRow = memberInsert.rows[0]; - if (!memberRow) throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); + const memberRow = memberInsert.rows[0]; + if (!memberRow) throw new ApiError(500, "INTERNAL_ERROR", "Member insert did not return a row"); - const token = await createSession(client, memberRow.id); - setSessionCookie(res, token); + const token = await createSession(client, memberRow.id); + setSessionCookie(res, token); - await client.query("COMMIT"); + await client.query("COMMIT"); - res.json({ - room: { id: roomRow.id, code: roomRow.code, expiresAt: roomRow.expires_at }, - member: { id: memberRow.id, role: memberRow.role, displayName: memberRow.display_name }, - session: { token }, - }); + res.json({ + room: { id: roomRow.id, code: roomRow.code, expiresAt: roomRow.expires_at }, + member: { id: memberRow.id, role: memberRow.role, displayName: memberRow.display_name }, + session: { token } + }); } catch (e: unknown) { - try { - await client.query("ROLLBACK"); - } catch { - // ignore rollback errors - } - throw e; + try { + await client.query("ROLLBACK"); + } catch { + // ignore rollback errors + } + throw e; } finally { - client.release(); + client.release(); } -}) + }) ); -roomsRouter.get("/rooms/:roomId", requireSession, asyncHandler(async (req: AuthedRequest, res) => { +roomsRouter.get( + "/rooms/:roomId", + requireSession, + asyncHandler(async (req: AuthedRequest, res) => { const { roomId } = RoomIdParamSchema.parse(req.params); const memberId = requireAuthedMemberId(req); const meResult = await pool.query>( - ` + ` SELECT id, role FROM members WHERE id = $1 AND room_id = $2 `, - [memberId, roomId] + [memberId, roomId] ); const meRow = meResult.rows[0]; if (!meRow) throw new ApiError(403, "FORBIDDEN", "Not a member of this room"); const roomResult = await pool.query>( - ` + ` SELECT id, code, created_at, expires_at, deleted_at FROM rooms WHERE id = $1 `, - [roomId] + [roomId] ); const roomRow = roomResult.rows[0]; @@ -250,73 +257,78 @@ roomsRouter.get("/rooms/:roomId", requireSession, asyncHandler(async (req: Authe await pool.query(`UPDATE members SET last_seen_at = now() WHERE id = $1`, [memberId]); - const membersResult = await pool.query>( - ` + const membersResult = await pool.query< + Pick + >( + ` SELECT id, role, display_name, joined_at, last_seen_at FROM members WHERE room_id = $1 ORDER BY joined_at ASC `, - [roomId] + [roomId] ); res.json({ - room: { - id: roomRow.id, - code: roomRow.code, - createdAt: roomRow.created_at ?? null, - expiresAt: roomRow.expires_at, - }, - me: { memberId: meRow.id, role: meRow.role }, - members: membersResult.rows.map((m) => ({ - id: m.id, - role: m.role, - displayName: m.display_name, - joinedAt: m.joined_at ?? null, - lastSeenAt: m.last_seen_at ?? null, - })), + room: { + id: roomRow.id, + code: roomRow.code, + createdAt: roomRow.created_at ?? null, + expiresAt: roomRow.expires_at + }, + me: { memberId: meRow.id, role: meRow.role }, + members: membersResult.rows.map((m) => ({ + id: m.id, + role: m.role, + displayName: m.display_name, + joinedAt: m.joined_at ?? null, + lastSeenAt: m.last_seen_at ?? null + })) }); -}) + }) ); -roomsRouter.delete("/rooms/:roomId", requireSession, asyncHandler(async (req: AuthedRequest, res) => { +roomsRouter.delete( + "/rooms/:roomId", + requireSession, + asyncHandler(async (req: AuthedRequest, res) => { const { roomId } = RoomIdParamSchema.parse(req.params); const memberId = requireAuthedMemberId(req); const client = await pool.connect(); try { - await client.query("BEGIN"); + await client.query("BEGIN"); - const meResult = await client.query>( - ` + const meResult = await client.query>( + ` SELECT role FROM members WHERE id = $1 AND room_id = $2 `, - [memberId, roomId] - ); + [memberId, roomId] + ); - const meRow = meResult.rows[0]; - if (!meRow) throw new ApiError(403, "FORBIDDEN", "Not a member of this room"); - if (meRow.role !== "creator") throw new ApiError(403, "FORBIDDEN", "Only the creator can delete the room"); + const meRow = meResult.rows[0]; + if (!meRow) throw new ApiError(403, "FORBIDDEN", "Not a member of this room"); + if (meRow.role !== "creator") throw new ApiError(403, "FORBIDDEN", "Only the creator can delete the room"); - const roomResult = await client.query>( - ` + const roomResult = await client.query>( + ` SELECT deleted_at, expires_at FROM rooms WHERE id = $1 `, - [roomId] - ); + [roomId] + ); - const roomRow = roomResult.rows[0]; - if (!roomRow) throw new ApiError(404, "ROOM_NOT_FOUND", "Room not found"); - if (roomRow.deleted_at !== null) throw new ApiError(410, "ROOM_GONE", "Room has already been deleted"); + const roomRow = roomResult.rows[0]; + if (!roomRow) throw new ApiError(404, "ROOM_NOT_FOUND", "Room not found"); + if (roomRow.deleted_at !== null) throw new ApiError(410, "ROOM_GONE", "Room has already been deleted"); - await client.query(`UPDATE rooms SET deleted_at = now() WHERE id = $1`, [roomId]); + await client.query(`UPDATE rooms SET deleted_at = now() WHERE id = $1`, [roomId]); - await client.query( - ` + await client.query( + ` UPDATE member_sessions ms SET revoked_at = now() FROM members m @@ -325,20 +337,20 @@ roomsRouter.delete("/rooms/:roomId", requireSession, asyncHandler(async (req: Au AND ms.revoked_at IS NULL AND ms.member_id <> $2 `, - [roomId, memberId] - ); + [roomId, memberId] + ); - await client.query("COMMIT"); - res.status(204).send(); + await client.query("COMMIT"); + res.status(204).send(); } catch (e: unknown) { - try { - await client.query("ROLLBACK"); - } catch { - // ignore rollback errors - } - throw e; + try { + await client.query("ROLLBACK"); + } catch { + // ignore rollback errors + } + throw e; } finally { - client.release(); + client.release(); } -}) -); \ No newline at end of file + }) +); diff --git a/apps/backend/src/utils/asyncHandler.ts b/apps/backend/src/utils/asyncHandler.ts index 619b123..8ab34ad 100644 --- a/apps/backend/src/utils/asyncHandler.ts +++ b/apps/backend/src/utils/asyncHandler.ts @@ -1,7 +1,9 @@ import type { Request, Response, NextFunction, RequestHandler } from "express"; -export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise): RequestHandler { - return (req, res, next) => { - void fn(req, res, next).catch(next); - }; -} \ No newline at end of file +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise +): RequestHandler { + return (req, res, next) => { + void fn(req, res, next).catch(next); + }; +} diff --git a/apps/backend/src/utils/roomCode.ts b/apps/backend/src/utils/roomCode.ts index 550df8a..5d8e141 100644 --- a/apps/backend/src/utils/roomCode.ts +++ b/apps/backend/src/utils/roomCode.ts @@ -1,14 +1,14 @@ -import crypto from "crypto" +import crypto from "crypto"; -const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; export function generateRoomCode(length = 6): string { - const bytes = crypto.randomBytes(length); - let out = ""; + const bytes = crypto.randomBytes(length); + let out = ""; - for (const byte of bytes) { - out += ALPHABET[byte % ALPHABET.length]; - } + for (const byte of bytes) { + out += ALPHABET[byte % ALPHABET.length]; + } - return out; -} \ No newline at end of file + return out; +} diff --git a/apps/backend/test/db.ts b/apps/backend/test/db.ts index 75abd9d..2d81c3d 100644 --- a/apps/backend/test/db.ts +++ b/apps/backend/test/db.ts @@ -1,7 +1,7 @@ import { pool } from "../src/db"; export async function resetDb(): Promise { - await pool.query(` + await pool.query(` TRUNCATE TABLE member_sessions, members, @@ -12,5 +12,5 @@ export async function resetDb(): Promise { } export async function closeDb(): Promise { - await pool.end(); -} \ No newline at end of file + await pool.end(); +} diff --git a/apps/backend/test/rooms.test.ts b/apps/backend/test/rooms.test.ts index e66dabf..fad9f32 100644 --- a/apps/backend/test/rooms.test.ts +++ b/apps/backend/test/rooms.test.ts @@ -5,283 +5,261 @@ import { resetDb, closeDb } from "./db"; import { pool } from "../src/db"; type CreateRoomResponse = { - room: { id: string; code: string; expiresAt: string }; - member: { id: string; role: "creator" | "member" }; + room: { id: string; code: string; expiresAt: string }; + member: { id: string; role: "creator" | "member" }; }; type JoinRoomResponse = { - room: { id: string; code: string; expiresAt: string }; - member: { id: string; role: "creator" | "member"; displayName: string | null }; - session?: { token: string }; + room: { id: string; code: string; expiresAt: string }; + member: { id: string; role: "creator" | "member"; displayName: string | null }; + session?: { token: string }; }; type GetRoomResponse = { - room: { id: string; code: string; createdAt: string | null; expiresAt: string }; - me: { memberId: string; role: "creator" | "member" }; - members: Array<{ - id: string; - role: "creator" | "member"; - displayName: string | null; - joinedAt: string | null; - lastSeenAt: string | null; - }>; + room: { id: string; code: string; createdAt: string | null; expiresAt: string }; + me: { memberId: string; role: "creator" | "member" }; + members: Array<{ + id: string; + role: "creator" | "member"; + displayName: string | null; + joinedAt: string | null; + lastSeenAt: string | null; + }>; }; function assertCreateRoomBody(body: unknown): asserts body is CreateRoomResponse { - if (typeof body !== "object" || body === null) throw new Error("Expected object body"); - const rec = body as Record; - if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); - if (typeof rec.member !== "object" || rec.member === null) throw new Error("Missing member"); + if (typeof body !== "object" || body === null) throw new Error("Expected object body"); + const rec = body as Record; + if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); + if (typeof rec.member !== "object" || rec.member === null) throw new Error("Missing member"); } function assertJoinRoomBody(body: unknown): asserts body is JoinRoomResponse { - if (typeof body !== "object" || body === null) throw new Error("Expected object body"); - const rec = body as Record; - if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); - if (typeof rec.member !== "object" || rec.member === null) throw new Error("Missing member"); + if (typeof body !== "object" || body === null) throw new Error("Expected object body"); + const rec = body as Record; + if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); + if (typeof rec.member !== "object" || rec.member === null) throw new Error("Missing member"); } function assertGetRoomBody(body: unknown): asserts body is GetRoomResponse { - if (typeof body !== "object" || body === null) throw new Error("Expected object body"); - const rec = body as Record; - if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); - if (typeof rec.me !== "object" || rec.me === null) throw new Error("Missing me"); - if (!Array.isArray(rec.members)) throw new Error("Missing members array"); + if (typeof body !== "object" || body === null) throw new Error("Expected object body"); + const rec = body as Record; + if (typeof rec.room !== "object" || rec.room === null) throw new Error("Missing room"); + if (typeof rec.me !== "object" || rec.me === null) throw new Error("Missing me"); + if (!Array.isArray(rec.members)) throw new Error("Missing members array"); } function assertErrorBody(body: unknown): asserts body is { error?: { code: string } } { - if (typeof body !== "object" || body === null) throw new Error("Expected object body"); + if (typeof body !== "object" || body === null) throw new Error("Expected object body"); } describe("Rooms API", () => { - const app = createApp(); - - beforeEach(async () => { - await resetDb(); - }); - - afterAll(async () => { - await closeDb(); - }); - - it("POST /rooms creates a room + creator session cookie", async () => { - const agent = request.agent(app); - - const res = await agent - .post("/rooms") - .send({ expiresInHours: 24 }) - .set("Content-Type", "application/json"); - - expect(res.status).toBe(201); - assertCreateRoomBody(res.body); - - expect(typeof res.body.room.id).toBe("string"); - expect(typeof res.body.room.code).toBe("string"); - expect(res.body.member.role).toBe("creator"); - - - const setCookieHeader = res.headers["set-cookie"]; - const cookies = Array.isArray(setCookieHeader) - ? setCookieHeader - : typeof setCookieHeader === "string" - ? [setCookieHeader] - : []; - - expect(cookies.join(";")).toContain("gs_session="); - }); - - it("POST /rooms/join returns 404 for unknown code", async () => { - const res = await request(app) - .post("/rooms/join") - .send({ code: "ABC234", displayName: "Ryan" }) - .set("Content-Type", "application/json"); - - expect(res.status).toBe(404); - assertErrorBody(res.body); - expect(res.body?.error?.code).toBe("ROOM_NOT_FOUND"); - }); - - it("POST /rooms/join joins an existing room and sets cookie", async () => { - const creator = request.agent(app); - const createRes = await creator - .post("/rooms") - .send({ expiresInHours: 24 }) - .set("Content-Type", "application/json"); - - expect(createRes.status).toBe(201); - assertCreateRoomBody(createRes.body); - - const code = createRes.body.room.code; - - const joiner = request.agent(app); - const joinRes = await joiner - .post("/rooms/join") - .send({ code, displayName: "Ryan" }) - .set("Content-Type", "application/json"); - - expect(joinRes.status).toBe(200); - assertJoinRoomBody(joinRes.body); - - expect(joinRes.body.room.code).toBe(code); - expect(joinRes.body.member.role).toBe("member"); - expect(joinRes.body.member.displayName).toBe("Ryan"); - - const setCookieHeader = joinRes.headers["set-cookie"]; - const cookies = Array.isArray(setCookieHeader) - ? setCookieHeader - : typeof setCookieHeader === "string" - ? [setCookieHeader] - : []; - - expect(cookies.join(";")).toContain("gs_session="); - }); - - it("GET /rooms/:roomId requires session", async () => { - const res = await request(app).get("/rooms/00000000-0000-0000-0000-000000000000"); - expect(res.status).toBe(401); - assertErrorBody(res.body); - expect(res.body?.error?.code).toBe("UNAUTHORISED"); - }); - - it("GET /rooms/:roomId returns room + member list for an authenticated member", async () => { - const creator = request.agent(app); - - const createRes = await creator - .post("/rooms") - .send({ expiresInHours: 24 }) - .set("Content-Type", "application/json"); - - expect(createRes.status).toBe(201); - assertCreateRoomBody(createRes.body); - - const roomId = createRes.body.room.id; - const code = createRes.body.room.code; - - // Join a second member - const joiner = request.agent(app); - const joinRes = await joiner - .post("/rooms/join") - .send({ code, displayName: "Ryan" }) - .set("Content-Type", "application/json"); - expect(joinRes.status).toBe(200); - - // Creator fetches room - const res = await creator.get(`/rooms/${roomId}`); - expect(res.status).toBe(200); - assertGetRoomBody(res.body); - - expect(res.body.room.id).toBe(roomId); - expect(res.body.room.code).toBe(code); - expect(res.body.members.length).toBe(2); - - const roles = res.body.members.map((m) => m.role).sort(); - expect(roles).toEqual(["creator", "member"]); - }); - - it("GET /rooms/:roomId returns 410 when room expired", async () => { - const creator = request.agent(app); - - const createRes = await creator - .post("/rooms") - .send({ expiresInHours: 24 }) - .set("Content-Type", "application/json"); - - expect(createRes.status).toBe(201); - assertCreateRoomBody(createRes.body); - const roomId = createRes.body.room.id; - - // Force expire in DB - await pool.query(`UPDATE rooms SET expires_at = now() - interval '1 minute' WHERE id = $1`, [roomId]); - - const res = await creator.get(`/rooms/${roomId}`); - expect(res.status).toBe(410); - assertErrorBody(res.body); - expect(res.body?.error?.code).toBe("ROOM_GONE"); - }); - - it("DELETE /rooms/:roomId requires creator role", async () => { - const creator = request.agent(app); - const createRes = await creator - .post("/rooms") - .send({ expiresInHours: 24 }) - .set("Content-Type", "application/json"); - expect(createRes.status).toBe(201); - assertCreateRoomBody(createRes.body); - - const roomId = createRes.body.room.id; - const code = createRes.body.room.code; - - const joiner = request.agent(app); - const joinRes = await joiner - .post("/rooms/join") - .send({ code, displayName: "Ryan" }) - .set("Content-Type", "application/json"); - expect(joinRes.status).toBe(200); - - const delRes = await joiner.delete(`/rooms/${roomId}`); - expect(delRes.status).toBe(403); - assertErrorBody(delRes.body); - expect(delRes.body?.error?.code).toBe("FORBIDDEN"); - }); - - it("DELETE /rooms/:roomId deletes room and revokes sessions", async () => { - const creator = request.agent(app); - const createRes = await creator - .post("/rooms") - .send({ expiresInHours: 24 }) - .set("Content-Type", "application/json"); - - expect(createRes.status).toBe(201); - assertCreateRoomBody(createRes.body); - - const roomId = createRes.body.room.id; - const code = createRes.body.room.code; - - const joiner = request.agent(app); - const joinRes = await joiner - .post("/rooms/join") - .send({ code, displayName: "Ryan" }) - .set("Content-Type", "application/json"); - expect(joinRes.status).toBe(200); - - const delRes = await creator.delete(`/rooms/${roomId}`); - expect(delRes.status).toBe(204); - - // After deletion, room fetch should be 410 for creator - const resCreator = await creator.get(`/rooms/${roomId}`); - expect(resCreator.status).toBe(410); - assertErrorBody(resCreator.body); - expect(resCreator.body?.error?.code).toBe("ROOM_GONE"); - - // Joiner session should now be revoked; should get 401 on protected endpoint - const resJoiner = await joiner.get(`/rooms/${roomId}`); - expect(resJoiner.status).toBe(401); - assertErrorBody(resJoiner.body); - expect(resJoiner.body?.error?.code).toBe("UNAUTHORISED"); - }); - - it("POST /rooms/join returns 410 if room deleted", async () => { - const creator = request.agent(app); - const createRes = await creator - .post("/rooms") - .send({ expiresInHours: 24 }) - .set("Content-Type", "application/json"); - expect(createRes.status).toBe(201); - assertCreateRoomBody(createRes.body); - - const roomId = createRes.body.room.id; - const code = createRes.body.room.code; - - const delRes = await creator.delete(`/rooms/${roomId}`); - expect(delRes.status).toBe(204); - - const joinRes = await request(app) - .post("/rooms/join") - .send({ code, displayName: "Ryan" }) - .set("Content-Type", "application/json"); - - expect(joinRes.status).toBe(410); - assertErrorBody(joinRes.body); - expect(joinRes.body?.error?.code).toBe("ROOM_GONE"); - }); -}); \ No newline at end of file + const app = createApp(); + + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await closeDb(); + }); + + it("POST /rooms creates a room + creator session cookie", async () => { + const agent = request.agent(app); + + const res = await agent.post("/rooms").send({ expiresInHours: 24 }).set("Content-Type", "application/json"); + + expect(res.status).toBe(201); + assertCreateRoomBody(res.body); + + expect(typeof res.body.room.id).toBe("string"); + expect(typeof res.body.room.code).toBe("string"); + expect(res.body.member.role).toBe("creator"); + + const setCookieHeader = res.headers["set-cookie"]; + const cookies = Array.isArray(setCookieHeader) + ? setCookieHeader + : typeof setCookieHeader === "string" + ? [setCookieHeader] + : []; + + expect(cookies.join(";")).toContain("gs_session="); + }); + + it("POST /rooms/join returns 404 for unknown code", async () => { + const res = await request(app) + .post("/rooms/join") + .send({ code: "ABC234", displayName: "Ryan" }) + .set("Content-Type", "application/json"); + + expect(res.status).toBe(404); + assertErrorBody(res.body); + expect(res.body?.error?.code).toBe("ROOM_NOT_FOUND"); + }); + + it("POST /rooms/join joins an existing room and sets cookie", async () => { + const creator = request.agent(app); + const createRes = await creator.post("/rooms").send({ expiresInHours: 24 }).set("Content-Type", "application/json"); + + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const code = createRes.body.room.code; + + const joiner = request.agent(app); + const joinRes = await joiner + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + + expect(joinRes.status).toBe(200); + assertJoinRoomBody(joinRes.body); + + expect(joinRes.body.room.code).toBe(code); + expect(joinRes.body.member.role).toBe("member"); + expect(joinRes.body.member.displayName).toBe("Ryan"); + + const setCookieHeader = joinRes.headers["set-cookie"]; + const cookies = Array.isArray(setCookieHeader) + ? setCookieHeader + : typeof setCookieHeader === "string" + ? [setCookieHeader] + : []; + + expect(cookies.join(";")).toContain("gs_session="); + }); + + it("GET /rooms/:roomId requires session", async () => { + const res = await request(app).get("/rooms/00000000-0000-0000-0000-000000000000"); + expect(res.status).toBe(401); + assertErrorBody(res.body); + expect(res.body?.error?.code).toBe("UNAUTHORISED"); + }); + + it("GET /rooms/:roomId returns room + member list for an authenticated member", async () => { + const creator = request.agent(app); + + const createRes = await creator.post("/rooms").send({ expiresInHours: 24 }).set("Content-Type", "application/json"); + + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const roomId = createRes.body.room.id; + const code = createRes.body.room.code; + + // Join a second member + const joiner = request.agent(app); + const joinRes = await joiner + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + expect(joinRes.status).toBe(200); + + // Creator fetches room + const res = await creator.get(`/rooms/${roomId}`); + expect(res.status).toBe(200); + assertGetRoomBody(res.body); + + expect(res.body.room.id).toBe(roomId); + expect(res.body.room.code).toBe(code); + expect(res.body.members.length).toBe(2); + + const roles = res.body.members.map((m) => m.role).sort(); + expect(roles).toEqual(["creator", "member"]); + }); + + it("GET /rooms/:roomId returns 410 when room expired", async () => { + const creator = request.agent(app); + + const createRes = await creator.post("/rooms").send({ expiresInHours: 24 }).set("Content-Type", "application/json"); + + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + const roomId = createRes.body.room.id; + + // Force expire in DB + await pool.query(`UPDATE rooms SET expires_at = now() - interval '1 minute' WHERE id = $1`, [roomId]); + + const res = await creator.get(`/rooms/${roomId}`); + expect(res.status).toBe(410); + assertErrorBody(res.body); + expect(res.body?.error?.code).toBe("ROOM_GONE"); + }); + + it("DELETE /rooms/:roomId requires creator role", async () => { + const creator = request.agent(app); + const createRes = await creator.post("/rooms").send({ expiresInHours: 24 }).set("Content-Type", "application/json"); + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const roomId = createRes.body.room.id; + const code = createRes.body.room.code; + + const joiner = request.agent(app); + const joinRes = await joiner + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + expect(joinRes.status).toBe(200); + + const delRes = await joiner.delete(`/rooms/${roomId}`); + expect(delRes.status).toBe(403); + assertErrorBody(delRes.body); + expect(delRes.body?.error?.code).toBe("FORBIDDEN"); + }); + + it("DELETE /rooms/:roomId deletes room and revokes sessions", async () => { + const creator = request.agent(app); + const createRes = await creator.post("/rooms").send({ expiresInHours: 24 }).set("Content-Type", "application/json"); + + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const roomId = createRes.body.room.id; + const code = createRes.body.room.code; + + const joiner = request.agent(app); + const joinRes = await joiner + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + expect(joinRes.status).toBe(200); + + const delRes = await creator.delete(`/rooms/${roomId}`); + expect(delRes.status).toBe(204); + + // After deletion, room fetch should be 410 for creator + const resCreator = await creator.get(`/rooms/${roomId}`); + expect(resCreator.status).toBe(410); + assertErrorBody(resCreator.body); + expect(resCreator.body?.error?.code).toBe("ROOM_GONE"); + + // Joiner session should now be revoked; should get 401 on protected endpoint + const resJoiner = await joiner.get(`/rooms/${roomId}`); + expect(resJoiner.status).toBe(401); + assertErrorBody(resJoiner.body); + expect(resJoiner.body?.error?.code).toBe("UNAUTHORISED"); + }); + + it("POST /rooms/join returns 410 if room deleted", async () => { + const creator = request.agent(app); + const createRes = await creator.post("/rooms").send({ expiresInHours: 24 }).set("Content-Type", "application/json"); + expect(createRes.status).toBe(201); + assertCreateRoomBody(createRes.body); + + const roomId = createRes.body.room.id; + const code = createRes.body.room.code; + + const delRes = await creator.delete(`/rooms/${roomId}`); + expect(delRes.status).toBe(204); + + const joinRes = await request(app) + .post("/rooms/join") + .send({ code, displayName: "Ryan" }) + .set("Content-Type", "application/json"); + + expect(joinRes.status).toBe(410); + assertErrorBody(joinRes.body); + expect(joinRes.body?.error?.code).toBe("ROOM_GONE"); + }); +}); diff --git a/apps/backend/test/setup.ts b/apps/backend/test/setup.ts index d9403b6..f44f830 100644 --- a/apps/backend/test/setup.ts +++ b/apps/backend/test/setup.ts @@ -3,17 +3,15 @@ import dotenv from "dotenv"; import { beforeAll } from "vitest"; dotenv.config({ - path: path.resolve(__dirname, "../../..", ".env"), - override: true, + path: path.resolve(__dirname, "../../..", ".env"), + override: true }); beforeAll(() => { - const testUrl = process.env.DATABASE_URL_TEST; - if (typeof testUrl !== "string" || testUrl.length === 0) { - throw new Error( - "DATABASE_URL_TEST is missing. Add DATABASE_URL_TEST to the repo root .env" - ); - } + const testUrl = process.env.DATABASE_URL_TEST; + if (typeof testUrl !== "string" || testUrl.length === 0) { + throw new Error("DATABASE_URL_TEST is missing. Add DATABASE_URL_TEST to the repo root .env"); + } - process.env.DATABASE_URL = testUrl; -}); \ No newline at end of file + process.env.DATABASE_URL = testUrl; +}); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index f52cb0a..1a0d024 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -4,12 +4,8 @@ "module": "CommonJS", "outDir": "dist", "rootDir": "src", - "types": [ - "node" - ], + "types": ["node"], "esModuleInterop": true }, - "include": [ - "src" - ] -} \ No newline at end of file + "include": ["src"] +} diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 1de1783..6926c76 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ setupFiles: ["./test/setup.ts"], globals: false, testTimeout: 30_000, - hookTimeout: 30_000, - }, -}); \ No newline at end of file + hookTimeout: 30_000 + } +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index fb31451..4760e0d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -75,12 +75,11 @@ export default [ "@typescript-eslint/no-unused-vars": [ "error", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_" } ] } } - ]; From 883e744e752ec284f390918d2ccfdf786dc35b64 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 02:06:00 +1100 Subject: [PATCH 13/25] Added documentation for rooms routes --- .markdownlint.json | 5 + apps/backend/README.md | 260 +++++++++++++++++++++++++++++++++++++++++ package-lock.json | 199 +++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 .markdownlint.json create mode 100644 apps/backend/README.md diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..4bae71d --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "no-duplicate-heading": { + "siblings_only": true + } +} \ No newline at end of file diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..d00d707 --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,260 @@ +# GameSwipe Backend (apps/backend) + +Express + TypeScript backend for GameSwipe (room-based multiplayer game picker). + +This README documents the currently implemented endpoints: + +- Health +- Create room +- Join room +- Get room state +- Delete room + +## Run locally + +### Prerequisites + +- Node.js +- A Postgres database (e.g. Supabase) + +### Environment variables + +These are read from the repo root `.env` (recommended): + +```env +PORT=4000 + +DATABASE_URL=postgresql://... +SESSION_SECRET=...at least 16 chars... + +SESSION_COOKIE_NAME=gs_session +SESSION_TTL_DAYS=30 +Start dev server +``` + +## Run from GameSwipe repo root + +```BASH +npm --workspace apps/backend run dev +``` + +Server listens on: + +- + +## Common API behavior + +### Content-Type + +All JSON endpoints use: + +- Content-Type: application/json + +### Session auth (cookie) + +Authenticated endpoints require a session cookie: + +- Cookie name: SESSION_COOKIE_NAME (default gs_session) + +- Cookie is set on successful room create/join. + +### Error response format + +On errors, responses follow: + +```JSON +{ + "error": { + "code": "SOME_CODE", + "message": "Human readable message", + "details": null + } +} +``` + +Common status codes: + +- 400 validation errors (e.g. invalid JSON shape) + +- 401 unauthenticated (missing/invalid/expired session) + +- 403 authenticated but forbidden (not a room member, not creator) + +- 404 not found (room does not exist / code not found) + +- 410 gone (room deleted or expired) + +- 500 unexpected server error + +## Endpoints + +### GET `/health` + +Simple health check. + +**Response `200`** + +```JSON +{ + "ok": true, + "service": "gameswipe-backend", + "time": "2026-02-20T12:59:55.000Z" +} +``` + +### POST /rooms + +Create a new room and a creator member. Sets a session cookie. + +```JSON +Request body +{ + "expiresInHours": 24 +} +``` + +- `expiresInHours` optional +- default `24` +- max `168` (7 days) + +#### Response `201` + +```JSON +{ + "room": { + "id": "uuid", + "code": "ABC123", + "expiresAt": "2026-02-21T12:00:00.000Z" + }, + "member": { + "id": "uuid", + "role": "creator" + } +} +``` + +#### Errors + +- `500 INTERNAL_ERROR` if room code allocation fails (extremely unlikely, but handled) + +### POST /rooms/join + +Join an existing room by code. Creates a member and sets a session cookie. + +```JSON +Request body +{ + "code": "ABC123", + "displayName": "Ryan" +} +``` + +- `code` is case-insensitive +- `displayName` optional + +#### Response `200` + +```JSON +{ + "room": { + "id": "uuid", + "code": "ABC123", + "expiresAt": "2026-02-21T12:00:00.000Z" + }, + "member": { + "id": "uuid", + "role": "member", + "displayName": "Ryan" + }, + "session": { + "token": "raw-session-token" + } +} +``` + +> Note: A cookie is set even though the token is also returned in JSON. + +**Errors.** + +- `404 ROOM_NOT_FOUND`if code doesn’t exist +- `410 ROOM_GONE` if room was deleted or expired + +### GET /rooms/:roomId + +Get the current room state (room metadata + members list). + +**Auth required:** session cookie. + +#### URL params + +- `roomId`: UUID + +#### Response `200` + +```JSON +{ + "room": { + "id": "uuid", + "code": "ABC123", + "createdAt": "2026-02-20T12:00:00.000Z", + "expiresAt": "2026-02-21T12:00:00.000Z" + }, + "me": { + "memberId": "uuid", + "role": "creator" + }, + "members": [ + { + "id": "uuid", + "role": "creator", + "displayName": null, + "joinedAt": "2026-02-20T12:00:00.000Z", + "lastSeenAt": "2026-02-20T12:05:00.000Z" + }, + { + "id": "uuid", + "role": "member", + "displayName": "Ryan", + "joinedAt": "2026-02-20T12:02:00.000Z", + "lastSeenAt": "2026-02-20T12:06:00.000Z" + } + ] +} +``` + +The endpoint also updates the requester’s last_seen_at (best-effort presence tracking). + +#### Errors + +- `401 UNAUTHORISED` if missing/invalid/expired session +- `403 FORBIDDEN` if authenticated but not a member of the room +- `404 ROOM_NOT_FOUND` if roomId doesn’t exist +- `410 ROOM_GONE` if room deleted or expired + +### DELETE /rooms/:roomId + +Delete a room early. + +**Auth required:** session cookie. +**Authorization:** only the creator can delete. + +#### URL params + +- `roomId`: UUID + +#### Response `204` + +No content. + +#### Behavior notes + +Room is marked deleted (soft delete). + +Member sessions in the room are revoked. + +#### Errors + +- `401 UNAUTHORISED` if missing/invalid/expired session +- `403 FORBIDDEN` if not a member, or not creator +- `404 ROOM_NOT_FOUND` if roomId doesn’t exist +- `410 ROOM_GONE` if already deleted diff --git a/package-lock.json b/package-lock.json index 8673052..b7bb8aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/pg": "^8.16.0", "concurrently": "^9.2.1", "eslint": "^9.39.2", + "eslint-plugin-markdown": "^5.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.0", @@ -1864,6 +1865,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2000,6 +2011,13 @@ "@types/superagent": "*" } }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -2990,6 +3008,39 @@ "node": ">=8" } }, + "node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3907,6 +3958,23 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-markdown": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-markdown/-/eslint-plugin-markdown-5.1.0.tgz", + "integrity": "sha512-SJeyKko1K6GwI0AN6xeCDToXDkfKZfXcexA6B+O2Wr2btUS9GrC+YgwSyVli5DJnctUHjFXcQ2cqTaAmVoLi2A==", + "deprecated": "Please use @eslint/markdown instead", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^0.8.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -4880,6 +4948,32 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5015,6 +5109,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5084,6 +5189,17 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5539,6 +5655,35 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz", + "integrity": "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-string": "^2.0.0", + "micromark": "~2.11.0", + "parse-entities": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -5566,6 +5711,27 @@ "node": ">= 0.6" } }, + "node_modules/micromark": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", + "integrity": "sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "parse-entities": "^2.0.0" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -5930,6 +6096,25 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -7562,6 +7747,20 @@ "dev": true, "license": "MIT" }, + "node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/package.json b/package.json index 6ca1151..0dc7139 100644 --- a/package.json +++ b/package.json @@ -40,4 +40,4 @@ "prettier": "^3.8.1", "typescript-eslint": "^8.56.0" } -} +} \ No newline at end of file From 9dbc34cf62c8a143a9707c6d4dcc7b7ebf34b63b Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 02:25:31 +1100 Subject: [PATCH 14/25] added frontend readme --- apps/frontend/README.md | 104 +++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 60 deletions(-) diff --git a/apps/frontend/README.md b/apps/frontend/README.md index d2cc24c..29282f7 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -1,73 +1,57 @@ -# React + TypeScript + Vite +# GameSwipe Frontend (apps/frontend) -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Vite + React + TypeScript frontend for GameSwipe (room-based multiplayer game picker). -Currently, two official plugins are available: +## Tech stack -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- React + TypeScript (Vite) +- ESLint via the repo root config (`eslint.config.mjs`) -## React Compiler +## Run locally -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +### Prerequisites -## Expanding the ESLint configuration +- Node.js (same version as repo standard) +- Backend running locally (for any API calls the UI makes) -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +### Install dependencies (from repo root) -```js -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - // Other configs... +```bash +npm install +``` + +### Start frontend dev server (from repo root) + +```BASH +npm --workspace apps/frontend run dev +``` + +Vite will print the local URL (typically something like ). - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked +### Environment variables - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname - } - // other options... - } - } -]); +These are read from the repo root `.env` (recommended): + +```env +FRONTEND_PORT=5173 +VITE_API_BASE_URL= ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from "eslint-plugin-react-x"; -import reactDom from "eslint-plugin-react-dom"; - -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs["recommended-typescript"], - // Enable lint rules for React DOM - reactDom.configs.recommended - ], - languageOptions: { - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname - } - // other options... - } - } -]); +### Scripts + +Run from the repo root: + +```BASH +npm --workspace apps/frontend run dev +npm --workspace apps/frontend run build +npm --workspace apps/frontend run preview +npm --workspace apps/frontend run lint ``` + +### Project structure + +- `src/pages/` - route-level pages (Room create/join, Swipe, Results) +- `src/components/` - reusable UI components +- `src/api/` - typed API client + DTOs (prefer shared types from packages/shared) +- `src/state/` - client state + server cache (if you adopt React Query or similar) +- `src/styles/` - global styles / theme tokens From 4bbd58d81578f11da098b2f3b00f78187d4122e8 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 02:32:49 +1100 Subject: [PATCH 15/25] updated main readme --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cfbdd42..2f7b246 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ A Swipe Web App for deciding what Steam game to play with a group of friends. Monorepo: -- `apps/frontend` — React + Vite + TypeScript -- `apps/backend` — Express + TypeScript -- `packages/shared` — shared types/schemas +- `apps/frontend` - React + Vite + TypeScript +- `apps/backend` - Express + TypeScript +- `packages/shared` - shared types/schemas ## Local dev @@ -14,3 +14,13 @@ Monorepo: npm install npm run dev ``` + +### Useful Commands + +```BASH +npm run lint +npm run build +npm run test +npm run format:check +npm run format +``` From 4e0c8774d1154e838cd815bdead0f095622889a1 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 02:34:55 +1100 Subject: [PATCH 16/25] added readme links to root readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 2f7b246..cb7df16 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,11 @@ npm run test npm run format:check npm run format ``` + +### Frontend + +Refer to [Frontend README](apps/frontend/README.md) + +### Backend + +Refer to [BACKEND README](apps/backend/README.md) From fe77311334e634c12ce3e476d5bc1cc32ea8ff00 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 02:43:43 +1100 Subject: [PATCH 17/25] fixed formatting --- .markdownlint.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.markdownlint.json b/.markdownlint.json index 4bae71d..fba9b6e 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,5 +1,5 @@ { - "no-duplicate-heading": { - "siblings_only": true - } -} \ No newline at end of file + "no-duplicate-heading": { + "siblings_only": true + } +} diff --git a/package.json b/package.json index 0dc7139..6ca1151 100644 --- a/package.json +++ b/package.json @@ -40,4 +40,4 @@ "prettier": "^3.8.1", "typescript-eslint": "^8.56.0" } -} \ No newline at end of file +} From 3404a88594abab57d201237c3a5e0471965b3877 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 03:05:47 +1100 Subject: [PATCH 18/25] Potentially fixed npm run test for pipeline --- .env.test | 3 +++ .gitignore | 4 ++++ apps/backend/src/env.ts | 37 ++++++++++++++++++++++++++++-------- apps/backend/test/loadEnv.ts | 17 +++++++++++++++++ apps/backend/test/setup.ts | 16 ++++++---------- 5 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 .env.test create mode 100644 apps/backend/test/loadEnv.ts diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..f944252 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +# DO NOT PUT REAL SECRETS IN THIS FILE. THIS FILE IS FOR TESTING PURPOSES ONLY. +DATABASE_URL_TEST="file:./.tmp/test.db" +SESSION_SECRET="test-secret" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 79ec1f7..537bc52 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage .env .env.* !.env.example +!.env.test .DS_Store # Vite / tooling @@ -23,3 +24,6 @@ coverage **/vite.config.js **/vite.config.d.ts **/vite.config.d.ts.map + +# any temp files or folders +**/.tmp \ No newline at end of file diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 7db7d97..d65505c 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -1,18 +1,39 @@ +import fs from "node:fs"; import path from "node:path"; import dotenv from "dotenv"; import { z } from "zod"; +const repoRoot = path.resolve(__dirname, "../../.."); +const dotenvPath = path.join(repoRoot, ".env"); +const dotenvTestPath = path.join(repoRoot, ".env.test"); + +const chosenEnvPath = fs.existsSync(dotenvPath) ? dotenvPath : dotenvTestPath; + dotenv.config({ - path: path.resolve(__dirname, "../../..", ".env"), + path: chosenEnvPath, override: true }); -const EnvSchema = z.object({ - PORT: z.coerce.number().default(3000), - DATABASE_URL: z.string().min(1), - SESSION_SECRET: z.string().min(16), - SESSION_COOKIE_NAME: z.string().default("gs_session"), - SESSION_TTL_DAYS: z.coerce.number().default(30) -}); +const EnvSchema = z + .object({ + PORT: z.coerce.number().default(3000), + + DATABASE_URL: z.string().min(1).optional(), + DATABASE_URL_TEST: z.string().min(1).optional(), + + SESSION_SECRET: z.string().min(16), + SESSION_COOKIE_NAME: z.string().default("gs_session"), + SESSION_TTL_DAYS: z.coerce.number().default(30) + }) + .superRefine((val, ctx) => { + if (!val.DATABASE_URL && !val.DATABASE_URL_TEST) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DATABASE_URL"], + message: + "Missing database URL. Set DATABASE_URL in .env, or (if .env does not exist) set DATABASE_URL_TEST in .env.test." + }); + } + }); export const env = EnvSchema.parse(process.env); diff --git a/apps/backend/test/loadEnv.ts b/apps/backend/test/loadEnv.ts new file mode 100644 index 0000000..d480b8b --- /dev/null +++ b/apps/backend/test/loadEnv.ts @@ -0,0 +1,17 @@ +import fs from "node:fs"; +import path from "node:path"; +import { config as dotenvConfig } from "dotenv"; + +export function loadTestEnvPreferDotenv() { + const repoRoot = path.resolve(__dirname, "..", "..", ".."); + const dotenvPath = path.join(repoRoot, ".env"); + const dotenvTestPath = path.join(repoRoot, ".env.test"); + + if (fs.existsSync(dotenvPath)) { + dotenvConfig({ path: dotenvPath, override: false }); + return { used: ".env" as const, path: dotenvPath }; + } + + dotenvConfig({ path: dotenvTestPath, override: false }); + return { used: ".env.test" as const, path: dotenvTestPath }; +} diff --git a/apps/backend/test/setup.ts b/apps/backend/test/setup.ts index f44f830..05b51f8 100644 --- a/apps/backend/test/setup.ts +++ b/apps/backend/test/setup.ts @@ -1,17 +1,13 @@ +import fs from "node:fs"; import path from "node:path"; -import dotenv from "dotenv"; import { beforeAll } from "vitest"; +import { loadTestEnvPreferDotenv } from "./loadEnv"; -dotenv.config({ - path: path.resolve(__dirname, "../../..", ".env"), - override: true -}); +const loaded = loadTestEnvPreferDotenv(); beforeAll(() => { - const testUrl = process.env.DATABASE_URL_TEST; - if (typeof testUrl !== "string" || testUrl.length === 0) { - throw new Error("DATABASE_URL_TEST is missing. Add DATABASE_URL_TEST to the repo root .env"); + if (loaded.used === ".env.test") { + const tmpDir = path.resolve(process.cwd(), ".tmp"); + fs.mkdirSync(tmpDir, { recursive: true }); } - - process.env.DATABASE_URL = testUrl; }); From c3bd84a6cf8e44a5a0e1bc736a6b6f9d57a12112 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 03:08:58 +1100 Subject: [PATCH 19/25] made session secret longer for test --- .env.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.test b/.env.test index f944252..ef605de 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,3 @@ # DO NOT PUT REAL SECRETS IN THIS FILE. THIS FILE IS FOR TESTING PURPOSES ONLY. DATABASE_URL_TEST="file:./.tmp/test.db" -SESSION_SECRET="test-secret" \ No newline at end of file +SESSION_SECRET="THIS_IS_A_TEST_SESSION_SECRET" \ No newline at end of file From f48a74aa179ede9f82024543c28ca26ec57ed753 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 03:25:32 +1100 Subject: [PATCH 20/25] another workflows fix attempt --- .env.test | 3 --- .github/workflows/ci.yml | 18 ++++++++++++++++++ apps/backend/src/env.ts | 37 ++++++++---------------------------- apps/backend/test/loadEnv.ts | 17 ----------------- apps/backend/test/setup.ts | 16 ++++++++++------ 5 files changed, 36 insertions(+), 55 deletions(-) delete mode 100644 .env.test delete mode 100644 apps/backend/test/loadEnv.ts diff --git a/.env.test b/.env.test deleted file mode 100644 index ef605de..0000000 --- a/.env.test +++ /dev/null @@ -1,3 +0,0 @@ -# DO NOT PUT REAL SECRETS IN THIS FILE. THIS FILE IS FOR TESTING PURPOSES ONLY. -DATABASE_URL_TEST="file:./.tmp/test.db" -SESSION_SECRET="THIS_IS_A_TEST_SESSION_SECRET" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94da84c..0d20ebb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,21 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: gameswipe + POSTGRES_PASSWORD: gameswipe + POSTGRES_DB: gameswipe_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U gameswipe -d gameswipe_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout uses: actions/checkout@v4 @@ -34,6 +49,9 @@ jobs: - name: Test run: npm run test + env: + DATABASE_URL: postgresql://gameswipe:gameswipe@localhost:5432/gameswipe_test + SESSION_SECRET: "0123456789abcdef0123456789abcdef" - name: Build run: npm run build diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index d65505c..7db7d97 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -1,39 +1,18 @@ -import fs from "node:fs"; import path from "node:path"; import dotenv from "dotenv"; import { z } from "zod"; -const repoRoot = path.resolve(__dirname, "../../.."); -const dotenvPath = path.join(repoRoot, ".env"); -const dotenvTestPath = path.join(repoRoot, ".env.test"); - -const chosenEnvPath = fs.existsSync(dotenvPath) ? dotenvPath : dotenvTestPath; - dotenv.config({ - path: chosenEnvPath, + path: path.resolve(__dirname, "../../..", ".env"), override: true }); -const EnvSchema = z - .object({ - PORT: z.coerce.number().default(3000), - - DATABASE_URL: z.string().min(1).optional(), - DATABASE_URL_TEST: z.string().min(1).optional(), - - SESSION_SECRET: z.string().min(16), - SESSION_COOKIE_NAME: z.string().default("gs_session"), - SESSION_TTL_DAYS: z.coerce.number().default(30) - }) - .superRefine((val, ctx) => { - if (!val.DATABASE_URL && !val.DATABASE_URL_TEST) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["DATABASE_URL"], - message: - "Missing database URL. Set DATABASE_URL in .env, or (if .env does not exist) set DATABASE_URL_TEST in .env.test." - }); - } - }); +const EnvSchema = z.object({ + PORT: z.coerce.number().default(3000), + DATABASE_URL: z.string().min(1), + SESSION_SECRET: z.string().min(16), + SESSION_COOKIE_NAME: z.string().default("gs_session"), + SESSION_TTL_DAYS: z.coerce.number().default(30) +}); export const env = EnvSchema.parse(process.env); diff --git a/apps/backend/test/loadEnv.ts b/apps/backend/test/loadEnv.ts deleted file mode 100644 index d480b8b..0000000 --- a/apps/backend/test/loadEnv.ts +++ /dev/null @@ -1,17 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { config as dotenvConfig } from "dotenv"; - -export function loadTestEnvPreferDotenv() { - const repoRoot = path.resolve(__dirname, "..", "..", ".."); - const dotenvPath = path.join(repoRoot, ".env"); - const dotenvTestPath = path.join(repoRoot, ".env.test"); - - if (fs.existsSync(dotenvPath)) { - dotenvConfig({ path: dotenvPath, override: false }); - return { used: ".env" as const, path: dotenvPath }; - } - - dotenvConfig({ path: dotenvTestPath, override: false }); - return { used: ".env.test" as const, path: dotenvTestPath }; -} diff --git a/apps/backend/test/setup.ts b/apps/backend/test/setup.ts index 05b51f8..f44f830 100644 --- a/apps/backend/test/setup.ts +++ b/apps/backend/test/setup.ts @@ -1,13 +1,17 @@ -import fs from "node:fs"; import path from "node:path"; +import dotenv from "dotenv"; import { beforeAll } from "vitest"; -import { loadTestEnvPreferDotenv } from "./loadEnv"; -const loaded = loadTestEnvPreferDotenv(); +dotenv.config({ + path: path.resolve(__dirname, "../../..", ".env"), + override: true +}); beforeAll(() => { - if (loaded.used === ".env.test") { - const tmpDir = path.resolve(process.cwd(), ".tmp"); - fs.mkdirSync(tmpDir, { recursive: true }); + const testUrl = process.env.DATABASE_URL_TEST; + if (typeof testUrl !== "string" || testUrl.length === 0) { + throw new Error("DATABASE_URL_TEST is missing. Add DATABASE_URL_TEST to the repo root .env"); } + + process.env.DATABASE_URL = testUrl; }); From d5116f5acda10ab99cd9810bb272eecd7d65a03a Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 03:30:43 +1100 Subject: [PATCH 21/25] added test url to ci --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d20ebb..5074fea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,8 @@ jobs: run: npm run test env: DATABASE_URL: postgresql://gameswipe:gameswipe@localhost:5432/gameswipe_test + DATABASE_URL_TEST: postgresql://gameswipe:gameswipe@localhost:5432/gameswipe_test SESSION_SECRET: "0123456789abcdef0123456789abcdef" - name: Build - run: npm run build + run: npm run build \ No newline at end of file From 3210c52a0732b3dd2050147d5874a7aa0da4af6b Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 03:32:39 +1100 Subject: [PATCH 22/25] tiny format fix --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5074fea..4789af0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,4 +55,4 @@ jobs: SESSION_SECRET: "0123456789abcdef0123456789abcdef" - name: Build - run: npm run build \ No newline at end of file + run: npm run build From 9af38fad74539603de06ef0a7d57f9480c9059ac Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 03:36:29 +1100 Subject: [PATCH 23/25] determine ssl mode --- apps/backend/src/db.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/db.ts b/apps/backend/src/db.ts index 355bc80..4f68975 100644 --- a/apps/backend/src/db.ts +++ b/apps/backend/src/db.ts @@ -1,9 +1,12 @@ import { Pool } from "pg"; import { env } from "./env"; +const shouldUseSsl = + process.env.PGSSLMODE === "require" || + process.env.DATABASE_URL?.includes("sslmode=require") || + process.env.NODE_ENV === "production"; + export const pool = new Pool({ connectionString: env.DATABASE_URL, - ssl: { - rejectUnauthorized: false - } -}); + ssl: shouldUseSsl ? { rejectUnauthorized: false } : false, +}); \ No newline at end of file From 0a2dd9fca8c722788580440a03d966de44b15ef5 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 03:38:10 +1100 Subject: [PATCH 24/25] format fix --- apps/backend/src/db.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/db.ts b/apps/backend/src/db.ts index 4f68975..5a915bc 100644 --- a/apps/backend/src/db.ts +++ b/apps/backend/src/db.ts @@ -8,5 +8,5 @@ const shouldUseSsl = export const pool = new Pool({ connectionString: env.DATABASE_URL, - ssl: shouldUseSsl ? { rejectUnauthorized: false } : false, -}); \ No newline at end of file + ssl: shouldUseSsl ? { rejectUnauthorized: false } : false +}); From ec5eb98e266e2e932a8ad2d76e76204a5cff2a59 Mon Sep 17 00:00:00 2001 From: Ryan Yensch Date: Sat, 21 Feb 2026 03:45:00 +1100 Subject: [PATCH 25/25] added psql to workflows --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4789af0..b111dbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,14 @@ jobs: - name: Lint run: npm run lint + - name: Install psql + run: sudo apt-get update && sudo apt-get install -y postgresql-client + + - name: Migrate DB (apply SQL) + run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f apps/backend/migrations/001_init.sql + env: + DATABASE_URL: postgresql://gameswipe:gameswipe@localhost:5432/gameswipe_test + - name: Test run: npm run test env: