# PROJECT.md — EventCentral

> Private working note. Not for the shared repo. See `.gitignore`.
> Last generated from repo state at commit `chore(backend): fix stale comments referencing the removed frontend/ harness` on branch `backend-hardening` (stacked on `eventer-form-integration`, stacked on `security-fixes`, stacked on `v1-prototype`). None of these branches are pushed to `origin` as of this writing.

---

## 1. What this is

EventCentral automates turning a CS Club event submission into a real Gitea pull request against the club website repo (`www/www-new`), gated behind a real login and a live team-membership check, with an optional Discord announcement once the PR exists. It replaces a fully manual process (someone hand-writes a markdown file, sorts an image into a folder, and opens a PR themselves).

It is a **working prototype**: the core pipeline (`backend/`) is deployed and has run in some form since mid-July 2026; the real login-gated form (`upstream/`) was built and verified working end-to-end today, but is not yet deployed anywhere, and the actual interactive login cannot be completed by anyone yet because no real OIDC client has been issued by the club's sysadmin team (syscom). If you only care about "does this work," the honest answer is: yes, provably, except for the one login step nobody can currently click through.

---

## 2. Quickstart

**Requires Node 20+.** (`backend/`'s own `package.json` declares `@types/node@^18`, but its test tooling — `vitest` — will not run under Node 16; `upstream/`'s `tsconfig.json`/`package.json` target Node 20 directly. Check `node --version` first.)

### Start `backend/`

