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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .claude/commands/add-feature-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
description: Add a feature flag to the correct system (global Remote Config vs per-user) and wire it up
allowed-tools: Bash, Read, Edit, Write, Grep, Glob, Skill
argument-hint: "<flagName> [and what it gates]"
---

Add a feature flag: $ARGUMENTS

**First load the `mobility-feature-flags` skill** — this repo has two independent flag systems and choosing
wrong is the common failure.

## 1. Choose the system

- **Firebase Remote Config** — global toggle/kill switch, flipped for everyone without a deploy, works for
logged-out users. Define in `src/app/interface/RemoteConfig.ts`.
- **Per-user flags** — the backend decides per user (entitlement, staged rollout). If it already appears in
`UserProfile.features[]` from `GET /v1/user`, it's this one. Define in
`src/app/interface/UserFeatureFlags.ts`.

If the request is ambiguous, ask which one rather than guessing — they have different sources of truth and
different security properties.

## 2. Define it

Add the key to the interface **and** to the defaults object in the same file. **Default to `false`** so
missing/unverifiable config fails closed. For per-user flags that single edit is sufficient — the ID type,
the hook, and `toUserFeatureFlags()` all derive from it.

## 3. Read it

```tsx
// Server Component — Remote Config
const config = await getUserRemoteConfigValues(); // applies the admin bypass
// Server Component — per-user
const { myNewFlag } = await getServerFlags();

// Client — Remote Config
const { config } = useRemoteConfig();
// Client — per-user
const { myNewFlag } = useUserFeatureFlags();
```

Gate with a ternary, not `&&` (skill rule `rendering-conditional-render`).

## 4. Check before you finish

- Does this flag gate **real access** (paywall, admin capability) rather than UI convenience? If per-user:
`POST /api/feature-flags` does not verify its caller, so a client could set its own value. Enforce access
server-side and flag the hardening need to the user.
- Is a same-named flag already in the *other* system? `isNotificationsEnabled` is in both — check for
duplication before adding.
- Don't read flags via `cookies()` inside a `static/` feed-detail route; it breaks static rendering.
- Don't put flags in Redux (`redux-persist` → `localStorage` leaks them across users on shared devices).

## 5. Verify

`yarn lint` and `yarn test:ci`. Then tell the user what still needs doing outside the code — creating the
parameter in the Firebase console, or the backend returning the new `features[]` entry.
50 changes: 50 additions & 0 deletions .claude/commands/add-translation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
description: Add or update i18n message keys in both en and fr, keeping the files in parity
allowed-tools: Bash, Read, Edit, Grep, Glob
argument-hint: "<namespace.key> [English text]"
---

Add or update translation keys: $ARGUMENTS

Messages live in `messages/en.json` and `messages/fr.json`. Existing namespaces: `common`,
`emailVerification`, `feeds`, `account`, `contactUs`, `gbfs`, `home`, `about`, `footer`. Prefer an existing
namespace over inventing one.

## Rules

1. **Always edit both files.** They are currently at full parity — every namespace present in `en.json`
exists in `fr.json`. Keep it that way; a key present in only one locale renders as the raw key.
2. Mirror the same nesting path and key order in both files so diffs stay readable.
3. Provide a real French translation. If you are not confident in the wording for a domain term (transit
jargon like "feed", "dataset", "validation report", "producer"), say so explicitly and mark it for review
rather than shipping a silent guess — check how the same term is already translated elsewhere in
`fr.json` first and stay consistent with it.
4. next-intl uses ICU message syntax. For interpolation use `{count}`, and use proper `plural`/`select`
blocks rather than string concatenation:
```json
{ "feedCount": "{count, plural, =0 {No feeds} one {# feed} other {# feeds}}" }
```
French plural rules differ from English — don't copy the English arms verbatim.
5. Do not hardcode user-facing strings in components. Read them via `useTranslations('namespace')` in client
components, `getTranslations()` from `next-intl/server` in server components.

## Consuming the key

```tsx
'use client';
import { useTranslations } from 'next-intl';
const t = useTranslations('feeds');
// t('myNewKey')
```

## Verify

- `node -e "require('./messages/en.json'); require('./messages/fr.json')"` — catches JSON syntax errors.
- Confirm parity, e.g.:
```bash
node -e "const en=require('./messages/en.json'),fr=require('./messages/fr.json');const f=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v!==null?f(v,p+k+'.'):[p+k]);const a=f(en),b=f(fr);console.log('only en:',a.filter(k=>!b.includes(k)));console.log('only fr:',b.filter(k=>!a.includes(k)));"
```
- `yarn lint` and `yarn test:ci`.

