From 76a8ad5ba495367872c9dfb56b487efd3f00903a Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sun, 16 Aug 2026 15:52:25 +0000 Subject: [PATCH 1/2] Add repo documentation and fix Dependabot and CodeQL alerts Document the monorepo (packages, sites, operations) and close the open security findings: drop unpatched image-size, bump sanitize-html, PostCSS, and brace-expansion, replace polynomial markup regexes, and strip CR/LF from CSP and Stripe logs. Ignore cursor/* in the feature-to-dev auto-PR workflow. Co-authored-by: Aamogh --- .github/workflows/feature-to-dev-pr.yml | 4 + CONTRIBUTING.md | 5 + GCP_SETUP.md | 2 + README.md | 217 ++++-------------- docs/README.md | 37 +++ docs/architecture.md | 114 +++++++++ docs/contributing.md | 71 ++++++ docs/getting-started.md | 117 ++++++++++ docs/glossary.md | 23 ++ docs/operations/ci-cd.md | 46 ++++ docs/operations/deployment.md | 66 ++++++ docs/operations/environment.md | 79 +++++++ docs/operations/security.md | 87 +++++++ docs/operations/testing.md | 62 +++++ docs/packages/api.md | 121 ++++++++++ docs/packages/auth.md | 70 ++++++ docs/packages/db.md | 149 ++++++++++++ docs/packages/ui.md | 35 +++ docs/sites/hacklytics2027.md | 61 +++++ docs/sites/mainweb.md | 95 ++++++++ docs/tooling.md | 31 +++ package.json | 4 +- packages/api/README.md | 11 + packages/api/package.json | 3 +- packages/api/src/routers/user.ts | 10 +- .../api/src/services/image-dimensions.test.ts | 81 +++++++ packages/api/src/services/image-dimensions.ts | 140 +++++++++++ packages/api/src/trpc.ts | 140 +++++++++-- packages/auth/README.md | 10 + packages/db/README.md | 12 + packages/ui/README.md | 11 + pnpm-lock.yaml | 50 ++-- sites/hacklytics2027/README.md | 37 +-- sites/hacklytics2027/package.json | 2 +- sites/mainweb/README.md | 32 +-- .../app/(portal)/api/webhooks/stripe/route.ts | 12 +- sites/mainweb/app/api/csp-report/route.ts | 11 +- sites/mainweb/package.json | 4 +- tooling/eslint/README.md | 15 +- tooling/prettier/README.md | 11 + tooling/tailwind/README.md | 9 + tooling/tailwind/package.json | 2 +- tooling/typescript/README.md | 9 + 43 files changed, 1822 insertions(+), 286 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/contributing.md create mode 100644 docs/getting-started.md create mode 100644 docs/glossary.md create mode 100644 docs/operations/ci-cd.md create mode 100644 docs/operations/deployment.md create mode 100644 docs/operations/environment.md create mode 100644 docs/operations/security.md create mode 100644 docs/operations/testing.md create mode 100644 docs/packages/api.md create mode 100644 docs/packages/auth.md create mode 100644 docs/packages/db.md create mode 100644 docs/packages/ui.md create mode 100644 docs/sites/hacklytics2027.md create mode 100644 docs/sites/mainweb.md create mode 100644 docs/tooling.md create mode 100644 packages/api/README.md create mode 100644 packages/api/src/services/image-dimensions.test.ts create mode 100644 packages/api/src/services/image-dimensions.ts create mode 100644 packages/auth/README.md create mode 100644 packages/db/README.md create mode 100644 packages/ui/README.md create mode 100644 tooling/prettier/README.md create mode 100644 tooling/tailwind/README.md create mode 100644 tooling/typescript/README.md diff --git a/.github/workflows/feature-to-dev-pr.yml b/.github/workflows/feature-to-dev-pr.yml index 8a61dfc4..1d2fe452 100644 --- a/.github/workflows/feature-to-dev-pr.yml +++ b/.github/workflows/feature-to-dev-pr.yml @@ -6,6 +6,10 @@ on: - main - dev - "dependabot/**" + # Cloud-agent branches already open a PR into main as the human owner. + # Auto-opening a second PR into dev (authored by github-actions, titled + # with the cursor/ prefix) duplicates review and is not wanted. + - "cursor/**" jobs: create-pull-request: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..cd839582 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +See [docs/contributing.md](./docs/contributing.md) for branching, scripts, tests, and schema-change rules. + +Documentation index: [docs/README.md](./docs/README.md). diff --git a/GCP_SETUP.md b/GCP_SETUP.md index bd730a9f..32e9083b 100644 --- a/GCP_SETUP.md +++ b/GCP_SETUP.md @@ -2,6 +2,8 @@ This guide explains how to set up your local environment to work with the GCP-hosted backend (Firebase App Hosting + Secret Manager). +Related: [docs/operations/deployment.md](./docs/operations/deployment.md), [docs/operations/environment.md](./docs/operations/environment.md), [docs/getting-started.md](./docs/getting-started.md). + ## 1. Prerequisites Install the following CLI tools: diff --git a/README.md b/README.md index edaeb8e3..78acdc68 100644 --- a/README.md +++ b/README.md @@ -1,188 +1,69 @@ # query -The central monorepo for club operations and digital infrastructure. +The central monorepo for Data Science at Georgia Tech club operations and digital infrastructure. -## Workspace layout +Two Next.js sites share one Postgres database and four internal packages. Club membership, events, bootcamp, and initiatives are modeled separately from hackathon editions, registration, teams, and judging — they share a database and touch nowhere. -| Path | Contents | -| --- | --- | -| `sites/mainweb` | Public club site | -| `sites/hacklytics2027` | Hacklytics 2027 event site (static export) | -| `packages/db` | Drizzle schema, client, seed script | -| `packages/api` | tRPC routers | -| `packages/auth` | NextAuth configuration | -| `packages/ui`, `packages/consts` | Shared components and constants | -| `tooling/*` | Shared eslint / tailwind / tsconfig | +**Documentation:** start at [`docs/README.md`](./docs/README.md). -## Database +## Workspace layout -Postgres, accessed through [Drizzle ORM](https://orm.drizzle.team). Production -runs on **Neon** (serverless Postgres, `us-west-2`, pooled endpoint); the -connection is made with `pg.Pool` in `packages/db/src/client.ts`, with SSL -required in production and a max pool size of 10. +| Path | Workspace | Role | +| --- | --- | --- | +| `sites/mainweb` | `web` | Public club site + authenticated portal (App Hosting) | +| `sites/hacklytics2027` | `hacklytics2027` | Hacklytics 2027 marketing site, static export (Firebase Hosting) | +| `packages/api` | `@query/api` | tRPC routers, middleware, pricing | +| `packages/auth` | `@query/auth` | NextAuth (Google, GitHub, email codes) | +| `packages/db` | `@query/db` | Drizzle schema, client, membership rules | +| `packages/ui` | `@query/ui` | Shared React components | +| `tooling/*` | `@query/eslint-config`, `@query/prettier-config`, `@query/tailwind-config`, `@query/tsconfig` | Shared configs | -Configuration is a single environment variable: +## Quick start -``` -DATABASE_URL=postgresql://:@/?sslmode=require +```bash +corepack enable +pnpm install +docker compose up -d +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb \ + pnpm --filter @query/db migrate:push +pnpm dev ``` -`packages/db/src/client.ts` logs a warning and leaves `db` as `null` when the -variable is absent rather than throwing, so builds that never touch the database -still succeed. - -### Schema - -Schemas live in `packages/db/src/schemas/` and are re-exported from -`schemas/index.ts`. Drizzle picks them up via `schema: "./src/schemas/**/*.ts"` -in `drizzle.config.ts`. - -| File | Tables | -| --- | --- | -| `auth.ts` | `user`, `account`, `session`, `verificationToken` | -| `members.ts` | `user_profile`, `member`, `membership_history` | -| `admins.ts` | `admin` | -| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_event`, `hackathon_event_attendee` | -| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue` | -| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` | -| `events.ts` | `event`, `event_check_in` | -| `stripe.ts` | `stripe_payment`, `user_account_link` | -| `security.ts` | `audit_logs` (+ `security_severity` enum) | -| `settings.ts` | `system_settings` | - -Two entities anchor the graph: - -- **`user`** — every identity-bearing table cascades from it: `account`, - `session`, `admin`, `user_profile`, `member`, `judge`, `event`, - `event_check_in`, `hackathon_team`, `hackathon_participant`, - `user_account_link`, and `stripe_payment.linked_user_id`. -- **`hackathon`** — every event-scoped table cascades from it: teams, - participants, projects, hackathon events, judges, judge assignments, judging - projects, judge queue, and maps. - -Nearly all foreign keys are `onDelete: "cascade"`, so deleting a user or a -hackathon removes its dependent rows rather than orphaning them. - -### Club and hackathon are separate - -Two aspects share the database and touch nowhere: - -- **Hackathon** — editions, registration, teams, project submission, judging. - Everything here hangs off a `hackathon` row. -- **Club** — `member`, `membership_history`, `event`, `event_check_in`, - `initiative`, its applications, and the `project_leader` role. Deliberately - **not** scoped to a hackathon. A club project runs whenever somebody leads - one, and leading is a standing appointment rather than a yearly re-grant. - Nothing in this half is ever judged; judges only score `hackathon_project`. - -The two halves no longer cross. `member` used to be `unique(user_id, -hackathon_id)`, which welded a paid year to an edition: the day the next -hackathon opened, every paying member read as a non-member. It is now -`unique(user_id)` and a membership is defined entirely by its own dates, with -`membership_history` recording which years somebody held one. The club half -therefore works with no hackathon in the database at all. - -#### One-off step — only for a database that already has the edition-scoped tables - -**Check first:** - -```sql -SELECT to_regclass('public.project_leader'); -``` +- Club site + portal: [http://localhost:3001](http://localhost:3001) +- Hacklytics 2027: [http://localhost:3000](http://localhost:3000) -If that returns `NULL`, this database has never had the club tables. Skip -everything below — `migrate:push` simply creates them in the current shape, and -the statements here would error on tables that do not exist. - -If it returns a table name, `migrate:push` cannot work the change out on its -own. `project_leader` moved from `unique(user_id, hackathon_id)` to -`unique(user_id)`, so anybody appointed in more than one edition has more than -one row; drizzle-kit fails building the new index partway and leaves the schema -half-applied. Run this against that database **once, before** the push. Every -statement is guarded, so it is safe to re-run. - -```sql -BEGIN; - --- Collapse duplicate leader appointments to one row per person. Keeps the --- oldest row, so created_at still reads as when they were first appointed, and --- keeps the role switched on if ANY of their rows was active — dropping an --- active appointment here silently locks a leader out of their own initiatives. -WITH ranked AS ( - SELECT - id, - user_id, - bool_or(is_active) OVER (PARTITION BY user_id) AS any_active, - row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn - FROM project_leader -) -UPDATE project_leader AS pl -SET is_active = ranked.any_active -FROM ranked -WHERE pl.id = ranked.id - AND ranked.rn = 1 - AND pl.is_active IS DISTINCT FROM ranked.any_active; - -DELETE FROM project_leader -WHERE id IN ( - SELECT id FROM ( - SELECT - id, - row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn - FROM project_leader - ) dupes - WHERE rn > 1 -); - --- Drop the edition columns and everything hanging off them. -ALTER TABLE project_leader - DROP CONSTRAINT IF EXISTS unique_project_leader_per_hackathon; -DROP INDEX IF EXISTS project_leader_hackathon_id_idx; -ALTER TABLE project_leader DROP COLUMN IF EXISTS hackathon_id; - -DROP INDEX IF EXISTS initiative_hackathon_id_idx; -ALTER TABLE initiative DROP COLUMN IF EXISTS hackathon_id; - --- The constraint the new schema expects. Added here rather than left to push, --- so a collision surfaces inside this transaction where it rolls back. -ALTER TABLE project_leader - DROP CONSTRAINT IF EXISTS unique_project_leader; -ALTER TABLE project_leader - ADD CONSTRAINT unique_project_leader UNIQUE (user_id); - -COMMIT; -``` +Full setup, env vars, and first-admin bootstrap: [docs/getting-started.md](./docs/getting-started.md). -Initiatives themselves are untouched. Rows that were invisible because they -belonged to a past edition become visible again — that is the point, they were -club projects an edition rollover hid. Archive any that should not come back -from the leader screen afterwards. +```bash +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` -### Working with the schema +## Architecture (short) -```bash -pnpm --filter @query/db migrate:push # push schema changes to DATABASE_URL -pnpm --filter @query/db migrate:generate # emit SQL into packages/db/drizzle -pnpm --filter @query/db studio # Drizzle Studio -pnpm --filter @query/db db:seed # scripts/seed.ts +``` +hacklytics2027 (static) ──interest CTA──► mainweb portal + │ + @query/api ◄──session──► @query/auth + │ + @query/db → Neon / local Postgres ``` -The project is **push-based**: `packages/db/drizzle/meta/_journal.json` has no -entries and there are no generated `.sql` files, so schema changes are applied -directly with `migrate:push` rather than through a migration history. If you -want reviewable migrations, switch to `migrate:generate` and commit the output. +The portal is a route group inside `sites/mainweb`, not a separate app. The event site does not query the database. -### Local database +Details: [docs/architecture.md](./docs/architecture.md). Schema and the club/hackathon split: [docs/packages/db.md](./docs/packages/db.md). -`docker-compose.yml` brings up a local Postgres with the same database name as -Neon, so only `DATABASE_URL` changes between the two: +## Deploy -```bash -docker compose up -d -DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb \ - pnpm --filter @query/db migrate:push -``` +| Surface | Platform | Config | +| --- | --- | --- | +| `web` | Firebase App Hosting / Cloud Run | `apphosting.yaml` | +| `hacklytics2027` | Firebase Hosting target `hacklytics` | `firebase.json` | + +GCP project: `dsgt-website`. Local secret sync: [GCP_SETUP.md](./GCP_SETUP.md). Operations: [docs/operations/deployment.md](./docs/operations/deployment.md). + +## License -It publishes on host port **5433** to avoid colliding with a system Postgres, -and has a `pg_isready` healthcheck so `migrate:push` is not run against a -container that is still starting. +Apache License 2.0. See [LICENSE](./LICENSE). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..1544941c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# Documentation + +This folder is the reference for **query**, the Data Science at Georgia Tech (DSGT) monorepo for club operations and digital infrastructure. + +Start here, then jump to the page that matches the work you are doing. + +| Document | What it covers | +| --- | --- | +| [Getting started](./getting-started.md) | Prerequisites, local Postgres, env vars, first `pnpm dev` | +| [Architecture](./architecture.md) | How the two sites and four packages fit together | +| [Contributing](./contributing.md) | Branches, scripts, tests, and review expectations | +| [Environment variables](./operations/environment.md) | Every env var the process actually reads | +| [Deployment](./operations/deployment.md) | Firebase App Hosting, Firebase Hosting, GCP secrets | +| [CI/CD](./operations/ci-cd.md) | GitHub Actions, Dependabot, branch automation | +| [Security](./operations/security.md) | Auth gates, rate limits, CSP, input scrubbing | +| [Testing](./operations/testing.md) | Vitest, Playwright, and what each suite protects | +| [Glossary](./glossary.md) | Club vs hackathon vocabulary | + +## Packages + +| Document | Workspace | Role | +| --- | --- | --- | +| [API](./packages/api.md) | `@query/api` | tRPC routers, middleware, pricing | +| [Auth](./packages/auth.md) | `@query/auth` | NextAuth, providers, mailer | +| [Database](./packages/db.md) | `@query/db` | Drizzle schema, client, membership rules | +| [UI](./packages/ui.md) | `@query/ui` | Shared React components and styles | + +## Sites + +| Document | Workspace | Role | +| --- | --- | --- | +| [Main website](./sites/mainweb.md) | `web` | Public club site plus the authenticated portal | +| [Hacklytics 2027](./sites/hacklytics2027.md) | `hacklytics2027` | Static event marketing site | + +## Tooling + +Shared ESLint, Prettier, Tailwind, and TypeScript configs live under [`tooling/`](./tooling.md). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..f91c56f5 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,114 @@ +# Architecture + +**query** is a pnpm + Turborepo monorepo. Two Next.js sites share four internal packages. Club operations and hackathon operations share one Postgres database but are modeled as separate domains. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ sites/hacklytics2027 │ +│ Static marketing site (Firebase Hosting) │ +│ Interest CTA → portal /login?callbackUrl=/hacklytics│ +└────────────────────────────┬────────────────────────────────┘ + │ absolute URL +┌────────────────────────────▼────────────────────────────────┐ +│ sites/mainweb (web) │ +│ Public pages Portal route group `(portal)` │ +│ / /team /events /login /dashboard /admin /judge … │ +│ │ │ +│ /api/trpc /api/auth /api/webhooks │ +└──────────┬──────────────────┴───────────────┬───────────────┘ + │ │ + ┌──────▼──────┐ ┌──────▼──────┐ + │ @query/api │◄──── session ──────│ @query/auth │ + │ tRPC app │ │ NextAuth │ + └──────┬──────┘ └──────┬──────┘ + │ │ + └──────────────┬───────────────────┘ + │ + ┌──────▼──────┐ + │ @query/db │ Drizzle + pg.Pool → Neon / local Postgres + └─────────────┘ +``` + +`@query/ui` is a small shared component library consumed by mainweb. It is not on the request path. + +## Two products, one database + +The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. + +### Club + +Year-round DSGT operations. **Not** scoped to a hackathon row. + +- Membership (`member`, `membership_history`) — one paid year per person, defined by start/end dates +- Club events and QR check-in (`event`, `event_check_in`) +- Bootcamp sessions are club events with `bootcamp_week` + `bootcamp_term` +- Initiatives (`initiative`, `initiative_application`) led by `project_leader` +- Stripe payments and account linking + +Club benefits (portal `/club`, bootcamp, initiatives) gate on a **paid, unexpired** membership. A `member` row that has lapsed is not treated as active. + +### Hackathon + +Edition-scoped event operations. Everything hangs off `hackathon`. + +- Editions, interest list, registration, teams, project submission +- Weekend schedule (`hackathon_event`) and badge scans +- Judging (`judge`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result`) +- Announcements and acceptance waves + +Hackathon participation is **open to non-members**. Membership is not a registration requirement. + +`resolveCurrentHackathonId` (in `@query/db`) is the single definition of “the current edition”: an in-progress event if one exists, otherwise the newest edition whose status is not `draft` or `announced`. Drafting next year must not retarget memberships, portal gates, or club check-in. + +## Request path (mainweb) + +1. Next.js App Router in `sites/mainweb`. +2. `proxy.ts` sets Cache-Control (private `no-store` on authenticated prefixes). It does not mint ETags. +3. Browser calls `/api/trpc/*` via `@trpc/react-query`. Superjson is the transformer. +4. `createContext` loads the NextAuth session (when `db` exists), attaches `userId`, client IP, and the in-process cache. +5. Procedures run through DB-required, sanitizer, content-type, rate-limit / DDoS, and (for mutations) cache invalidation middleware. +6. Role gates (`isAdmin`, `isScanner`, `isJudge`, `isProjectLeader`, `isSuperAdmin`) live in `packages/api/src/middleware/procedures.ts`. There is no `adminProcedure` alias that skips a role check. + +## Auth + +NextAuth v5 (`next-auth@5` beta) with a **database session** strategy when `DATABASE_URL` is set, otherwise JWT. + +Providers: + +- Google (always registered; PKCE + state) +- GitHub (only if both client id and secret are set) +- Email 6-digit code via nodemailer (CSPRNG, 10-minute TTL, previous codes deleted) + +On every successful sign-in, `linkPaidPaymentByVerifiedEmail` claims a paid-but-unlinked Stripe payment for that verified address and grants membership. Failures are swallowed so a membership glitch cannot block login. + +## Payments + +Stripe Checkout and Payment Intents both exist. Amounts are defined once in `@query/api` pricing: + +| Product | Cents | +| --- | --- | +| Annual membership | `2500` ($25) | +| Bootcamp add-on (on top of membership) | `1000` ($10) | +| Max charge treated as membership | `10000` | + +The webhook is `sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts`. Linking can also happen from the portal (`stripe.linkAccount`, `stripe.attemptAutoLink`) and at sign-in. + +## Caching + +`packages/api/src/middleware/cache.ts` is an **in-memory TTL cache** per Node process (not Redis). Role lookups and portal context are cached ~60s. Mutations evict by a path → glob map in `trpc.ts`; unmapped mutations fall back to namespace eviction. This is per-instance: Cloud Run concurrency 80 shares one cache; extra instances do not share it. + +## Deployment split + +| Surface | Where it runs | Output | +| --- | --- | --- | +| `sites/mainweb` | Firebase App Hosting / Cloud Run (`apphosting.yaml`) | Next `standalone` | +| `sites/hacklytics2027` | Firebase Hosting target `hacklytics` | Static `output: "export"` | + +The event site does not talk to the database. Interest and registration live on the portal; the marketing site links to `/login?callbackUrl=/hacklytics`. + +## What is not in this repo + +- `packages/consts` is mentioned in older notes and is **not** a workspace today. +- `apps/*` is listed in `pnpm-workspace.yaml` but there is no `apps/` directory. +- `graphify-out/` is generated graph output, not product code. +- `trust badge/` holds MLH league badge SVGs for the event site. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..8f731d59 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,71 @@ +# Contributing + +## Branching + +Long-lived branches: + +- `main` — production. App Hosting and Firebase Hosting deploys fire from here. +- `dev` — integration. Pushes open (or refresh) an automated PR into `main`. + +Feature branches (anything other than `main`, `dev`, or `dependabot/**`) get an automated PR into `dev`. After `main` moves, `sync-main-to-branches.yml` merges `main` into `feature/*`, `fix/*`, `rework/*`, `refactor/*`, and `hackaton/*` when there is no conflict. + +Name branches so [labeler](../.github/labeler.yml) can tag the PR: `feature/…`, `fix/…`, `docs/…`, `chore/…`. + +Code owners: `.github/CODEOWNERS` assigns `*` to `@aamoghS`. + +## Making changes + +1. Branch from `dev` unless you are fixing production. +2. Keep club and hackathon concerns separate. Do not add `hackathon_id` to club tables. +3. Put shared rules in the package that every caller can import. Membership grant/link logic belongs in `@query/db/services/membership`, not copied into auth, Stripe, and tRPC. +4. Role checks go through `isAdmin` / `isScanner` / `isJudge` / `isProjectLeader` / `isSuperAdmin`. Do not invent an `adminProcedure` that is only `protectedProcedure`. +5. Dangerous HTML in tRPC input is **rejected**, not stripped. See [Security](./operations/security.md). +6. Prices live in `packages/api/src/services/pricing.ts`. Do not hard-code dollar amounts in UI or Stripe calls. + +## Scripts + +From the repo root: + +```bash +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` + +Lint is `--max-warnings 0`. Fix warnings rather than raising the cap. + +Format: + +```bash +pnpm format +``` + +## Tests + +See [Testing](./operations/testing.md). At minimum, run `pnpm test` before opening a PR. API suites live next to routers and under `packages/api/src/.internal-tests/`. + +Hacklytics end-to-end: + +```bash +pnpm --filter hacklytics2027 e2e +``` + +## Schema changes + +1. Edit files in `packages/db/src/schemas/`. +2. `pnpm --filter @query/db migrate:push` against a database you are allowed to change. +3. `pnpm --filter @query/db db:check` confirms every declared column exists (this is the App Hosting build gate). +4. Prefer `migrate:generate` if you want reviewable SQL in `packages/db/drizzle/`. + +Destructive changes abort on App Hosting because `drizzle-kit push` is fed `/dev/null` and cannot confirm. Plan those separately. + +Unique indexes, cascade behavior, and “current hackathon” resolution have bitten this product before. Read the comments on the table you are touching. + +## Workspace protocol + +Internal packages use `workspace:*`. `restore-workspace.js` rewrites accidental `"*"` versions back to `workspace:*` if a tool flattened them. + +## Issues + +Use [`.github/ISSUE_TEMPLATE/bug_report.md`](../.github/ISSUE_TEMPLATE/bug_report.md) for bugs. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..07ad9c4b --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,117 @@ +# Getting started + +This guide gets a local copy of **query** running: Postgres, schema, env, and both Next.js sites. + +## Prerequisites + +- **Node.js** `>=20.16.0 <24` (`.nvmrc` pins `20`; CI also uses 20 and 22) +- **pnpm** `10.33.2` (see `packageManager` in the root `package.json`) +- **Docker** (for local Postgres) +- Optional: **gcloud** and **Firebase CLI** if you need production secrets or deploys + +Enable Corepack so the repo’s pnpm version is used: + +```bash +corepack enable +corepack prepare pnpm@10.33.2 --activate +``` + +## Install + +```bash +pnpm install +``` + +Workspaces are defined in `pnpm-workspace.yaml`: `sites/*`, `packages/*`, and `tooling/*`. + +## Local database + +Production uses Neon (serverless Postgres). Locally, `docker-compose.yml` starts Postgres 15 with the same database name (`neondb`) so only `DATABASE_URL` changes. + +```bash +docker compose up -d +``` + +It listens on host port **5433** so it does not collide with a system Postgres on 5432. Wait for the healthcheck (`pg_isready`) before pushing schema. + +```bash +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb \ + pnpm --filter @query/db migrate:push +``` + +Schema work is **push-based**. There is no committed SQL migration history. See [Database](./packages/db.md) for generate, Studio, drift checks, and the one-off club-table migration. + +## Environment + +Copy the names from [Environment variables](./operations/environment.md) into a root `.env` (and `sites/mainweb/.env.local` if you prefer Next’s local loader). The minimum to boot the portal against local Postgres: + +``` +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb +AUTH_SECRET= +NEXTAUTH_SECRET= +AUTH_URL=http://localhost:3001 +NEXTAUTH_URL=http://localhost:3001 +``` + +Google / GitHub OAuth, SMTP, and Stripe are optional for browsing public pages. Login, membership checkout, and email codes need the corresponding secrets. + +If you have GCP access to project `dsgt-website`, you can pull secrets instead of typing them. See [GCP_SETUP.md](../GCP_SETUP.md) and [Deployment](./operations/deployment.md). + +## Run + +From the repo root: + +```bash +pnpm dev +``` + +Turbo runs every workspace `dev` task. The two sites: + +| Site | URL | Notes | +| --- | --- | --- | +| Main website + portal | [http://localhost:3001](http://localhost:3001) | Next.js App Router, `output: "standalone"` | +| Hacklytics 2027 | [http://localhost:3000](http://localhost:3000) | Static-export marketing site (`--turbopack`) | + +Useful filters: + +```bash +pnpm --filter web dev # main site only +pnpm --filter hacklytics2027 dev # event site only +pnpm --filter @query/db studio # Drizzle Studio +``` + +## Common scripts + +| Command | What it does | +| --- | --- | +| `pnpm dev` | All workspace `dev` tasks | +| `pnpm build` | `turbo run build` | +| `pnpm lint` | ESLint across workspaces (`--max-warnings 0`) | +| `pnpm typecheck` | `tsc --noEmit` via Turbo | +| `pnpm test` | Vitest: `packages/api`, `packages/db`, `sites/mainweb/lib` | +| `pnpm format` | Prettier write | + +Database scripts live on `@query/db`: + +```bash +pnpm --filter @query/db migrate:push +pnpm --filter @query/db migrate:generate +pnpm --filter @query/db db:check +pnpm --filter @query/db studio +pnpm --filter @query/db db:seed +``` + +## First-admin bootstrap + +Staff roles live in the `admin` table. After signing in once (so a `user` row exists), grant yourself `super_admin` in the database, then use `/admin/staff` to appoint others. There is no public self-serve admin signup. + +## Troubleshooting + +**`DATABASE_URL not set - database operations will fail`** +The db client logs this and leaves `db` as `null` so builds that never query still succeed. Public and authenticated tRPC procedures then fail with `PRECONDITION_FAILED: Database unavailable`. Set `DATABASE_URL` and restart. + +**OAuth “State cookie was missing”** +`AUTH_URL` / `NEXTAUTH_URL` must match the origin you actually open (including port). PKCE + state checks are required; do not disable them. + +**Port already in use** +Mainweb is `--port 3001`. Hacklytics uses Next’s default 3000. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 00000000..23be0649 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,23 @@ +# Glossary + +| Term | Meaning in this repo | +| --- | --- | +| **query** | This monorepo (`package.json` name). Not a search engine. | +| **Club** | Year-round DSGT operations: membership, club events, bootcamp, initiatives. Not keyed by hackathon. | +| **Hackathon / edition** | One `hackathon` row (e.g. Hacklytics 2027) and everything that cascades from it. | +| **Hacklytics** | DSGT’s annual data-science hackathon. Marketing site is `sites/hacklytics2027`; operations are the portal. | +| **Portal** | Authenticated product UI inside `sites/mainweb` route group `(portal)`. | +| **Member** | A `member` row with a **paid, unexpired** year. A lapsed row still exists but `isMember` is false. | +| **Pass** | `member.pass_code` — rotatable QR for club check-in. Independent of membership dates. | +| **Volunteer** | Weakest `admin.role`. Can scan badges (`isScanner`). Cannot pass `isAdmin`. | +| **Staff** | Active admin whose role is not `volunteer`. | +| **Project leader** | `project_leader` row. Runs club **initiatives**. Not a staff role. | +| **Initiative** | Club project members apply to join. Never judged. Distinct from a hackathon **project**. | +| **Hackathon project** | Team/solo submission (`hackathon_project`). Promoted into `judging_project` for scoring. | +| **Interest** | “Tell me when registration opens” (`hackathon_interest`). Requires a signed-in user. | +| **Current edition** | In-progress hackathon if one exists; otherwise the newest edition that is not `draft` or `announced`. | +| **Announced** | Public landing + interest, registration closed, **not** current for membership resolution. | +| **Wave** | Batch accept of oldest pending applicants; acceptance email is stamped per participant so retries are safe. | +| **Judging queue** | Per-judge ordered tables. `startedAt` is a short claim; `arrivedAt` is the QR scan at the table. | +| **Results snapshot** | `hackathon_result` — frozen placing. Live z-score is only used when computing that snapshot. | +| **Bootcamp term** | String like `2026-fall`. Access checks this, not the never-expiring `bootcamp_member` boolean. | diff --git a/docs/operations/ci-cd.md b/docs/operations/ci-cd.md new file mode 100644 index 00000000..a0f141ca --- /dev/null +++ b/docs/operations/ci-cd.md @@ -0,0 +1,46 @@ +# CI/CD + +All workflows live in `.github/workflows/`. + +## Quality + +| Workflow | Trigger | What it does | +| --- | --- | --- | +| `pnpm-ci.yml` | Push `main`/`dev`, PRs | `pnpm install` + `pnpm turbo run build` (Node 22) | +| `test.yml` | Push `main`/`dev`, PRs | `pnpm test` (Node 20, pnpm 8 in this file — version drift vs root `pnpm@10`) | +| `codeql.yml` | Push/PR `main`/`dev`, daily 02:00 UTC | CodeQL `security-extended,security-and-quality`; PRs also run dependency review (`fail-on-severity: high`) | + +## Deploy + +| Workflow | Trigger | Target | +| --- | --- | --- | +| `deploy-hacklytics.yml` | Push `main` and PRs | Firebase Hosting `hacklytics` (live vs `pr-N`) | +| `firebase-hosting-merge.yml` | Push `main` | Same live Hacklytics deploy | +| `firebase-hosting-pull-request.yml` | PRs (same-repo only) | Hacklytics preview channel | + +Mainweb production is **Firebase App Hosting**, not these Hosting workflows. App Hosting builds from `apphosting.yaml` when the connected branch updates. + +`deploy-hacklytics.yml.disabled` is a leftover disabled copy. + +## Branch automation + +| Workflow | Behavior | +| --- | --- | +| `feature-to-dev-pr.yml` | Push to any branch except `main`/`dev`/`dependabot/**` → open PR into `dev` (reviewer/assignee `aamoghS`) | +| `dev-to-main-pr.yml` | Push to `dev` → open PR into `main` | +| `sync-main-to-branches.yml` | Push to `main` (or manual) → merge `main` into `feature/*`, `fix/*`, `rework/*`, `refactor/*`, `hackaton/*` when fast-forwardable; skip conflicts | + +`|| true` on `gh pr create` means a duplicate PR is not a failing job. + +## Housekeeping + +| Workflow / config | Behavior | +| --- | --- | +| `label.yml` | `pull_request_target` + `actions/labeler@v6` using `.github/labeler.yml` (branch prefixes + lockfile paths) | +| `dependabot.yml` | Weekly npm (root) and GitHub Actions | +| `dependabot-auto-merge.yml` | Comments `@dependabot merge` on non-major Dependabot PRs | +| `.github/pull.yml` | Additional pull-request automation config | + +## Permissions + +Deploy jobs need `contents: read` plus Hosting’s `pull-requests: write` / `checks: write` for preview comments. Branch-sync needs `contents: write`. CodeQL needs `security-events: write`. diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md new file mode 100644 index 00000000..13e7abe6 --- /dev/null +++ b/docs/operations/deployment.md @@ -0,0 +1,66 @@ +# Deployment + +Two surfaces, two platforms. + +## Main website — Firebase App Hosting + +Config: [`apphosting.yaml`](../../apphosting.yaml) at the repo root. + +| | | +| --- | --- | +| App | `sites/mainweb` (workspace `web`) | +| Runtime | Node 20 | +| Output | Next standalone (`sites/mainweb/.next/standalone/sites/mainweb/server.js`) | +| Concurrency | 80 | +| CPU / memory | 2 / 1024 MiB | +| Instances | 0–10 | +| GCP project | `dsgt-website` | + +Build command (abbreviated): + +1. `pnpm install` +2. `drizzle-kit push --verbose < /dev/null` on `@query/db` (additive only; destructive waits for a TTY confirmation that stdin cannot give) +3. `pnpm --filter @query/db db:check` — **fails the build** if a declared column is missing +4. `pnpm turbo run build --filter=web` +5. Copy `.next/static` (and `public` if present) into the standalone tree + +Run: `node sites/mainweb/.next/standalone/sites/mainweb/server.js` + +Secrets are GCP Secret Manager. Grant the App Hosting backend access once per secret, e.g. `firebase apphosting:secrets:grantaccess DATABASE_URL --backend query`. + +Env mapping (names only) is in `apphosting.yaml`: `DATABASE_URL`, `AUTH_SECRET` (also copied to `NEXTAUTH_SECRET`), Google/GitHub OAuth, Stripe, SMTP, DDoS ceilings, `TRUSTED_PROXY_HOPS=1`. + +## Hacklytics — Firebase Hosting + +Config: [`firebase.json`](../../firebase.json), [`.firebaserc`](../../.firebaserc). + +| Hosting site / target | Public directory | +| --- | --- | +| `dsgt-website` | `sites/mainweb/out` (legacy static path; **production mainweb is App Hosting**, not this) | +| target `hacklytics` | `sites/hacklytics2027/out` | + +Live deploys of Hacklytics: push to `main` runs `pnpm turbo run build --filter=hacklytics2027` then `FirebaseExtended/action-hosting-deploy` with `channelId: live` and `target: hacklytics`. Pull requests get preview channels `pr-`. + +Service account secret: `FIREBASE_SERVICE_ACCOUNT_DSGT_WEBSITE`. + +## Local GCP access + +See [GCP_SETUP.md](../../GCP_SETUP.md): + +```bash +gcloud auth login +gcloud auth application-default login +firebase login +gcloud config set project dsgt-website +firebase use dsgt-website +``` + +`./scripts/sync-secrets.sh` is referenced there for pulling Secret Manager values into `.env.local`. If that script is not in the tree, copy secrets from Secret Manager manually or recreate the script. + +## Docker + +`.dockerignore` excludes git, `node_modules`, env files, markdown (except README), and Firebase metadata from an image build context. There is no root `Dockerfile` in the current tree; App Hosting builds from `apphosting.yaml`. + +## First production admin + +Sign in once, then insert an `admin` row with `role = 'super_admin'` and `is_active = true` for that `user_id`. Further staff are appointed from `/admin/staff`. diff --git a/docs/operations/environment.md b/docs/operations/environment.md new file mode 100644 index 00000000..79af41a5 --- /dev/null +++ b/docs/operations/environment.md @@ -0,0 +1,79 @@ +# Environment variables + +Names the process actually reads. Do not commit values. App Hosting maps many of these from GCP Secret Manager in `apphosting.yaml`. + +Turbo `globalEnv` lists the ones that must invalidate the build cache when they change. + +## Required for a working portal + +| Variable | Used by | Notes | +| --- | --- | --- | +| `DATABASE_URL` | `@query/db` | Neon pooled URL in prod; local `postgresql://postgres:postgres@localhost:5433/neondb` | +| `AUTH_SECRET` / `NEXTAUTH_SECRET` | NextAuth | App Hosting sets both from secret `AUTH_SECRET` | +| `AUTH_URL` / `NEXTAUTH_URL` | NextAuth | Public origin. Must match the host users open | + +Without `DATABASE_URL`, `db` is null, sessions fall back to JWT, and tRPC procedures that require DB fail with `PRECONDITION_FAILED`. + +## OAuth + +| Variable | Provider | +| --- | --- | +| `GOOGLE_CLIENT_ID` | Google (App Hosting secret `AUTH_GOOGLE_ID`) | +| `GOOGLE_CLIENT_SECRET` | Google (`AUTH_GOOGLE_SECRET`) | +| `GITHUB_CLIENT_ID` | GitHub (`AUTH_GITHUB_ID`) — optional; both GitHub vars required to register the provider | +| `GITHUB_CLIENT_SECRET` | GitHub (`AUTH_GITHUB_SECRET`) | + +## Email + +| Variable | Default / notes | +| --- | --- | +| `EMAIL_SERVER_HOST` | SMTP host | +| `EMAIL_SERVER_PORT` | `587` | +| `EMAIL_SERVER_USER` | SMTP username | +| `EMAIL_SERVER_PASSWORD` | Secret | +| `EMAIL_FROM` | From address; must be verified with the provider | +| `EMAIL_MAX_CONNECTIONS` | `5` | +| `EMAIL_MAX_MESSAGES` | `100` | + +## Stripe + +| Variable | Notes | +| --- | --- | +| `STRIPE_SECRET_KEY` | Server | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Browser Payment Element | +| `STRIPE_WEBHOOK_SECRET` | Webhook signature | +| `STRIPE_MOCK_MODE` | Test helper; listed in Turbo `globalEnv` | + +## Database pool + +| Variable | Default | +| --- | --- | +| `DB_POOL_MAX` | `20` | +| `DB_CONNECTION_TIMEOUT_MS` | `3000` | + +## Security / proxy + +| Variable | Default / notes | +| --- | --- | +| `TRUSTED_PROXY_HOPS` | App Hosting sets `1` (Cloud Run behind Google LB). Increment if a CDN is added. Process logs `[Security] x-forwarded-for has N entries` at startup; hops should be `entries - 1` | +| `DDOS_MAX_REQUESTS_PER_MINUTE` | App Hosting `20000` | +| `DDOS_SUSPICIOUS_THRESHOLD` | `14000` | +| `DDOS_BLOCK_DURATION_MS` | `30000` | +| `DDOS_BURST_THRESHOLD` | `3000` | +| `DDOS_BURST_WINDOW_MS` | Code default if unset | +| `DDOS_CLEANUP_INTERVAL_MS` | Code default if unset | +| `CSP_ENFORCE` | `true` turns CSP from Report-Only into enforcing. Default is report-only | + +## Runtime (App Hosting) + +| Variable | Value | +| --- | --- | +| `PORT` | `8080` | +| `HOSTNAME` | `0.0.0.0` | +| `NODE_ENV` | `production` | + +`GCP_SETUP.md` mentions `RESEND_API_KEY`; the mailer in this repo uses SMTP (`EMAIL_SERVER_*`), not Resend. + +## Local files + +`drizzle.config.ts` loads **root** `.env` via dotenv. Next.js also loads `sites/mainweb/.env.local`. Keep `DATABASE_URL` consistent in both if you use both files. diff --git a/docs/operations/security.md b/docs/operations/security.md new file mode 100644 index 00000000..8fc656e4 --- /dev/null +++ b/docs/operations/security.md @@ -0,0 +1,87 @@ +# Security + +This page is the map of controls already in the product. It is not a pentest report. + +## Authentication and sessions + +- Database sessions when Postgres is available; 30-day max age +- Google and GitHub use PKCE + state. Do not set `checks: []` +- Email codes: CSPRNG 6-digit, 10-minute TTL, previous codes for that identifier deleted +- `allowDangerousEmailAccountLinking` is on so Google/GitHub can attach to an existing verified email. That is why CSRF on the OAuth callback must stay on +- Redirect callback only allows same-origin URLs + +## Authorization + +Roles are rows, not JWT claims. + +| Gate | Who | +| --- | --- | +| Signed-in | Any `user` | +| `isScanner` | Any active `admin` including `volunteer` | +| `isAdmin` | Active admin whose role is **not** `volunteer` | +| `isSuperAdmin` | `super_admin` | +| `isJudge` | Active `judge` for the resolved edition | +| `isProjectLeader` | Active `project_leader` or staff | + +Volunteers can staff check-in desks. They cannot delete editions, grant memberships, or pass `isAdmin`. + +Draft hackathons are staff-only. Public child queries (`getEvents`, projects, results) call `assertHackathonVisible` and return `NOT_FOUND` (not `FORBIDDEN`) so existence is not leaked. + +## Input + +`scrubMarkup` in `packages/api/src/trpc.ts`: + +- Rejects dangerous tags, inline handlers, `javascript:` URIs +- Does **not** rewrite HTML (rewriting ate prose like `loss/api/auth/callback/github +``` + +### Email code + +Nodemailer SMTP. Not a magic link: a 6-digit code from `crypto.randomInt`, stored as `custom:` in `verificationToken`, 10-minute expiry. Outstanding `custom:%` tokens for that identifier are deleted first so spamming sign-in cannot stack valid codes. + +HTML template is inline in `config.ts` (DSGT branding). SMTP host/user/password come from env (see [Environment](../operations/environment.md)). + +Production currently uses consumer Gmail (~500 recipients/day, shared with acceptance and announcement mail). Acceptance waves are capped at 500 for that reason. Switching providers is env-only: host, user, password secret, and a verified `EMAIL_FROM`. + +## Adapter + +`@auth/drizzle-adapter` over `user` / `account` / `session` / `verificationToken`. `createVerificationToken` and `useVerificationToken` are raw SQL to avoid Drizzle `boolin` errors on the compound primary key in this deployment. + +## Sign-in event + +`events.signIn` calls `linkPaidPaymentByVerifiedEmail` from `@query/db/services/membership`. The address is provider-verified, which is enough proof to claim a paid Stripe row. Errors are swallowed so membership never blocks login. + +## Mailer (`src/email.ts`) + +Process-wide pooled SMTP (`Mailer` class). `pool: true` only helps if the transporter outlives a single message — building one per send was a handshake storm on mass acceptance. + +Tunable: + +- `EMAIL_MAX_CONNECTIONS` (default 5) +- `EMAIL_MAX_MESSAGES` (default 100) + +`sendAcceptanceEmail` and other transactional templates share this path so from-address and HTML/text cannot drift. + +## Peer dependency + +`next >= 15`. Mainweb is on Next 16. diff --git a/docs/packages/db.md b/docs/packages/db.md new file mode 100644 index 00000000..d28aa572 --- /dev/null +++ b/docs/packages/db.md @@ -0,0 +1,149 @@ +# `@query/db` + +Drizzle ORM schemas, Postgres client, and membership rules. Package: `packages/db`. + +## Client + +`src/client.ts` builds a `pg.Pool` when `DATABASE_URL` is set: + +| Setting | Default | Why | +| --- | --- | --- | +| `max` | `DB_POOL_MAX` or `20` | Cloud Run concurrency 80; 10 was too small against the Neon pooler | +| `min` | `2` | Avoid handshake storms after idle | +| `connectionTimeoutMillis` | `DB_CONNECTION_TIMEOUT_MS` or `3000` | Fail fast rather than occupy a request slot | +| `idleTimeoutMillis` | `10000` | | +| SSL | `rejectUnauthorized: true` in production | | + +If `DATABASE_URL` is missing, `db` is `null` and a warning is logged. Builds that never query still succeed. + +Production: Neon serverless Postgres (`us-west-2`), pooled endpoint (`-pooler` host). Local: `docker compose` Postgres 15 on port 5433, database `neondb`. + +## Schema layout + +Files in `src/schemas/`, re-exported from `schemas/index.ts`. `drizzle.config.ts` globs `./src/schemas/**/*.ts`. + +| File | Tables | +| --- | --- | +| `auth.ts` | `user`, `account`, `session`, `verificationToken` | +| `members.ts` | `user_profile`, `member`, `membership_history` | +| `admins.ts` | `admin` | +| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_interest`, `hackathon_event`, `hackathon_event_attendee`, `hackathon_announcement`, `hackathon_announcement_recipient` | +| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result` | +| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` | +| `events.ts` | `event`, `event_check_in` | +| `stripe.ts` | `stripe_payment`, `user_account_link` | +| `security.ts` | `audit_logs` (+ `security_severity` enum) | +| `settings.ts` | `system_settings` (single row, `id = 'default'`) | + +Two cascade roots: + +- **`user`** — accounts, sessions, admin, profile, member, judge, club events/check-ins, teams (captain), participants, Stripe `linked_user_id` +- **`hackathon`** — teams, participants, projects, weekend events, judges, assignments, judging projects, queue, results, interest, announcements + +Nearly all FKs are `onDelete: "cascade"`. Deleting a user or an edition removes dependents. Exceptions are documented on the column (e.g. judging `source_project_id` is `set null` so deleting a submission does not erase votes). + +### Club vs hackathon (schema) + +Club tables are **not** keyed by `hackathon_id`. `member` is `unique(user_id)`. Which years someone paid is `membership_history`. Initiatives and `project_leader` are standing club appointments. + +Hackathon participation does not require a membership row. + +Edition statuses: `draft`, `announced`, `open`, `closed`, `in_progress`, `completed`, `cancelled`. `PRE_CURRENT_STATUSES` is `draft` and `announced` — those editions are never “current” for membership/portal resolution. `announced` is public (landing + interest) but registration is closed. + +Admin roles: `super_admin`, `admin`, `moderator`, `volunteer`. Volunteers are not full staff. + +Initiative statuses: `proposed` → (`declined` \| `draft`) → `open` \| `closed`. Only `open` is visible to members. Application statuses: `pending`, `accepted`, `rejected`, `withdrawn` (`withdrawn` is a state, not a delete, so the unique index still holds). + +## Membership service + +`src/services/membership.ts` is the one implementation of grant/link/current-edition. Auth sign-in, Stripe webhook, and tRPC all call it. + +Notable functions: + +- `resolveCurrentHackathonId` — in-progress edition, else newest non-pre-current +- `linkPaidPaymentByVerifiedEmail` — claim a paid Stripe row by verified email and upsert membership +- `setMembershipChangeHandler` — `@query/api` registers cache eviction; auth cannot import the API cache (dependency direction) + +Membership is paid + unexpired. A row with a past `membership_end_date` is lapsed (`hasLapsed`), not active. + +## Commands + +```bash +pnpm --filter @query/db migrate:push # drizzle-kit push to DATABASE_URL +pnpm --filter @query/db migrate:generate # SQL into packages/db/drizzle +pnpm --filter @query/db db:check # fail if declared columns are missing +pnpm --filter @query/db studio # Drizzle Studio +pnpm --filter @query/db db:seed # scripts/seed.ts +``` + +The repo is **push-based**: `packages/db/drizzle/meta/_journal.json` has no migration entries. App Hosting runs `drizzle-kit push --verbose < /dev/null` then `db:check`. Destructive prompts cannot be confirmed, so the push aborts rather than dropping columns; `db:check` is the real gate (push can still exit 0). + +`scripts/link-payments.ts` is a one-off to attach historical paid Stripe rows to matching user emails: + +```bash +pnpm --filter @query/db tsx scripts/link-payments.ts # dry run +pnpm --filter @query/db tsx scripts/link-payments.ts --apply +``` + +## One-off: collapsing edition-scoped club tables + +Only for a database that **already** had `project_leader` / `initiative` keyed by `hackathon_id`. **Check first:** + +```sql +SELECT to_regclass('public.project_leader'); +``` + +If that is `NULL`, skip this section — `migrate:push` creates the current shape. + +If the table exists with the old unique `(user_id, hackathon_id)`, push cannot rebuild the unique index (duplicate people across editions). Run this **once, before** push. Statements are guarded; re-running is safe. + +```sql +BEGIN; + +WITH ranked AS ( + SELECT + id, + user_id, + bool_or(is_active) OVER (PARTITION BY user_id) AS any_active, + row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn + FROM project_leader +) +UPDATE project_leader AS pl +SET is_active = ranked.any_active +FROM ranked +WHERE pl.id = ranked.id + AND ranked.rn = 1 + AND pl.is_active IS DISTINCT FROM ranked.any_active; + +DELETE FROM project_leader +WHERE id IN ( + SELECT id FROM ( + SELECT + id, + row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn + FROM project_leader + ) dupes + WHERE rn > 1 +); + +ALTER TABLE project_leader + DROP CONSTRAINT IF EXISTS unique_project_leader_per_hackathon; +DROP INDEX IF EXISTS project_leader_hackathon_id_idx; +ALTER TABLE project_leader DROP COLUMN IF EXISTS hackathon_id; + +DROP INDEX IF EXISTS initiative_hackathon_id_idx; +ALTER TABLE initiative DROP COLUMN IF EXISTS hackathon_id; + +ALTER TABLE project_leader + DROP CONSTRAINT IF EXISTS unique_project_leader; +ALTER TABLE project_leader + ADD CONSTRAINT unique_project_leader UNIQUE (user_id); + +COMMIT; +``` + +Initiatives are not deleted. Rows that were hidden by an old edition become visible again. Archive any that should not return from the leader screen. + +## Tests + +`src/services/membership.test.ts` — membership date/active/lapsed rules and linking behavior. diff --git a/docs/packages/ui.md b/docs/packages/ui.md new file mode 100644 index 00000000..a4a193c2 --- /dev/null +++ b/docs/packages/ui.md @@ -0,0 +1,35 @@ +# `@query/ui` + +Shared React components and CSS for the main website. Package: `packages/ui`. + +This is a small library, not a full design system. Mainweb also uses `@mawtech/glass-ui`, local `components/`, and Tailwind 4. + +## Exports + +`package.json` points `main` / `types` at `dist/` after `tsc`. Source: + +| File | Export | +| --- | --- | +| `src/glass.tsx` | Glass-style primitives (re-exported from `src/index.ts`) | +| `src/card.tsx` / `card.jsx` | Card | +| `src/gradient.tsx` / `gradient.jsx` | Gradient | +| `src/turborepo-logo.tsx` | Logo leftover from the Turbo starter | +| `src/styles.css` | Shared styles; also exported as `@query/ui/styles` | + +Peer dependency: React 18 or 19. + +## Scripts + +```bash +pnpm --filter @query/ui build:components # tsc → dist/ +pnpm --filter @query/ui build:styles # tailwindcss CLI in → dist/index.css +pnpm --filter @query/ui dev:components +pnpm --filter @query/ui dev:styles +pnpm --filter @query/ui lint +``` + +Mainweb `transpilePackages` includes `@query/ui`, so the site can import source during Next builds even when `dist/` is stale. Prefer building the package when changing public exports. + +## Tooling + +ESLint: `@query/eslint-config`. Tailwind: `@query/tailwind-config`. TSConfig: `@query/tsconfig`. diff --git a/docs/sites/hacklytics2027.md b/docs/sites/hacklytics2027.md new file mode 100644 index 00000000..6ef15d56 --- /dev/null +++ b/docs/sites/hacklytics2027.md @@ -0,0 +1,61 @@ +# Hacklytics 2027 (`hacklytics2027`) + +Path: `sites/hacklytics2027` +Workspace name: `hacklytics2027` +Framework: Next.js 16, React 19, Tailwind 4, **static export** (`output: "export"`) + +Marketing site for Hacklytics 2027 (“Digital Bloom”). It has **no database** and **no tRPC**. Anything dynamic (interest list, registration) lives on the portal. + +## Run + +```bash +pnpm --filter hacklytics2027 dev # next dev --turbopack (port 3000) +pnpm --filter hacklytics2027 build # writes static files for Firebase Hosting +pnpm --filter hacklytics2027 e2e # Playwright +``` + +## Structure + +| Path | Role | +| --- | --- | +| `app/page.tsx` | Home (pixel garden hero + sections) | +| `app/layout.tsx` | Fonts (Roboto Mono, Space Grotesk, Silkscreen), metadata, SW registrar | +| `app/not-found.tsx` | 404 | +| `components/HomeSections.tsx` | Lazy-loaded below-the-fold sections | +| `components/sections/*` | About, tracks, schedule, prizes/speakers, FAQ, sponsors | +| `components/pixel/*` | Pixel sprites / garden | +| `components/Navbar.tsx`, `Footer.tsx` | Chrome | +| `lib/links.ts` | Portal origin + interest URL | +| `public/sw.js` | Service worker (Firebase header: no-cache, `Service-Worker-Allowed: /`) | + +Schedule copy lives in `components/sections/Schedule/data.ts`. + +## Interest CTA + +`lib/links.ts` is the only outbound destination. The Typeform this replaced was pasted in four files and drifted. + +Interest requires a portal account (verified email). The CTA is: + +``` +{PORTAL_ORIGIN}/login?callbackUrl=/hacklytics +``` + +`callbackUrl` is encoded so it survives the email-code hop through `/verify`. The portal only honors same-origin paths. + +## Deploy + +Firebase Hosting target `hacklytics`, public dir `sites/hacklytics2027/out` (`firebase.json`). + +Workflows: + +- `.github/workflows/deploy-hacklytics.yml` — build filter `hacklytics2027`, deploy target `hacklytics` (live on `main`, preview channel `pr-N` on PRs) +- `.github/workflows/firebase-hosting-merge.yml` — same live deploy on `main` +- `.github/workflows/firebase-hosting-pull-request.yml` — PR preview channels + +Asset caching: hashed JS/CSS/fonts/images `max-age=31536000, immutable`; HTML `max-age=3600`. See `firebase.json`. + +Images are `unoptimized: true` because static export has no image optimizer. React Compiler is on. + +## Assets + +MLH league trust badges live in the repo-root folder `trust badge/` (SVG + `.ai`). Copy into `public/` if a page needs to ship one. diff --git a/docs/sites/mainweb.md b/docs/sites/mainweb.md new file mode 100644 index 00000000..636ca0ee --- /dev/null +++ b/docs/sites/mainweb.md @@ -0,0 +1,95 @@ +# Main website (`web`) + +Path: `sites/mainweb` +Workspace name: `web` +Framework: Next.js 16 App Router, React 19, Tailwind 4, `output: "standalone"` +Dev: `next dev --port 3001` + +This is the club’s public site **and** the authenticated portal. There is no separate `sites/portal` app; portal routes live in the `(portal)` route group. + +## Public routes + +| Path | Page | +| --- | --- | +| `/` | Home (`HomePageClient`) | +| `/team` | Team | +| `/events` | Public events | +| `/projects` | Projects | +| `/history` | Club history | +| `/bootcamp` | Bootcamp marketing | +| `/docs` | In-app docs UI | +| `/status` | Status | +| `/sitemap.xml` / `robots.txt` | Generated via `app/sitemap.ts`, `app/robots.ts` | + +`app/api/csp-report/route.ts` receives CSP report-only violations. + +## Portal routes (`app/(portal)`) + +Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `private, no-store`. + +| Path | Audience | +| --- | --- | +| `/login` | Sign-in (Google, GitHub if configured, email code) | +| `/verify` | Email code entry | +| `/auth/error` | NextAuth error page | +| `/dashboard` | Member home | +| `/settings` | Profile / account | +| `/club` | Membership, pass, club scanner tab | +| `/club/bootcamp` | Bootcamp (paid add-on, term-gated) | +| `/initiatives` | Browse / apply | +| `/lead`, `/lead/[id]` | Project leader | +| `/hackathons`, `/hackathons/[id]` | Edition pages (info, schedule, teams, projects, results) | +| `/hackathons/[id]/judge` | Judging for that edition | +| `/hacklytics` | Current Hacklytics interest / portal landing | +| `/submit` | Project submission | +| `/judge`, `/judge/register` | Judge home / apply | +| `/scan` | QR scanning | +| `/admin` | Staff home | +| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events) | +| `/admin/members` | Membership admin | +| `/admin/attendees` | Attendee tools | +| `/admin/judging` | Judging admin | +| `/admin/initiatives` | Initiative / proposal review | +| `/admin/bootcamp` | Bootcamp attendance | +| `/admin/staff` | Admin users | +| `/admin/analytics` | Overview | +| `/admin/audit` | Audit log | +| `/admin/projects` | Project admin | +| `/admin/setup` | First-run wizard | + +## API routes + +| Path | Role | +| --- | --- | +| `/api/trpc/[trpc]` | tRPC fetch adapter (`GET` + `POST`) | +| `/api/auth/[...nextauth]` | NextAuth handlers | +| `/api/auth/verify-email` | Email-code verification | +| `/api/webhooks/stripe` | Stripe webhooks | + +## Client data + +- `lib/trpc.tsx` — `createTRPCReact()` +- `lib/query-client.ts` — TanStack Query client +- `lib/use-portal-context.ts` — `user.getPortalContext` +- `(portal)/providers.tsx` — Query + tRPC providers + +Helpers: `lib/hackathon-slug.ts`, `lib/phone.ts`, `lib/bootcamp-schedule.ts`, `lib/trpc-error.ts`, `lib/chunk-error.ts`, `lib/safe-callback.ts` (the last three have unit tests). + +## Config highlights (`next.config.mjs`) + +- Transpiles `@query/api`, `@query/auth`, `@query/db`, `@query/ui` +- `outputFileTracingRoot` is the monorepo root (needed for standalone on App Hosting) +- Security headers: HSTS, CSP (report-only unless `CSP_ENFORCE=true`), frame options SAMEORIGIN (admin print/QR iframes), Permissions-Policy (camera **not** denied — `/scan` needs it) +- React Compiler enabled + +`start.sh` is a local convenience script (turbo build, firebase hosting deploy, then `pnpm dev`). Production does **not** use it; App Hosting uses `apphosting.yaml`. + +## UI stack + +Portal: liquid-glass CSS, Lucide icons, Stripe React, QR scanner (`@yudiel/react-qr-scanner`), Chart.js on admin analytics. Public marketing: custom Hero/Section/Navbar/Footer plus `@query/ui` glass. + +## Tests + +```bash +pnpm test # includes sites/mainweb/lib +``` diff --git a/docs/tooling.md b/docs/tooling.md new file mode 100644 index 00000000..4c075210 --- /dev/null +++ b/docs/tooling.md @@ -0,0 +1,31 @@ +# Tooling + +Shared configs under `tooling/`. Each is a workspace package consumed via `workspace:*`. + +| Path | Package | Exports | +| --- | --- | --- | +| `tooling/eslint` | `@query/eslint-config` | `./base`, `./next-js`, `./react`, `./react-internal` | +| `tooling/prettier` | `@query/prettier-config` | `.` (`index.js`) — import sort + Tailwind plugin | +| `tooling/tailwind` | `@query/tailwind-config` | `.` (`shared-styles.css`), `./postcss` | +| `tooling/typescript` | `@query/tsconfig` | `./base.json`, `./nextjs.json`, `./internal-package.json` | + +ESLint in app packages is `--max-warnings 0`. + +## Turbo + +Root `turbo.json` defines `build`, `dev`, `lint`, `typecheck`, `test`, `format`, `clean`, plus `push` / `studio` / `ui-add`. `globalEnv` lists secrets and tunables that must bust the cache when they change (database, auth, Stripe, email, DDoS, proxy hops). + +`sites/mainweb/turbo.json` extends the root and sets Next `.next/**` build outputs. + +`turbo/generators/` is a Plop generator (`init`) that scaffolds a new `packages/` with eslint, package.json, tsconfig, and `src/index.ts`. + +## Other root files + +| File | Role | +| --- | --- | +| `.nvmrc` | Node 20 | +| `.npmrc` | `auto-install-peers=false`, hoist eslint/prettier, `frozen-lockfile=false` | +| `restore-workspace.js` | Rewrite internal deps from `"*"` back to `workspace:*` | +| `.dockerignore` | Slim Docker context (docs, git, env files, `node_modules`) | +| `types/globals.d.ts` | Image module declarations (png/jpg/svg) | +| `.vscode/settings.json` | Quiet terminal bell; disable compile-hero on save | diff --git a/package.json b/package.json index bdcf132d..0954cf6d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "pnpm": { "overrides": { - "postcss": "^8.5.18", + "postcss": "^8.5.23", "esbuild": "^0.25.12", "ws": "^8.20.1", "@eslint/plugin-kit": "^0.3.4", @@ -36,7 +36,7 @@ "undici": "^6.27.0", "sharp": "^0.35.0", "vite": "^7.3.5", - "brace-expansion": "^5.0.8" + "brace-expansion": "^5.0.9" } } } diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 00000000..2c839eea --- /dev/null +++ b/packages/api/README.md @@ -0,0 +1,11 @@ +# `@query/api` + +tRPC application layer for the portal. Mounted from `sites/mainweb` at `/api/trpc`. + +**Full reference:** [docs/packages/api.md](../../docs/packages/api.md) + +```bash +pnpm --filter @query/api lint +pnpm --filter @query/api typecheck +pnpm --filter @query/api test +``` diff --git a/packages/api/package.json b/packages/api/package.json index ff66d3c3..da963a79 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -29,8 +29,7 @@ "@trpc/react-query": "11.18.0", "@trpc/server": "11.18.0", "drizzle-orm": "0.45.2", - "image-size": "2.0.2", - "sanitize-html": "2.17.4", + "sanitize-html": "2.17.5", "stripe": "^22.0.0", "superjson": "2.2.3", "zod": "3.25.53" diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index bd18bff7..55f61624 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm"; import { CacheKeys } from "../middleware/cache"; import type { DrizzleDB } from "@query/db"; import { fetchPortalContext } from "../services/portal-context"; +import { readImageDimensions } from "../services/image-dimensions"; // z.string().url() is backed by new URL(), which accepts any scheme — a stored // data: or javascript: URI is handed straight back to whoever renders it. @@ -185,9 +186,8 @@ export const userRouter = createTRPCRouter({ const buffer = Buffer.from(base64Data, "base64"); try { - const { imageSize } = await import("image-size"); - const dimensions = imageSize(buffer); - if (!dimensions.width || !dimensions.height) { + const dimensions = readImageDimensions(buffer); + if (!dimensions?.width || !dimensions.height) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid image dimensions. File may be corrupt.", @@ -200,8 +200,8 @@ export const userRouter = createTRPCRouter({ "Image dimensions exceed the maximum allowed size of 2000x2000 pixels.", }); } - const allowedTypes = ["jpg", "jpeg", "png", "webp"]; - if (!dimensions.type || !allowedTypes.includes(dimensions.type)) { + const allowedTypes = ["jpeg", "png", "webp"]; + if (!allowedTypes.includes(dimensions.type)) { throw new TRPCError({ code: "BAD_REQUEST", message: diff --git a/packages/api/src/services/image-dimensions.test.ts b/packages/api/src/services/image-dimensions.test.ts new file mode 100644 index 00000000..72333bb6 --- /dev/null +++ b/packages/api/src/services/image-dimensions.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { readImageDimensions } from "./image-dimensions"; + +const png = (width: number, height: number) => { + const buf = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf); + buf.writeUInt32BE(13, 8); + buf.write("IHDR", 12); + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + return buf; +}; + +const jpegSof = (width: number, height: number) => { + // SOI (2) + SOF0 marker (2) + length-inclusive segment (11) + const buf = Buffer.alloc(15); + buf[0] = 0xff; + buf[1] = 0xd8; + buf[2] = 0xff; + buf[3] = 0xc0; + buf.writeUInt16BE(11, 4); + buf[6] = 8; + buf.writeUInt16BE(height, 7); + buf.writeUInt16BE(width, 9); + buf[11] = 1; + return buf; +}; + +const webpVp8x = (width: number, height: number) => { + const buf = Buffer.alloc(30); + buf.write("RIFF", 0); + buf.writeUInt32LE(22, 4); + buf.write("WEBP", 8); + buf.write("VP8X", 12); + buf.writeUInt32LE(10, 16); + const w = width - 1; + const h = height - 1; + buf[24] = w & 0xff; + buf[25] = (w >> 8) & 0xff; + buf[26] = (w >> 16) & 0xff; + buf[27] = h & 0xff; + buf[28] = (h >> 8) & 0xff; + buf[29] = (h >> 16) & 0xff; + return buf; +}; + +describe("readImageDimensions", () => { + it("reads PNG IHDR width and height", () => { + expect(readImageDimensions(png(640, 480))).toEqual({ + width: 640, + height: 480, + type: "png", + }); + }); + + it("reads JPEG SOF0 width and height", () => { + expect(readImageDimensions(jpegSof(32, 16))).toEqual({ + width: 32, + height: 16, + type: "jpeg", + }); + }); + + it("reads WebP VP8X canvas size", () => { + expect(readImageDimensions(webpVp8x(200, 100))).toEqual({ + width: 200, + height: 100, + type: "webp", + }); + }); + + it("refuses zero-sized and truncated buffers instead of looping", () => { + expect(readImageDimensions(png(0, 10))).toBeNull(); + expect(readImageDimensions(Buffer.from("icns"))).toBeNull(); + expect(readImageDimensions(Buffer.alloc(0))).toBeNull(); + // A zero-size JXL/HEIF box used to hang image-size. We never parse those. + const jxlish = Buffer.alloc(32, 0); + jxlish.write("JXL ", 4); + expect(readImageDimensions(jxlish)).toBeNull(); + }); +}); diff --git a/packages/api/src/services/image-dimensions.ts b/packages/api/src/services/image-dimensions.ts new file mode 100644 index 00000000..18699e81 --- /dev/null +++ b/packages/api/src/services/image-dimensions.ts @@ -0,0 +1,140 @@ +/** + * Dimensions for the three types the profile-image upload already allows. + * + * `image-size` through 2.0.2 (the latest published release) infinite-loops on + * crafted ICNS / JXL / HEIF buffers. There is no patched version on npm, so + * this parser understands only PNG, JPEG and WebP — the same types the data-URI + * regex already admits. Anything else is corrupt, not "try the next format". + */ + +export type ImageKind = "png" | "jpeg" | "webp"; + +export type ImageDimensions = { + width: number; + height: number; + type: ImageKind; +}; + +const PNG_SIG = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +const isFinitePositive = (n: number) => + Number.isInteger(n) && n > 0 && n <= 0xffff_ffff; + +const pngDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 24) return null; + if (!buf.subarray(0, 8).equals(PNG_SIG)) return null; + if (buf.toString("ascii", 12, 16) !== "IHDR") return null; + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "png" }; +}; + +const jpegDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; + + let offset = 2; + while (offset + 3 < buf.length) { + if (buf[offset] !== 0xff) return null; + while (offset < buf.length && buf[offset] === 0xff) offset += 1; + if (offset >= buf.length) return null; + + const marker = buf[offset]!; + offset += 1; + + // Standalone markers (no length): RST0–RST7, SOI, EOI, TEM. + if ( + marker === 0xd8 || + marker === 0xd9 || + marker === 0x01 || + (marker >= 0xd0 && marker <= 0xd7) + ) { + if (marker === 0xd9) return null; + continue; + } + + if (offset + 1 >= buf.length) return null; + const length = buf.readUInt16BE(offset); + if (length < 2 || offset + length > buf.length) return null; + + // SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15 carry the frame size. + const isSof = + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf); + + if (isSof) { + if (length < 7 || offset + 6 >= buf.length) return null; + const height = buf.readUInt16BE(offset + 3); + const width = buf.readUInt16BE(offset + 5); + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "jpeg" }; + } + + offset += length; + } + + return null; +}; + +const readUInt24LE = (buf: Buffer, offset: number) => + buf[offset]! | (buf[offset + 1]! << 8) | (buf[offset + 2]! << 16); + +const webpDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 16) return null; + if (buf.toString("ascii", 0, 4) !== "RIFF") return null; + if (buf.toString("ascii", 8, 12) !== "WEBP") return null; + + const fourcc = buf.toString("ascii", 12, 16); + if (buf.length < 20) return null; + const chunkSize = buf.readUInt32LE(16); + const payload = 20; + + if (fourcc === "VP8X") { + // 1 byte flags + 3 reserved + 3 width-1 + 3 height-1 + if (chunkSize < 10 || buf.length < payload + 10) return null; + const width = readUInt24LE(buf, payload + 4) + 1; + const height = readUInt24LE(buf, payload + 7) + 1; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + if (fourcc === "VP8L") { + // signature 0x2f, then 14-bit width-1 and 14-bit height-1. + if (chunkSize < 5 || buf.length < payload + 5) return null; + if (buf[payload] !== 0x2f) return null; + const bits = + buf[payload + 1]! | + (buf[payload + 2]! << 8) | + (buf[payload + 3]! << 16) | + (buf[payload + 4]! << 24); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + if (fourcc === "VP8 ") { + // 3-byte frame tag, then 0x9d 0x01 0x2a, then 16-bit width/height (14 used). + if (chunkSize < 10 || buf.length < payload + 10) return null; + if ( + buf[payload + 3] !== 0x9d || + buf[payload + 4] !== 0x01 || + buf[payload + 5] !== 0x2a + ) { + return null; + } + const width = buf.readUInt16LE(payload + 6) & 0x3fff; + const height = buf.readUInt16LE(payload + 8) & 0x3fff; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + return null; +}; + +export const readImageDimensions = (buf: Buffer): ImageDimensions | null => + pngDimensions(buf) ?? jpegDimensions(buf) ?? webpDimensions(buf); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index fe930fe4..88037fdb 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -72,16 +72,134 @@ const requiresDb = t.middleware(async ({ ctx, next }) => { * places a value might reach an HTML sink, and a hackathon full of people * writing `vector` or `a]*\bon[a-z]+\s*=/`) were + * polynomial in the length of attacker-controlled input (CodeQL #804/#805): + * nested `\s*` and `[^>]*` plus a later alternative make the matcher walk the + * same prefix over and over. A hackathon payload is large enough for that to + * stall the instance; a linear walk cannot. + */ +const DANGEROUS_TAGS = [ + "script", + "iframe", + "object", + "embed", + "link", + "meta", + "base", + "svg", + "math", + "style", + "form", + "input", + "button", + "img", + "video", + "audio", + "source", + "track", + "template", + "noscript", + "textarea", + "xmp", + "frame", + "frameset", + "applet", +] as const; + +const isHtmlSpace = (ch: string) => + ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f"; + +const isAsciiLetter = (ch: string) => + (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z"); + +const isNameBoundary = (ch: string | undefined) => { + if (ch === undefined) return true; + const c = ch.toLowerCase(); + return !( + (c >= "a" && c <= "z") || + (c >= "0" && c <= "9") || + c === "-" + ); +}; + +/** + * `onerror=` / `onload=` only count inside a tag. Matched loosely it would + * reject prose like "onboarding = great". + * + * `end` is already bounded (next `>` or 2048 chars), so this is linear in a + * small window rather than in the whole payload. + */ +const hasInlineHandler = (lower: string, start: number, end: number) => { + let pos = start; + while (pos < end) { + const on = lower.indexOf("on", pos); + if (on === -1 || on >= end) return false; + if (on > start) { + const prev = lower[on - 1]!; + if (!isHtmlSpace(prev) && prev !== "<") { + pos = on + 1; + continue; + } + } + let k = on + 2; + let n = 0; + while (k < end && n < 32) { + const ch = lower[k]!; + if (ch < "a" || ch > "z") break; + k += 1; + n += 1; + } + if (n === 0) { + pos = on + 1; + continue; + } + while (k < end && isHtmlSpace(lower[k]!)) k += 1; + if (k < end && lower[k] === "=") return true; + pos = on + 1; + } + return false; +}; -// An event handler only means anything inside a tag; matched loosely it would -// reject prose like "onboarding = great". -const TAG_WITH_HANDLER = /<[a-zA-Z][^>]*\bon[a-z]+\s*=/i; +/** + * True when the string could execute if it reached an HTML sink. + * + * `javascript:` is a substring check (case-insensitive). Tags and handlers + * are found by walking `<` … `>` so combining characters / long runs of + * spaces cannot force backtracking. + */ +export const hasDangerousMarkup = (value: string): boolean => { + const lower = value.toLowerCase(); + if (lower.includes("javascript:")) return true; + + for (let i = 0; i < lower.length; i += 1) { + if (lower[i] !== "<") continue; + + let j = i + 1; + while (j < lower.length && isHtmlSpace(lower[j]!)) j += 1; + if (j < lower.length && lower[j] === "/") { + j += 1; + while (j < lower.length && isHtmlSpace(lower[j]!)) j += 1; + } + for (const tag of DANGEROUS_TAGS) { + if (lower.startsWith(tag, j) && isNameBoundary(lower[j + tag.length])) { + return true; + } + } + + // Original handler regex required a letter immediately after `<`. + if (i + 1 < lower.length && isAsciiLetter(lower[i + 1]!)) { + const gt = lower.indexOf(">", i + 1); + const end = gt === -1 ? Math.min(lower.length, i + 2048) : gt; + if (hasInlineHandler(lower, i, end)) return true; + } + } -// Still dangerous as plain text: whoever renders it into an href gets an -// executable link. -const SCRIPTABLE_URI = /javascript:/i; + return false; +}; const isPlainObject = (value: object) => { const proto = Object.getPrototypeOf(value) as object | null; @@ -129,11 +247,7 @@ export const scrubMarkup = (input: unknown, depth = 0): unknown => { } if (typeof input === "string") { - if ( - DANGEROUS_TAG.test(input) || - TAG_WITH_HANDLER.test(input) || - SCRIPTABLE_URI.test(input) - ) { + if (hasDangerousMarkup(input)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid input: HTML and script content are not allowed", diff --git a/packages/auth/README.md b/packages/auth/README.md new file mode 100644 index 00000000..c87c845e --- /dev/null +++ b/packages/auth/README.md @@ -0,0 +1,10 @@ +# `@query/auth` + +NextAuth v5 (Google, optional GitHub, email 6-digit codes) plus the pooled SMTP mailer. + +**Full reference:** [docs/packages/auth.md](../../docs/packages/auth.md) + +```bash +pnpm --filter @query/auth lint +pnpm --filter @query/auth typecheck +``` diff --git a/packages/db/README.md b/packages/db/README.md new file mode 100644 index 00000000..b85b9f5c --- /dev/null +++ b/packages/db/README.md @@ -0,0 +1,12 @@ +# `@query/db` + +Drizzle schemas, `pg.Pool` client, and membership grant/link rules. + +**Full reference:** [docs/packages/db.md](../../docs/packages/db.md) +Local Postgres: [docs/getting-started.md](../../docs/getting-started.md) + +```bash +pnpm --filter @query/db migrate:push +pnpm --filter @query/db db:check +pnpm --filter @query/db studio +``` diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 00000000..4f0a36d1 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,11 @@ +# `@query/ui` + +Small shared React + CSS package used by `sites/mainweb`. + +**Full reference:** [docs/packages/ui.md](../../docs/packages/ui.md) + +```bash +pnpm --filter @query/ui build:components +pnpm --filter @query/ui build:styles +pnpm --filter @query/ui lint +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dcee082..6b1e7f67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - postcss: ^8.5.18 + postcss: ^8.5.23 esbuild: ^0.25.12 ws: ^8.20.1 '@eslint/plugin-kit': ^0.3.4 @@ -17,7 +17,7 @@ overrides: undici: ^6.27.0 sharp: ^0.35.0 vite: ^7.3.5 - brace-expansion: ^5.0.8 + brace-expansion: ^5.0.9 importers: @@ -63,12 +63,9 @@ importers: drizzle-orm: specifier: 0.45.2 version: 0.45.2(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) - image-size: - specifier: 2.0.2 - version: 2.0.2 sanitize-html: - specifier: 2.17.4 - version: 2.17.4 + specifier: 2.17.5 + version: 2.17.5 stripe: specifier: ^22.0.0 version: 22.1.1(@types/node@22.15.32) @@ -261,7 +258,7 @@ importers: specifier: 10.1.0 version: 10.1.0(jiti@2.7.0) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 tailwindcss: specifier: 4.3.0 @@ -357,8 +354,8 @@ importers: specifier: 1.9.3 version: 1.9.3(react-dom@19.0.0(react@19.2.7))(react@19.2.7) sanitize-html: - specifier: ^2.17.4 - version: 2.17.4 + specifier: ^2.17.5 + version: 2.17.5 stripe: specifier: ^22.0.0 version: 22.1.1(@types/node@22.15.32) @@ -418,7 +415,7 @@ importers: specifier: 10.1.0 version: 10.1.0(jiti@2.7.0) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 tailwindcss: specifier: 4.3.0 @@ -528,7 +525,7 @@ importers: specifier: 16.3.0 version: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 react: specifier: 19.2.7 @@ -2251,7 +2248,7 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.5.18 + postcss: ^8.5.23 available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -2284,8 +2281,8 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -2941,11 +2938,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - image-size@2.0.2: - resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} - engines: {node: '>=16.x'} - hasBin: true - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3721,8 +3713,8 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - sanitize-html@2.17.4: - resolution: {integrity: sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==} + sanitize-html@2.17.5: + resolution: {integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==} scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -4166,7 +4158,7 @@ packages: optional: true xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: - resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} version: 0.20.3 engines: {node: '>=0.8'} hasBin: true @@ -5800,7 +5792,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -6494,8 +6486,6 @@ snapshots: ignore@7.0.5: {} - image-size@2.0.2: {} - imurmurhash@0.1.4: {} internal-slot@1.1.0: @@ -6759,15 +6749,15 @@ snapshots: minimatch@10.2.3: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@10.2.4: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimist@1.2.8: {} @@ -7218,7 +7208,7 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - sanitize-html@2.17.4: + sanitize-html@2.17.5: dependencies: deepmerge: 4.3.1 escape-string-regexp: 4.0.0 diff --git a/sites/hacklytics2027/README.md b/sites/hacklytics2027/README.md index e215bc4c..1777f1f6 100644 --- a/sites/hacklytics2027/README.md +++ b/sites/hacklytics2027/README.md @@ -1,36 +1,13 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Hacklytics 2027 (`hacklytics2027`) -## Getting Started +Static marketing site for Hacklytics 2027 (Digital Bloom). No database. Interest and registration go to the portal. -First, run the development server: +**Full reference:** [docs/sites/hacklytics2027.md](../../docs/sites/hacklytics2027.md) ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +pnpm --filter hacklytics2027 dev # http://localhost:3000 +pnpm --filter hacklytics2027 build # static export → out/ +pnpm --filter hacklytics2027 e2e # Playwright ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Outbound links are centralized in `lib/links.ts`. diff --git a/sites/hacklytics2027/package.json b/sites/hacklytics2027/package.json index 56a3d993..247f4576 100644 --- a/sites/hacklytics2027/package.json +++ b/sites/hacklytics2027/package.json @@ -27,7 +27,7 @@ "autoprefixer": "10.4.22", "baseline-browser-mapping": "2.9.19", "eslint": "10.1.0", - "postcss": "8.5.18", + "postcss": "8.5.23", "tailwindcss": "4.3.0", "typescript": "5.8.3", "typescript-eslint": "^8.59.2" diff --git a/sites/mainweb/README.md b/sites/mainweb/README.md index 58dc3500..31ca6130 100644 --- a/sites/mainweb/README.md +++ b/sites/mainweb/README.md @@ -1,28 +1,14 @@ -## Getting Started +# Main website (`web`) -First, run the development server: +Public DSGT site and the authenticated portal (Next.js App Router on port 3001). + +**Full reference:** [docs/sites/mainweb.md](../../docs/sites/mainweb.md) ```bash -yarn dev +pnpm --filter web dev # http://localhost:3001 +pnpm --filter web build +pnpm --filter web lint +pnpm --filter web typecheck ``` -Open [http://localhost:3001](http://localhost:3001) with your browser to see the result. - -You can start editing the page by modifying `src/app/page.tsx`. The page auto-updates as you edit the file. - -To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3001/api/hello](http://localhost:3001/api/hello). - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +The portal is the `(portal)` route group in this app, not a separate workspace. tRPC, NextAuth, and the Stripe webhook all live here. diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts index e66f9b7a..79f588c0 100644 --- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts +++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts @@ -27,8 +27,14 @@ import { const safeLogId = (value: unknown) => String(value ?? "") .replace(/[^\w-]/g, "") + .replace(/[\n\r]/g, "") .slice(0, 64); +const safeLogError = (err: unknown) => + err instanceof Error + ? err.name.replace(/[\n\r]/g, "") + : "Error"; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; const stripe = process.env.STRIPE_SECRET_KEY @@ -173,7 +179,7 @@ export async function POST(req: NextRequest) { console.error( "[Stripe webhook] payment marked paid, membership grant failed", safeLogId(existingPayment.id), - e, + safeLogError(e), ); } } @@ -256,7 +262,7 @@ export async function POST(req: NextRequest) { console.error( "[Stripe webhook] membership grant failed for checkout session", safeLogId(session.id), - e, + safeLogError(e), ); } } @@ -373,7 +379,7 @@ export async function POST(req: NextRequest) { console.error( "[Stripe webhook] membership grant failed for payment intent", safeLogId(pi.id), - e, + safeLogError(e), ); } } diff --git a/sites/mainweb/app/api/csp-report/route.ts b/sites/mainweb/app/api/csp-report/route.ts index f64a612a..2c42f386 100644 --- a/sites/mainweb/app/api/csp-report/route.ts +++ b/sites/mainweb/app/api/csp-report/route.ts @@ -45,10 +45,13 @@ export async function POST(request: NextRequest) { } // Two formats in the wild: the legacy `report-uri` shape - // ({"csp-report": {...}}) and the newer Reporting API array. Log whichever - // arrives rather than parsing both into one shape — this is a diagnostic, - // not a data pipeline. - console.warn("[CSP] violation report:", body.slice(0, MAX_REPORT_BYTES)); + // ({"csp-report": {...}}) and the newer Reporting API array. Log a + // newline-stripped copy — the body is attacker-controlled (this endpoint + // is unauthenticated) and a raw CR/LF would forge extra log lines. + const forLog = body + .slice(0, MAX_REPORT_BYTES) + .replace(/[\n\r]/g, " "); + console.warn("[CSP] violation report:", forLog); } catch (error) { console.error("[CSP] failed to read a violation report:", error); } diff --git a/sites/mainweb/package.json b/sites/mainweb/package.json index 3a1bc233..92515417 100644 --- a/sites/mainweb/package.json +++ b/sites/mainweb/package.json @@ -39,7 +39,7 @@ "react-chartjs-2": "5.3.1", "react-dom": "19.0.0", "react-scroll": "1.9.3", - "sanitize-html": "^2.17.4", + "sanitize-html": "^2.17.5", "stripe": "^22.0.0", "superjson": "^2.2.3" }, @@ -61,7 +61,7 @@ "autoprefixer": "^10.4.22", "cross-env": "^7.0.3", "eslint": "10.1.0", - "postcss": "8.5.18", + "postcss": "8.5.23", "tailwindcss": "4.3.0", "turbo": "^2.9.14", "typescript": "^6.0.2", diff --git a/tooling/eslint/README.md b/tooling/eslint/README.md index 8b42d901..58dd0279 100644 --- a/tooling/eslint/README.md +++ b/tooling/eslint/README.md @@ -1,3 +1,14 @@ -# `@turbo/eslint-config` +# `@query/eslint-config` -Collection of internal eslint configurations. +Internal ESLint configs for the monorepo. + +| Export | Use | +| --- | --- | +| `@query/eslint-config/base` | `base.js` — shared TypeScript rules | +| `@query/eslint-config/next-js` | `next.js` — Next.js apps | +| `@query/eslint-config/react` | `react.js` | +| `@query/eslint-config/react-internal` | `react-internal.js` — packages such as `@query/ui` | + +App packages run `eslint . --max-warnings 0`. + +See [docs/tooling.md](../../docs/tooling.md). diff --git a/tooling/prettier/README.md b/tooling/prettier/README.md new file mode 100644 index 00000000..ebd8e28e --- /dev/null +++ b/tooling/prettier/README.md @@ -0,0 +1,11 @@ +# `@query/prettier-config` + +Shared Prettier config (`@ianvs/prettier-plugin-sort-imports` + `prettier-plugin-tailwindcss`). + +```js +{ + "prettier": "@query/prettier-config" +} +``` + +From the repo root: `pnpm format`. See [docs/tooling.md](../../docs/tooling.md). diff --git a/tooling/tailwind/README.md b/tooling/tailwind/README.md new file mode 100644 index 00000000..bcdbceae --- /dev/null +++ b/tooling/tailwind/README.md @@ -0,0 +1,9 @@ +# `@query/tailwind-config` + +Shared Tailwind 4 styles (`shared-styles.css`) and PostCSS config. + +```js +import "@query/tailwind-config"; +``` + +See [docs/tooling.md](../../docs/tooling.md). diff --git a/tooling/tailwind/package.json b/tooling/tailwind/package.json index df425c73..adee341d 100644 --- a/tooling/tailwind/package.json +++ b/tooling/tailwind/package.json @@ -10,7 +10,7 @@ "dependencies": { "@tailwindcss/postcss": "4.0.0", "next": "16.3.0", - "postcss": "8.5.18", + "postcss": "8.5.23", "react": "19.2.7", "react-dom": "19.0.0", "tailwindcss": "4.0.0" diff --git a/tooling/typescript/README.md b/tooling/typescript/README.md new file mode 100644 index 00000000..8ea700ea --- /dev/null +++ b/tooling/typescript/README.md @@ -0,0 +1,9 @@ +# `@query/tsconfig` + +Shared TypeScript configs: + +- `base.json` — default (`@query/tsconfig`) +- `nextjs.json` — Next.js apps +- `internal-package.json` — `packages/*` + +See [docs/tooling.md](../../docs/tooling.md). From b4aa60565d5c4792ae046ec5b4d0899c6073717b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sun, 16 Aug 2026 16:04:42 +0000 Subject: [PATCH 2/2] Address PR review: handler scan, CSP logs, Stripe errors Reject slash-separated inline handlers to match the old word-boundary regex, strip all control characters from CSP report logs, and keep a newline-safe Stripe error message for membership-grant failures. Co-authored-by: Aamogh --- .../api/src/.internal-tests/security.test.ts | 6 +++++ packages/api/src/trpc.ts | 14 +++++++----- .../app/(portal)/api/webhooks/stripe/route.ts | 22 +++++++++++-------- sites/mainweb/app/api/csp-report/route.ts | 7 +++--- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/api/src/.internal-tests/security.test.ts b/packages/api/src/.internal-tests/security.test.ts index aaaeab61..263a65f1 100644 --- a/packages/api/src/.internal-tests/security.test.ts +++ b/packages/api/src/.internal-tests/security.test.ts @@ -35,6 +35,12 @@ describe("Security and Protection Verification Suite", () => { scrubMarkup(''), ).toThrow(TRPCError); expect(() => scrubMarkup('')).toThrow(TRPCError); + // Slash is an attribute separator in HTML; the old `\bon` regex + // rejected this, and so must the linear scan. + expect(() => + scrubMarkup("
"), + ).toThrow(TRPCError); + expect(() => scrubMarkup("")).toThrow(TRPCError); }); it("refuses a javascript: URI even as plain text", () => { diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 88037fdb..303ba935 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -130,20 +130,22 @@ const isNameBoundary = (ch: string | undefined) => { * `onerror=` / `onload=` only count inside a tag. Matched loosely it would * reject prose like "onboarding = great". * + * A start-of-handler is a word boundary, same as the old `\bon` regex: `/` + * counts (`
` or 2048 chars), so this is linear in a * small window rather than in the whole payload. */ +const isWordChar = (ch: string) => + (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_"; + const hasInlineHandler = (lower: string, start: number, end: number) => { let pos = start; while (pos < end) { const on = lower.indexOf("on", pos); if (on === -1 || on >= end) return false; - if (on > start) { - const prev = lower[on - 1]!; - if (!isHtmlSpace(prev) && prev !== "<") { - pos = on + 1; - continue; - } + if (on > start && isWordChar(lower[on - 1]!)) { + pos = on + 1; + continue; } let k = on + 2; let n = 0; diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts index 79f588c0..23097ff9 100644 --- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts +++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts @@ -24,16 +24,20 @@ import { * (log injection) — and interpolating it into the message argument would let a * `%s` be read as a format directive. Both were flagged by CodeQL. */ -const safeLogId = (value: unknown) => +const sanitizeLogText = (value: unknown, max: number) => String(value ?? "") - .replace(/[^\w-]/g, "") - .replace(/[\n\r]/g, "") - .slice(0, 64); - -const safeLogError = (err: unknown) => - err instanceof Error - ? err.name.replace(/[\n\r]/g, "") - : "Error"; + .replace(/[\x00-\x1F\x7F\u2028\u2029]/g, " ") + .slice(0, max); + +const safeLogId = (value: unknown) => + sanitizeLogText(String(value ?? "").replace(/[^\w-]/g, ""), 64); + +const safeLogError = (err: unknown) => { + if (!(err instanceof Error)) return "Error"; + const name = sanitizeLogText(err.name, 64); + const message = sanitizeLogText(err.message, 180); + return message ? `${name}: ${message}` : name; +}; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; diff --git a/sites/mainweb/app/api/csp-report/route.ts b/sites/mainweb/app/api/csp-report/route.ts index 2c42f386..e0dae1c9 100644 --- a/sites/mainweb/app/api/csp-report/route.ts +++ b/sites/mainweb/app/api/csp-report/route.ts @@ -46,11 +46,12 @@ export async function POST(request: NextRequest) { // Two formats in the wild: the legacy `report-uri` shape // ({"csp-report": {...}}) and the newer Reporting API array. Log a - // newline-stripped copy — the body is attacker-controlled (this endpoint - // is unauthenticated) and a raw CR/LF would forge extra log lines. + // control-char-stripped copy — the body is attacker-controlled (this + // endpoint is unauthenticated) and control bytes can forge/mangle log + // output in some sinks. const forLog = body .slice(0, MAX_REPORT_BYTES) - .replace(/[\n\r]/g, " "); + .replace(/[\x00-\x1F\x7F\u2028\u2029]/g, " "); console.warn("[CSP] violation report:", forLog); } catch (error) { console.error("[CSP] failed to read a violation report:", error);