```bash
cd backend
cp .env.example .env
```
Edit `.env` — nothing is *required* to start (everything degrades to a logged no-op), but for a real test you want at minimum:
```
UPSTREAM_API_SECRET=<any random string>
TARGET_GIT_SSH_URL=<a throwaway repo you control, NEVER www/www-new>
PR_OWNER=<you>
PR_REPONAME=<your throwaway repo>
ALLOWED_POSTER_ORIGINS=http://localhost:3001
PUBLIC_BASE_URL=http://localhost:8001
```
```bash
npm install
npm run compile     # tsc -> build/
npm run server      # node build/server.js
```
Success looks like: `EventCentral backend (v1 prototype) listening on http://localhost:8001` and `curl localhost:8001/health` → `{"status":"ok"}`. Runtime/hardware: trivial — a single-request Express app, no GPU, no meaningful RAM/CPU floor (Siracha, the club's sysadmin, suggested 2GB RAM / 0.5 CPU for a staging VM running both `backend/` and `upstream/`, per project conversation history — untested against that exact spec, but plausible for this workload).

### Start `upstream/`

```bash
cd upstream
cp .env.example .env
```
Edit `.env`:
```
BACKEND_URL=http://localhost:8001
BACKEND_SECRET=<must match backend/.env's UPSTREAM_API_SECRET exactly>
PUBLIC_BASE_URL=http://localhost:3001
ALLOWED_TEAMS=<a real LDAP group you're actually in — see §7>
OIDC_CLIENT_ID=<any non-empty string if you don't have a real one>
OIDC_CLIENT_SECRET=<any non-empty string if you don't have a real one>
SESSION_SECRET=<any string at least 32 characters>
```
```bash
npm install
npm run dev     # tsx watch src/server.ts
```
Success looks like: `Eventer upstream listening on :3001` and `curl -I localhost:3001/login` → `302` redirecting to `https://keycloak.csclub.uwaterloo.ca/...`.

> **UNKNOWN / blocked**: completing the actual login (clicking through Keycloak, being redirected back with a valid session) requires a real `OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` registered on the club's real Keycloak realm by syscom. This has never happened as of this writing. There is also a local-Keycloak-via-Docker dev setup referenced in project conversation history (a `docker-compose` + `keycloak-realm.json` under an `eventer/dev/` path) that **does not exist anywhere in this repository** — searched exhaustively, confirmed absent. Whoever described that setup either never committed it or it lives somewhere else entirely.

**Workaround used throughout this session to get past login for testing:** mint a session token directly with the same code the server uses, since the login page itself can't be completed:
```bash
cd upstream
npx tsx -e "
import { createSessionToken } from './src/auth-oidc/session';
const secret = new TextEncoder().encode('<same as SESSION_SECRET above>');
createSessionToken({ username: '<a real WatIAM username>', email: '<username>@uwaterloo.ca' }, secret).then(console.log);
"
```
Then in a browser on `localhost:3001`: `document.cookie = "eventer_session=<printed token>; path=/"`, and navigate to `localhost:3001/submit`. This produces a token cryptographically indistinguishable from a real post-login one — the two real guards (signature check, live LDAP check) still run for real. It was verified this way against the actual production LDAP server (`ldap1.csclub.uwaterloo.ca`) and the actual production Keycloak's discovery endpoint during this session.

### A successful full run looks like

1. Land on the real form at `/submit`, showing `Logged in as <user> (<email>), verified against <team> membership`.
2. Fill it out, submit.
3. Response body: `{ ok: true, status: 201, message: "Submitted (status: ...)", pullRequestUrl: ... }`.
4. `git ls-remote <your throwaway repo>` shows a new `event/<slug>` branch that wasn't there before.

---

## 3. Architecture

### Directory tree

```
EventCentral/
├── .drone.yml                  # entire CI/CD: one SSH step, deploys backend/ only, no tests gate it
├── deploy.sh                   # the actual deploy script Drone's SSH step fetches and runs
├── README.md                   # team-facing docs, kept in sync with reality (see §6 for its history of drifting)
├── backend/                    # "CSC Event PR Service" — the only piece actually deployed
│   ├── .env.example
│   ├── tsconfig.json
│   └── src/
│       ├── server.ts           # Express app: buildApp() (testable) + startup (backend/src/server.ts:19,118)
│       ├── auth.ts             # shared-secret bearer check, fail-closed in production
│       ├── validateRequest.ts  # hand-rolled strict field validation, incl. SSRF-guard poster origin allowlist
│       ├── jobStore.ts         # file-based, atomic-write job persistence with TTL eviction
│       ├── publishEvent.ts     # orchestrates the actual pipeline (clone -> ... -> Discord)
│       ├── gitClient.ts        # execFileSync wrappers around real git, with a timeout
│       ├── giteaClient.ts      # opens the real PR via Gitea's REST API
│       ├── posterHandler.ts    # downloads + validates a poster image
│       ├── eventFile.ts        # renders the event markdown + computes target paths
│       ├── discordNotifier.ts  # posts the Discord announcement via bot REST API (no Gateway, no separate service)
│       ├── uploadRoute.ts      # dev-only poster upload endpoint, not mounted in production
│       ├── types.ts            # Job/PublishRequest/EventInput/PosterInfo shapes
│       └── *.test.ts           # 39 tests total (added this session; there were 0 before)
└── upstream/                   # login gate + the real submission form — built, not yet deployed
    ├── .env.example
    ├── tsconfig.json
    └── src/
        ├── server.ts           # buildApp(deps) DI pattern; routes: /login, /callback, /submit (GET+POST), /upload
        ├── auth-oidc/
        │   ├── oidcClient.ts   # Keycloak discovery, auth URL, token exchange, ID token verification
        │   ├── session.ts      # signs/verifies the app's own session JWT (HS256, 1h TTL)
        │   ├── middleware.ts   # requireOidcSession + requireTeamAccess guards
        │   ├── ldapTeamClient.ts # live, anonymous LDAP group-membership check
        │   └── types.ts        # EventerSession, TeamDirectory interface
        ├── eventForm.ts        # the actual HTML page (server-rendered, vanilla JS, no framework)
        ├── uploadRoute.ts      # the REAL poster upload endpoint (login-gated, safe filenames)
        ├── backendClient.ts    # calls backend/'s publish endpoint with the shared secret
        ├── submitHandler.ts    # builds backend/'s payload from the raw form body
        ├── eventPayload.ts     # date/slug helpers (deliberately avoids `new Date()` server-side, see §6)
        └── *.test.ts           # 24 tests total
```

`frontend/` (a Next.js dev harness that bypassed login entirely) existed until this session and was deleted — see §6.

### Data flow, entry point to output

```
Browser
  → GET upstream:/submit          [requireOidcSession, requireTeamAccess]   upstream/src/server.ts:68-70
  → (optional) POST upstream:/upload  [same guards]                        upstream/src/server.ts:72-78
  → POST upstream:/submit         [same guards]                           upstream/src/server.ts:80-83
      → submitHandler.handleEventSubmission(body, session, backendConfig)  upstream/src/submitHandler.ts:38
          → eventPayload.{formatEventDate,deriveYearAndTerm,slugify}(startLocal)
          → backendClient.publishEvent(config, payload)                   upstream/src/backendClient.ts:20
              → POST backend:/api/events/publish  [requireUpstreamSecret]  backend/src/server.ts:40
                  → validateRequest.validatePublishRequest(body)           backend/src/validateRequest.ts:209
                  → jobStore.getOrCreateJob(publishRequest)                backend/src/jobStore.ts:80
                  → publishEvent.runPublishJob(job)                        backend/src/publishEvent.ts:30
                      → gitClient.cloneRepo / createBranch
                      → posterHandler.fetchAndValidatePoster(poster)   (if poster present)
                      → gitClient.writeBinaryFile / writeFile
                      → eventFile.renderEventMarkdown
                      → gitClient.commitAll / pushBranch
                      → giteaClient.makePullRequest
                      → discordNotifier.notifyDiscord(event)    (only if a PR was actually created)
                      → jobStore.updateJob(requestId, outcome)
                  ← JSON: { requestId, status, branchName, pullRequestUrl, discord, error }
      ← JSON: { ok, status, message, pullRequestUrl }
  ← rendered result shown in the page's result box
```

### Mermaid diagram

```mermaid
sequenceDiagram
    participant B as Browser
    participant U as upstream/
    participant LDAP as ldap1.csclub.uwaterloo.ca
    participant BE as backend/
    participant GIT as Target git repo
    participant GITEA as Gitea API
    participant DIS as Discord API

    B->>U: GET /login
    U->>U: build auth URL (Keycloak discovery)
    U-->>B: 302 -> Keycloak
    B->>U: GET /callback?code=...
    U->>U: exchange code, verify ID token, sign session JWT
    U-->>B: Set-Cookie eventer_session, 302 /submit

    B->>U: GET /submit
    U->>U: verify session signature
    U->>LDAP: live anonymous group-membership search
    LDAP-->>U: teams[]
    U-->>B: real HTML form

    B->>U: POST /upload (poster file)
    U-->>B: {sourceUrl, filename, contentType}

    B->>U: POST /submit (event JSON)
    U->>BE: POST /api/events/publish (Bearer shared secret)
    BE->>BE: validate request
    BE->>GIT: clone, branch, write, commit, push
    BE->>GITEA: POST pulls (if GITEA_API_KEY set)
    GITEA-->>BE: PR url (or skipped)
    BE->>DIS: GET roles, GET channel, POST message (only if PR created)
    BE-->>U: job result
    U-->>B: {ok, status, message, pullRequestUrl}
```

### Core abstractions

| Abstraction | Defined in | Represents | Depended on by |
|---|---|---|---|
| `Job` / `PublishRequest` / `EventInput` | `backend/src/types.ts:44,25,10` | The full lifecycle record of one submission, persisted as JSON | `jobStore.ts`, `publishEvent.ts`, `server.ts` |
| `PosterInfo` | `backend/src/types.ts:4` | `{sourceUrl, filename, contentType}` — the contract for a poster reference | `validateRequest.ts`, `posterHandler.ts`, `eventFile.ts` |
| `TeamDirectory` (interface) | `upstream/src/auth-oidc/types.ts:6` | Abstraction over "can I check if a user is on team X" | Implemented by `LdapTeamDirectory`; injected via `AppDependencies` so `server.test.ts` can substitute a fake |
| `AppDependencies` | `upstream/src/server.ts:39` | Dependency-injection bag (`teamDirectory`, `sessionSecret`, `backend`, `publish` override) passed to `buildApp()` | Enables `upstream/`'s tests to run without a real LDAP/backend |
| `BackendConfig` | `upstream/src/backendClient.ts:4` | `{baseUrl, secret}` — everything needed to call `backend/` | `submitHandler.ts`, injected `publish` fn in tests |

### External dependencies and boundaries

| Dependency | Used by | What breaks if it's gone |
|---|---|---|
| `keycloak.csclub.uwaterloo.ca` (real Keycloak) | `upstream/`'s `/login`, `/callback` | Login entirely unusable; everything behind it inaccessible |
| `ldap1.csclub.uwaterloo.ca`, port 389, anonymous bind | `upstream/`'s `requireTeamAccess` (`ldapTeamClient.ts`) | Every request past login gets a 500 (`team_check_failed`) instead of proceeding — see `middleware.ts:36-43` |
| Target git repo over SSH | `backend/`'s `cloneRepo`/`pushBranch` | Every publish attempt fails at the clone step |
| Gitea REST API | `giteaClient.ts`, only if `GITEA_API_KEY` set | Falls back gracefully to "PR skipped, logged" — this is a designed degradation, not a crash |
| Discord REST API | `discordNotifier.ts`, only if `DISCORD_NOTIFICATIONS_ENABLED=true` | Falls back gracefully to "notification skipped" — also designed, not a crash; failure here never fails the PR step (`discordNotifier.ts:157-162`) |
| Local filesystem: `backend/jobs/`, `backend/uploads/`, `upstream/uploads/` | Job persistence, dev uploads, real uploads | Job history/uploaded files lost; nothing else breaks structurally |

---

## 4. How it actually works

### The auth chain, concretely

Two independent checks run on **every single request** past login — not cached from a one-time login:

1. **`requireOidcSession`** (`upstream/src/auth-oidc/middleware.ts:14-25`): reads the `eventer_session` cookie, calls `verifySessionToken` (`session.ts:14-27`), which is a `jose.jwtVerify` against the app's own `SESSION_SECRET` (HS256, symmetric — not Keycloak's key at all; the app mints its own token after login, per `session.ts:6-12`). No signature match → redirect to `/login`.
2. **`requireTeamAccess`** (`middleware.ts:27-55`): takes the verified username, calls `LdapTeamDirectory.getTeamsForUser(username, candidateTeams)` (`ldapTeamClient.ts:12-51`). This does a real anonymous LDAP bind (`client.bind('', '')`, line 18), then for each candidate team searches `cn=<team>,ou=Group,dc=csclub,dc=uwaterloo,dc=ca` for a `base`-scope match, and checks whether `uid=<username>,ou=People,dc=csclub,dc=uwaterloo,dc=ca` appears in that group's `uniqueMember` attribute. A group that doesn't exist is treated as "not a member," not an error (`NoSuchObjectError` caught specifically, lines 33-38) — any *other* LDAP error propagates as a real 500.