**Testing note:** `next-intl` is globally mocked in Jest and `useTranslations()` returns the key itself. So
tests assert on **keys**, not English text — don't update specs to expect your new English string.
34 changes: 34 additions & 0 deletions .claude/commands/e2e.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
description: Start the E2E stack (Next + MSW + Firebase emulator) and run Cypress specs
allowed-tools: Bash, Read, Edit, Grep, Glob
argument-hint: "[spec name or path, e.g. signin]"
---

Run the Cypress e2e suite. Target: $ARGUMENTS (empty means the whole suite).

The stack needs two services before Cypress can run — Next on **:3001** with MSW mocking enabled, and the
Firebase auth emulator on **:9099**.

1. Check whether they're already up (`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001` and
`:9099`). Don't start a second copy if they are.
2. If not, start the stack **in the background**: `yarn e2e:setup:dev` for iteration (uses `next dev`), or
`yarn e2e:setup` to match CI exactly (runs `next build` first — slower, but it's also the only real
typecheck gate). Prefer `:dev` unless the task is about reproducing a CI failure.
3. Wait for both ports to answer before proceeding — poll, don't sleep blindly.
4. Run the specs:
- whole suite: `yarn e2e:run`
- one spec: `CYPRESS_BASE_URL=http://localhost:3001 npx cypress run --spec "cypress/e2e/<name>.cy.ts"`
5. Report actual results. On failure, read the spec and check
`cypress/screenshots/` and `cypress/videos/`.

Things that will bite you:

- `cy.createNewUserAndSignIn()` **wipes all Firebase emulator accounts** — specs are not isolated from each
other in that respect.
- The session-renewal block in `cypress/e2e/userFeatureFlags.cy.ts` is `describe.skip` because the renewal
interval doesn't fire under CI's `next start`. Expect it to be skipped; don't un-skip casually.
- Local env comes from `.env.development`. `cypress/e2e/feed-isr-caching.cy.ts` needs `REVALIDATE_SECRET`
to be set there.
- Stop the background stack when you're done.

For patterns and custom commands, load the `mobility-testing` skill.
32 changes: 32 additions & 0 deletions .claude/commands/regen-api-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
description: Regenerate TypeScript types from the OpenAPI specs and fix resulting type errors
allowed-tools: Bash, Read, Edit, Grep, Glob
argument-hint: "[feed | gbfs | user | all]"
---

Regenerate generated API types. Target: $ARGUMENTS (default `all`).

Never hand-edit the generated files and never hand-write API response types — they come from the specs in
`external_types/`.

| Target | Command | Output |
|---|---|---|
| `feed` | `yarn generate:api-types` | `src/app/services/feeds/types.ts` |
| `gbfs` | `yarn generate:gbfs-validator-types` | `src/app/services/feeds/gbfs-validator-types.ts` |
| `user` | `yarn generate:user-api-types` | `src/app/services/user-service-api-types.ts` |

Steps:

1. If a spec in `external_types/` was updated, confirm that with `git diff` first so you know what changed.
2. Run the relevant command(s). The gbfs/user scripts pipe through `eslint --fix` automatically.
3. `git diff --stat` the generated file, then review the **semantic** changes — new/removed/renamed schema
fields, changed optionality, changed enum members. Summarize those for the user; don't just say
"regenerated".
4. Run `npx tsc --noEmit` to find call sites broken by the new types, and fix them.
5. If a schema alias or type guard in `src/app/services/feeds/utils.ts` needs updating to cover a new type,
update it there — that module is the ergonomic layer everything else should consume instead of indexing
raw `paths[...]`.
6. Run `yarn lint` and `yarn test:ci`.

Report the semantic diff and anything that needs a human decision (e.g. a field that became optional and
now needs a null check with a real fallback, not just `?? ''`).
29 changes: 29 additions & 0 deletions .claude/commands/verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Run the full local CI gate — lint, unit tests, and a typecheck
allowed-tools: Bash(yarn lint), Bash(yarn lint:fix), Bash(yarn test:ci), Bash(npx tsc --noEmit), Read, Edit, Grep, Glob
argument-hint: "[--fix]"
---

Run this project's complete pre-PR verification and report results honestly.

Arguments: $ARGUMENTS (if `--fix` is passed, run `yarn lint:fix` first)

Run all three in parallel where possible, since they're independent:

1. `yarn lint` — ESLint over `src`. Remember `prettier/prettier` is an **error** here, so formatting
failures fail CI.
2. `yarn test:ci` — the exact Jest command CI runs.
3. `npx tsc --noEmit` — **not part of CI and there is no `typecheck` script.** CI only catches type errors
indirectly via `next build`, so this is the step most likely to surface something new. Run it.

Then:

- Fix what you broke. Do **not** "fix" violations of rules that are deliberately disabled in
`eslint.config.mjs` — notably all `react-hooks/exhaustive-deps` and `rules-of-hooks`, and the
`no-unsafe-*` family.
- If `tsc` reports pre-existing errors unrelated to the current change, say so and list them separately
rather than folding them into your own work.
- Report each command's actual pass/fail state with the relevant output. If something fails and you can't
fix it, say that plainly instead of hedging.

Note: this does not run Cypress. For e2e use `/e2e`.
50 changes: 50 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"permissions": {
"allow": [
"Bash(yarn install)",
"Bash(yarn lint)",
"Bash(yarn lint:fix)",
"Bash(yarn test)",
"Bash(yarn test:ci)",
"Bash(yarn test:watch)",
"Bash(yarn build:prod)",
"Bash(yarn build:analyze)",
"Bash(yarn e2e:run)",
"Bash(yarn generate:api-types)",
"Bash(yarn generate:gbfs-validator-types)",
"Bash(yarn generate:user-api-types)",
"Bash(npx tsc --noEmit)",
"Bash(npx jest:*)",
"Bash(npx eslint:*)",
"Bash(npx prettier --check:*)",
"Bash(git status)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git stash list)",
"Bash(git worktree list)",
"Bash(gh pr view:*)",
"Bash(gh pr list:*)",
"Bash(gh pr diff:*)",
"Bash(gh run list:*)",
"Bash(gh run view:*)",
"Bash(gh issue view:*)",
"Bash(gh issue list:*)"
],
"ask": [
"Bash(git push:*)",
"Bash(gh pr create:*)",
"Bash(gh pr merge:*)",
"Bash(vercel:*)",
"Bash(npx firebase:*)",
"Bash(firebase:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./cypress.env.json)"
]
}
}
133 changes: 133 additions & 0 deletions .claude/skills/mobility-auth/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
name: mobility-auth
description: How authentication works in this app — server-side GCIP/IAP token minting, the md_session cookie, end-user context propagation, and Firebase Admin setup. Load this when touching anything that calls the Mobility Feed API from the server, mints or verifies tokens, reads md_session, adds a protected route or API route, changes login/logout/session-renewal behavior, or debugs 401/403 responses, "Firebase app already exists", or "Invalid GCIP ID token" errors.
---

# Authentication in Mobility Database Web

Canonical reference: `docs/Authentication.md`. This skill is the operational summary — read the doc for
env-var detail, Vercel deployment, and the full troubleshooting list.

## The one rule that explains the design

**The client's Firebase ID token is never forwarded to the server for API calls.** The Mobility Feed API is
behind Google Cloud IAP with Identity Platform (GCIP), and the server mints its own credentials. A client
token reaching a server API call is a bug.

Two independent tokens are in play:

| Token | Purpose | Origin |
|---|---|---|
| GCIP ID token | Satisfies IAP — `Authorization: Bearer` | Server-minted for a **service UID**, cached, refreshed ~5 min before expiry |
| User-context JWT | Tells the backend *which end user* — `x-mdb-user-context` | The `md_session` cookie's own JWT, re-used verbatim |

## Making an authenticated server-side API call

```tsx
import { getSSRAccessToken, getUserContextJwtFromCookie } from '../../utils/auth-server';

const [accessToken, userContextJwt] = await Promise.all([
getSSRAccessToken(),
getUserContextJwtFromCookie(),
]);
const feed = await getGtfsFeed(feedId, accessToken, userContextJwt);
```

Parallelize with `Promise.all` — these are independent. `getSSRAccessToken()` (`src/app/utils/auth-server.ts`)
is the **canonical** token provider; don't call `getGcipIdToken()` directly. The service functions in
`src/app/services/feeds/index.ts` all take `(…, accessToken, userContextJwt?)` and apply
`generateAuthMiddlewareWithToken(accessToken, userContextJwt?)`
(`src/app/services/api-auth-middleware.ts`) via a per-call use/eject pattern — the middleware is
deliberately **not** registered globally, so don't "simplify" it into one.

## The `md_session` cookie

Server-signed JWT, httpOnly, `sameSite=lax`, `secure` in production, **1 hour** TTL. Payload:
`uid`, optional `email`, `isGuest`, `iat`, `exp`. Signed with `NEXT_SESSION_JWT_SECRET`.

Lifecycle:

1. Client signs in with Firebase Auth (email/password, provider, or anonymous).
2. A Redux saga calls `setUserCookieSession()` (`src/app/services/session-service.ts`), which POSTs the
Firebase ID token to `/api/session`.
3. `src/app/api/session/route.ts` verifies that token with Firebase Admin, derives
`isGuest` from `sign_in_provider === 'anonymous'`, signs the session JWT, sets the cookie.
4. `AuthSessionProvider` (`src/app/components/AuthSessionProvider.tsx`) re-checks every 5 minutes and on
every `onIdTokenChanged`, renewing when stale. On a renewal it also refreshes user feature flags.
5. Logout → `DELETE /api/session` clears both `md_session` and `md_features`.