Verified live during this session: `progcom` (the club-president-mandated required team, per a comment in `upstream/.env.example`) genuinely exists in LDAP with 9 real members; the account used for testing is on `webcom`, not `progcom` — confirmed by direct LDAP query, not assumed.

### The publish pipeline, one concrete example

Given this input to `POST /api/events/publish`:
```json
{
  "requestId": "event-hardening-smoke-test-1786023625661",
  "submittedByEmail": "j47ho@uwaterloo.ca",
  "event": {
    "name": "Hardening Smoke Test",
    "short": "verifying persistent job store + git timeout after refactor",
    "startDate": "August 12 2026 14:00",
    "online": true,
    "location": "Online",
    "descriptionMarkdown": "Confirming everything still works after the backend hardening pass.",
    "year": 2026,
    "term": "spring",
    "slug": "Hardening-Smoke-Test",
    "poster": null
  }
}
```
this actually happened, verified live: a real `git clone` of the target repo into a fresh `mkdtemp` dir (`gitClient.ts:14-18`), `git checkout -b event/Hardening-Smoke-Test`, a file written to `content/events/2026/spring/Hardening-Smoke-Test.md` with YAML frontmatter (`eventFile.ts:14-40`), a real commit and push, and — since no `GITEA_API_KEY` was configured — a logged "would have opened PR" instead of a real API call (`giteaClient.ts:27-31`). The resulting job record on disk:
```json
{ "status": "pr_skipped_no_credentials", "branchName": "event/Hardening-Smoke-Test", "pullRequestUrl": null, ... }
```
`term: "spring"` for an August date: `termForMonth` computes `TERMS[Math.trunc(monthIndex / 4)]` (`dateUtils.ts:18-20`) — August is month index 7, `Math.trunc(7/4) = 1`, `TERMS = ["winter","spring","fall"]` → `spring`. This is a direct port of `www-new`'s own term-boundary logic (comment at `dateUtils.ts:17` names the source file), not an independent invention.

### The Discord integration, and why it's not a separate service

`notifyDiscord` (`discordNotifier.ts:128-163`) is a plain async function, called in-process, directly from `publishEvent.ts:70`, only after `prResult.created` is true. It makes three sequential outbound REST calls with `Authorization: Bot <token>` — no Gateway connection, no listening port of its own:
1. `GET /guilds/{guildId}/roles` — confirms the configured role exists and is mentionable.
2. `GET /channels/{channelId}` — confirms it belongs to the guild and is a text/announcement channel (`type` 0 or 5).
3. `POST /channels/{channelId}/messages` — the actual announcement, with `allowed_mentions` restricted to exactly the one role (never `@everyone`, never the submitter).

This was ported from a completely separate, independently-built repo (`k39jin/CSC_DiscordBot`, see §6) that implemented the *entire* pipeline a second time — only this notification logic was extracted and rewritten to run in-process here.

### Domain concepts a newcomer needs

- **WatIAM username**: the university's account identifier (e.g. `j47ho`), also the LDAP `uid` and the local part of the `@uwaterloo.ca` email. `submitHandler.ts:78-80` relies on `username@uwaterloo.ca` as a fallback when the OIDC token has no `email` claim — **unverified against a real login**, see §7.
- **Idempotency via requestId**: `jobStore.getOrCreateJob` (`jobStore.ts:80-101`) treats a repeated `requestId` with an identical body as a safe retry (returns the cached job, does no work); a repeated `requestId` with a *different* body throws `RequestConflictError` → HTTP 409. Body equality is `JSON.stringify(a) === JSON.stringify(b)` (`jobStore.ts:47-49`) — relies on stable key ordering, which holds today because both objects are always constructed via the same object-literal code path.