Reading it server-side:

- `getCurrentUserFromCookie()` → decoded `SessionPayload` (who the user is)
- `getUserContextJwtFromCookie()` → the raw verified JWT (to forward to the backend)
- `isMobilityDatabaseAdmin(email)` for admin checks

Sign/verify helpers live in `src/app/utils/session-jwt.ts`. `auth-server.ts` is `import 'server-only'` —
never import it from a client component.

## Firebase Admin

Initialize **only** via `getFirebaseAdminApp()` in `src/lib/firebase-admin.ts`. It reuses an existing app
(avoiding "Firebase app already exists"), prefers inline `GOOGLE_SA_JSON` over `GOOGLE_SA_JSON_PATH`, and
**fails fast rather than falling back to ADC** — that fail-fast is intentional; ADC fallback causes
confusing metadata-server errors locally and in serverless.

Client-side Firebase is the **compat** SDK (`src/firebase.ts`, `firebase/compat/app` + `firebase/compat/auth`),
though modular `firebase/auth` types are used in places. Under Cypress it points at the auth emulator on
:9099.

## Client-side auth state

Two surfaces, and they are not interchangeable:

- **Redux** `userProfile` (`src/app/store/profile-reducer.ts`) — `status` is a 10-value union. The
load-bearing distinction is `registered` vs `authenticated`, not merely logged-in/out. Selectors in
`profile-selectors.ts`.
- **`useAuthSession()`** (`AuthSessionProvider`) — session/cookie readiness, used by the context providers.

Auth logic lives in `src/app/store/saga/auth-saga.ts` (login variants, signup, logout, email verification,
password change/reset, token refresh, cookie session, flag application, cross-tab broadcast).
Cross-tab sync goes through `LOGIN_CHANNEL` / `LOGOUT_CHANNEL` in `src/app/services/channel-service.ts`.

## Protecting things

- **Pages**: wrap in `components/ProtectedPageWrapper.tsx` (plus `ReduxGateWrapper` where rehydrated state
or `useSearchParams()` is needed).
- **Feed detail**: don't hand-roll — `src/proxy.ts` already routes authed users to `.../authed/` and guests
to `.../static/`. See the `mobility-feed-caching` skill.
- **Server actions and route handlers**: authenticate them like API routes (skill rule
`server-auth-actions`). Verify the caller; don't trust a client-supplied body.

Known gap: `POST /api/feature-flags` does **not** verify its caller, unlike `/api/session`. Acceptable only
because today's flags are UI-only. Before any flag gates real access, add idToken verification there.

## Local development without Firebase access

Mock mode bypasses both the real API and Firebase:

```bash
npx msw init public/ # one time
NEXT_PUBLIC_API_MOCKING=enabled yarn start:dev:mock
```

`NEXT_PUBLIC_API_MOCKING=enabled` is checked in `auth-server.ts` (relaxes server auth),
`remote-config.server.ts` (returns defaults), `src/instrumentation.ts` (starts the MSW node server), and
`providers.tsx` (starts the browser worker). `LOCAL_DEV_NO_ADMIN=1` additionally bypasses Admin init.

## Env vars

Server-only: `GOOGLE_SA_JSON` (inline JSON, preferred) or `GOOGLE_SA_JSON_PATH`, `NEXT_SESSION_JWT_SECRET`,
`GCIP_API_KEY`, optional `GCIP_TENANT_ID` / `GCIP_SERVICE_UID` (default `iap-service-caller`).
Shared: `NEXT_PUBLIC_FIREBASE_PROJECT_ID`. Service account JSON must contain `project_id`, `client_email`,
`private_key`. Never expose any of these to client code.

## Debugging

| Symptom | Cause |
|---|---|
| "Firebase app already exists" | Admin initialized outside `getFirebaseAdminApp()` |
| "Invalid GCIP ID token" | Sent a Google OIDC token; IAP+Identity Platform requires a **GCIP** token |
| `ENOTFOUND` / metadata errors | No explicit credentials — ADC fallback attempt; set `GOOGLE_SA_JSON` |
| 401/403 from the Feed API | Missing or stale `accessToken`, or `getSSRAccessToken()` bypassed |
| Backend can't identify the user | `userContextJwt` not threaded through to the service call |
| MSW not intercepting | `NEXT_PUBLIC_API_MOCKING` unset, or `public/mockServiceWorker.js` missing |
Loading
Loading