---

## 5. Configuration and parameters

| Variable | Where read | Default | Principled or arbitrary |
|---|---|---|---|
| `UPSTREAM_API_SECRET` | `backend/src/auth.ts:4` | unset → dev-permissive; **required** in `NODE_ENV=production` (throws otherwise, `auth.ts:11-15`) | Principled — the only auth gate on `backend/` |
| `ALLOWED_POSTER_ORIGINS` | `backend/src/validateRequest.ts:23-30` | `PUBLIC_BASE_URL` or `http://localhost:{PORT\|\|8001}` | Principled — deliberately narrow (SSRF fix), not "any https URL" |
| `GIT_TIMEOUT_MS` | `backend/src/gitClient.ts:8` | `30000` | **Arbitrary** — my choice this session, never tuned against real network conditions |
| Job TTL | `backend/src/jobStore.ts:14` (`JOB_TTL_MS`) | `24 * 60 * 60 * 1000` (24h, hardcoded, no env override) | Arbitrary — my choice this session |
| `MAX_FILE_SIZE_IN_BYTES` | `backend/src/posterHandler.ts:9` | `15 * 1_000_000` | Principled-by-inheritance — comment says "same limits as the legacy Eventer app" |
| `SQUARE_TOLERANCE` | `backend/src/posterHandler.ts:11` | `0.2` (±20%) | Principled-by-inheritance, same as above; original rationale not in this repo |
| `SESSION_TTL_SECONDS` | `upstream/src/auth-oidc/session.ts:4` | `3600` (1h) | > **UNKNOWN**: comment says "per R3 in auth-requirements.md" — that document does not exist anywhere in this repository. The value is real and load-bearing; its original justification is not verifiable here. |
| `ALLOWED_TEAMS` | `upstream/src/server.ts:96-99` (env `ALLOWED_TEAMS`, comma-split) | `progcom` | Principled — per a comment citing "the CSC president's explicit decision" |
| `MAX_BODY_SIZE` | `backend/src/server.ts:14` | `"1mb"` | Arbitrary-but-reasonable, predates this session |

---

## 6. Design decisions and history

- **Two people wrote every commit in this repo's entire history**: `jordanjho <jordanjho@gmail.com>` (Jordan Jho — referred to elsewhere in project conversation as "Juker") wrote the OIDC/LDAP scaffolding and the original (later removed) form/PR-handoff attempt in `upstream/`; `Tian Yi Tong Tong` wrote the initial `backend/` prototype, ported the real Discord integration, and everything from this session (security fixes, the real form, backend hardening). *Inferred* from `git log --all --format='%an <%ae>'`, cross-referenced with project conversation history identifying "Juker" as Jordan.
- **`upstream/` originally had a form and a direct PR handoff, then had it removed.** Commit `52d5074`, "refactor(upstream): scope to auth only, drop event form and PR handoff," predates this session. The branch that commit came from (`feature/eventer-auth-integration`, aka PR #2) was checked this session by rebasing it onto the latest mainline: **zero net diff** — it was already fully absorbed, nothing left to merge. That PR can be closed as a no-op.
- **Per-submitter allowlist removed in favor of live LDAP.** `backend/.env.example` comment (lines around `UPSTREAM_API_SECRET`): keeping both the old static `allowedUsers.json`-style check *and* the new live LDAP check risked the two silently drifting apart as the real roster changed. One source of truth was chosen deliberately.
- **A second, fully independent implementation of this entire service exists**: `k39jin/CSC_DiscordBot` (Kaius). Confirmed by cloning it directly and running its own test suite (23/23 passing, real Node 22, real `tsc --noEmit`) — not just reading its README. The team decision (per commit `97d3677`, "Note: backend/ is the one going forward, not CSC_DiscordBot") was to continue with this repo's `backend/`, porting over only the Discord-notification logic. Kaius's implementation is measurably more defensive in several ways not yet adopted here: DNS-resolution-based SSRF checking with redirect re-validation, streaming size-limit enforcement, image-magic-byte verification, a generic retry-with-backoff helper applied to git/Gitea/poster calls, and pre-write file-collision detection. None of that has been ported except the file-persistent job store and the git command timeout, both added this session specifically because they closed gaps this repo's own README already flagged as deferred.
- **Job store: in-memory → TTL-evicted in-memory → file-persisted.** Started as a bare `Map` (lost on every restart). This session first added TTL eviction to stop unbounded growth, then replaced the whole thing with one-JSON-file-per-job, atomic writes (temp file + rename), directly modeled on the equivalent piece in Kaius's implementation — closing the exact gap the README had listed as deferred ("Job persistence... needs a real store before this runs unattended").
- **`upstream/` hosts the real form itself, rather than a separate frontend app.** *Inferred, not stated anywhere in the repo*: the session cookie is `httpOnly`+`secure`+`sameSite: lax`; keeping the form same-origin with the session-issuing app avoids cross-origin cookie complexity a separate frontend would introduce for no clear benefit at this scale.
- **CSRF protection relies entirely on the existing `SameSite=Lax` cookie setting** (`upstream/src/server.ts:151,185`), with no separate CSRF token added when the new `POST /submit`/`POST /upload` routes were built this session. This was a unilateral implementation call, explicitly flagged during this session as needing real team sign-off, not a considered team decision.
- **`frontend/` (Next.js dev harness) was deleted this session.** It predated `upstream/`'s real form and existed only so `backend/` could be exercised without a working Keycloak client; kept the confusing property of being named "frontend" while not being the real frontend. Removed once the real form existed and had been used throughout this session's testing; `backend/`'s equivalent dev-only route (`POST /api/dev-uploads`) was kept for exercising `backend/` in isolation via curl.
- **A real project incident shaped the current emphasis on tests and small commits.** Per project conversation history: a prior, unreviewed, undemoed state of this codebase was connected to review/deploy discussion before its actual maturity was communicated clearly, leading to a public cross-team escalation about code quality and unverified "it was tested" claims. This session's practice of small, individually-tested, individually-explained commits (rather than one large unreviewed change) is a direct, deliberate response to that history, not an arbitrary style choice.

---

## 7. Known issues, limitations, and traps

Blunt, on purpose.

- **A legitimate, expected validation failure returns HTTP 500, not 400.** Confirmed live this session: submitting a non-square poster image correctly gets rejected by `posterHandler.ts`, but `server.ts:72` maps *any* `status === "failed"` job to HTTP 500 — indistinguishable, from the response code alone, from an actual server crash. **Not yet fixed.** Anyone debugging a "500 error" report should check the job's `error` field before assuming something broke.
- **No rate limiting anywhere**, on any route, in either app. A single valid session (or a leaked/misused secret) can trigger unlimited real git operations.
- **CSRF relies solely on `SameSite=Lax`**, not an explicit token — see §6. Defensible but never explicitly approved.
- **The email fallback (`username@uwaterloo.ca`) is unverified against a real login**, since no real login has ever completed. If Keycloak's `email` claim is ever absent or differently formatted for some accounts, this silently produces a wrong `submittedByEmail`.
- **`bodiesMatch`'s `JSON.stringify` equality check** (`jobStore.ts:47-49`) is correct today only because both compared objects are always built the same way. A future refactor that changes construction order for one but not the other would silently break idempotency detection — this would fail *quietly*, showing a false `RequestConflictError` (409) rather than an obvious crash.
- **`cloneRepo` does a full fresh `git clone` over SSH on every single publish**, not an incremental fetch against a local mirror (contrast with Kaius's `--shared` local-clone approach in `gitPublisher.ts`). Fine at "a few events a week" volume; a real scale limit if usage ever grows meaningfully.
- **The job-store eviction sweep reads every job file on disk on every new submission** (`jobStore.ts:56-70`). Cheap at current volume; would need rethinking well before job count reached the thousands.
- **Silent-looking-fine failure mode**: Discord notification failures never fail the PR (`discordNotifier.ts:157-162`, by design) — correct behavior, but it means "the PR opened but nobody got pinged" looks, to a human glancing at the PR itself, identical to "everything worked." The job's `discord.code` field is the only place this is visible.
- **A newcomer would plausibly "simplify" the file-based job store back to an in-memory Map**, since the in-memory version reads simpler. That would silently reintroduce total data loss on every restart — the whole reason it was changed.
- **A newcomer would plausibly remove `auth.ts`'s "refuse to start in production without a secret" throw**, since it looks like startup friction. It is the single most important line of code in this service — see §6, "per-submitter allowlist removed."
- **`upstream/` has zero deploy automation.** Only `backend/` is wired into `.drone.yml`. Deploying `upstream/` today would be entirely manual.
- **The local-Keycloak dev setup referenced in project history does not exist in this repo** — see §2. Anyone following those old instructions verbatim will get stuck immediately.
- **Kaius's `CSC_DiscordBot` process's current live status is genuinely unknown.** > UNKNOWN: whether it's still running independently anywhere, separate from this codebase.
- **The "Codey vs. a new bot" decision for the real production Discord bot is unresolved**, per project conversation history (Kaius asked, got "no preference, ask webcom," never closed out).

---

## 8. Testing and validation

- **`backend/`: 39 tests, `vitest run`** (`backend/src/*.test.ts`). Covers: auth's fail-closed-in-production behavior and constant-time comparison (`auth.test.ts`); validation rules including the SSRF-guard origin allowlist (`validateRequest.test.ts`); the file-based job store's persistence-across-a-fresh-import (standing in for a restart), idempotency, conflict detection, and TTL eviction (`jobStore.test.ts`); the git timeout actually killing a hung `git commit` via a real sleeping `pre-commit` hook, not a mock (`gitClient.test.ts`); and the HTTP routes end-to-end via `supertest`, with `runPublishJob` mocked out so these don't do real git/Gitea/Discord work (`server.test.ts`).
- **`upstream/`: 24 tests, `vitest run`** (`upstream/src/**/*.test.ts`). Covers: session JWT sign/verify (`session.test.ts`); the LDAP client against a fake `Client` (`ldapTeamClient.test.ts`); the two auth guards (`middleware.test.ts`); OIDC discovery/token-exchange/verification (`oidcClient.test.ts`); and the real form/upload/submit routes end-to-end, with `publish` injected as a mock so no real `backend/` is needed (`server.test.ts`).
- **What is NOT covered by any automated test:**
  - The actual Keycloak login handshake past the redirect (no real client credentials exist to test with).
  - Real LDAP, real Gitea, real Discord — all mocked/injected in the test suites. These were separately verified *manually*, live, during this session (real LDAP queries, a real redirect to production Keycloak, real branches pushed to a throwaway repo) — but that verification is not repeatable by running `npm test`; it was one-off, interactive, and not automated into anything.
  - The browser-side JavaScript inside `eventForm.ts` (the fetch calls, the confirm dialog, the result rendering) has never executed inside any test — only manually, by clicking through it in a real browser during this session.
- **How do we currently know the output is correct?** For the pipeline mechanics (validation, git operations, job persistence, Discord API shape): the test suites, plus one live manual run that pushed a real branch with verified-correct file content. For the auth chain: a live manual run against real LDAP and real Keycloak (up to the point real credentials are needed). For the actual end-user experience of a real login: **we don't know** — nobody has ever completed one.

---

## 9. State of play

**Works today, provably:** the full pipeline from a (stood-in) authenticated session through to a real branch push, including the live team-membership check against real LDAP, is proven working — this was demonstrated interactively, repeatedly, this session, not just claimed.

**Half-finished, with visible seams:**
- `upstream/`'s deploy story — code exists, deploy automation doesn't.
- The Gitea PR and Discord notification steps — the code paths are real and tested, but nobody has yet run them against real credentials in this environment (a throwaway Gitea token + test Discord bot were being set up as of this writing, not yet confirmed working end-to-end).
- The 500-vs-400 status code issue — identified, not fixed.

**Next, in priority order:**
1. Get a real OIDC client from syscom — this is the single blocking item for anyone to complete a real login.
2. Fix the HTTP 500-vs-400 status mapping for caller-fault job failures (`server.ts:72`).
3. Push the three local branches (`security-fixes`, `eventer-form-integration`, `backend-hardening`) so this work is actually visible to the team — as of this writing, none of it is.
4. Decide, as a team, on the CSRF/rate-limiting open questions flagged in §6/§7 rather than leaving them as one person's implicit call.
5. Set up a staging environment per the sysadmin team's offer (small VM/LXC), now that the services/functionality answer is settled (LDAP read-only, Keycloak outbound, Gitea read/write to a throwaway repo, Discord outbound) — plus resolve the still-open question of inbound access for anyone besides the sysadmin/dev team to actually reach a staged form.

---

## 10. Learning path

Ordered foundational → advanced, honest about difficulty:

1. **HTTP fundamentals / Express middleware chains** — foundational to reading any route in either app. Shows up everywhere; `upstream/src/server.ts`'s stacked `requireSession, requireTeam, handler` pattern is the clearest example. Docs: expressjs.com/en/guide/using-middleware.html.
2. **OAuth 2.0 Authorization Code flow** — needed to understand `/login` → `/callback`. This project's implementation is a minimal, mostly-correct hand-rolled version, not a library like `passport`. Reference: RFC 6749 §4.1, or Auth0's "Authorization Code Flow" explainer for a gentler version.
3. **JWTs and HMAC signing** — needed for `session.ts`. The `jose` library does the heavy lifting; understanding *why* HS256 with a symmetric secret is appropriate here (server signs and verifies its own token, no third party needs to verify it) is the actual conceptual piece. jwt.io's debugger is a fast way to build intuition.
4. **LDAP basics: DNs, binds, group membership via `uniqueMember`** — needed for `ldapTeamClient.ts`. This project's usage (anonymous bind, base-scope search) is about as simple as LDAP gets; still worth understanding the DN structure (`uid=x,ou=People,dc=...`) since it's hand-constructed as a string, not abstracted away. RFC 4511 for the protocol; skimmable.
5. **Idempotency keys** — needed for `jobStore.ts`'s `getOrCreateJob` design. Stripe's API docs on idempotency keys are the clearest practical explainer of this exact pattern, despite being from an unrelated product.
6. **SSRF and why "validate the URL scheme" isn't enough** — needed to actually understand why `ALLOWED_POSTER_ORIGINS` exists and why Kaius's DNS-resolution-based approach in his own repo is more general. OWASP's SSRF cheat sheet is the standard reference.
7. **Dependency injection for testability without a DI framework** — the `AppDependencies` / injectable `publish` function pattern in `upstream/`. This is the part most likely to feel unfamiliar coming from smaller scripts; the pattern itself is simple (pass functions/objects as parameters instead of importing singletons directly) but seeing *why* it's needed requires reading `server.test.ts` alongside `server.ts`.
8. **Honest self-assessment**: rebuilding the OIDC/JWT/LDAP layer from scratch, correctly, including its edge cases (state-parameter CSRF protection on the login flow, JWKS key rotation handling via `jose.createRemoteJWKSet`) would be the hardest part of this project to redo without referencing this code directly — that layer has more subtle correctness requirements than anything else here.

---

## 11. Glossary

- **OIDC (OpenID Connect)** — an identity layer on top of OAuth 2.0; how login actually happens here.
- **JWT (JSON Web Token)** — a signed, encoded token; used both for Keycloak's ID token and this app's own session token (two different JWTs, two different keys).
- **JWKS** — JSON Web Key Set; the public keys Keycloak publishes so this app can verify ID token signatures without a shared secret.
- **LDAP** — Lightweight Directory Access Protocol; the club's directory service used for team-membership checks.
- **DN (Distinguished Name)** — an LDAP entry's full path-like identifier, e.g. `uid=j47ho,ou=People,dc=csclub,dc=uwaterloo,dc=ca`.
- **WatIAM** — University of Waterloo's identity system; usernames here double as LDAP `uid`s and the local part of `@uwaterloo.ca` emails.
- **SSRF (Server-Side Request Forgery)** — tricking a server into making a request to an address it shouldn't reach; the reason `ALLOWED_POSTER_ORIGINS` exists.
- **CSRF (Cross-Site Request Forgery)** — tricking a logged-in browser into making an unwanted request; mitigated here only via `SameSite=Lax` cookies.
- **Idempotent** — safe to repeat with the same result; the property `requestId` reuse is designed to provide.
- **Bearer token** — a credential sent as `Authorization: Bearer <value>`; how `upstream/` authenticates to `backend/`.
- **Slug** — the URL/filename-safe short identifier derived from an event's name.
- **Term** (winter/spring/fall) — the club's own academic-term bucketing, derived from month, ported directly from `www-new`'s own logic.
- **Job** — this project's word for one submission's tracked lifecycle record (`processing` → `pr_created`/`pr_skipped_no_credentials`/`failed`/etc.).
- **Gitea** — the self-hosted git/PR platform the club uses instead of GitHub.
- **Fork-based PR flow** — the (currently unused, `FORK_OWNER` env var) intended pattern of pushing to a bot-owned fork rather than the target repo directly.
