1328 lines
68 KiB
Markdown
1328 lines
68 KiB
Markdown
# Phase 02 Authentication, Users & Trip Core Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Turn the Phase 01 foundation into a working, multi-tenant travel-planning core: OIDC Authorization Code + PKCE sign-in against an existing external Identity Provider, just-in-time local `User` provisioning, versioned/reproducible database migrations with no schema auto-sync, `Trip`/`TripSettings`/`TripMember`/`TripInvitation`/`Traveler`/`TripPreferenceOverride` persistence with optimistic locking on `Trip`, backend-enforced trip-membership authorization, and a minimal Angular login + trips-list + create-trip + members UI that keeps the existing PWA working end to end.
|
||
|
||
**Architecture:** The Angular PWA performs the OIDC Authorization Code flow with PKCE directly against the configured external IdP using `oidc-client-ts` (a framework-agnostic client so the choice is not coupled to Angular's own release cadence); it stores the resulting access token client-side and attaches it as a `Bearer` header to every `/api/v1/*` request via an `HttpInterceptorFn`. NestJS never sees a password and never mediates the token exchange — it is a stateless OIDC resource server. An `OidcDiscoveryService` fetches and caches `${OIDC_ISSUER}/.well-known/openid-configuration`, and an `OidcAuthGuard` verifies each bearer token's signature/issuer/audience/expiry using `jose` against the IdP's JWKS (`createRemoteJWKSet` in production, `createLocalJWKSet` in unit tests via dependency substitution). On a valid token, the guard performs just-in-time provisioning: an idempotent `INSERT ... ON CONFLICT (external_subject_id) DO UPDATE` upserts a local `User` row keyed by the stable `sub` claim and refreshes `displayName`/`email` from the token's `name`/`email` claims, then attaches `{ id, externalSubjectId, email, displayName }` to `req.user`. No local password is ever stored, matching the design spec's hard requirement.
|
||
|
||
For database access this phase deliberately does **not** introduce a full ORM (TypeORM/Prisma-style `synchronize: true` is explicitly forbidden by the design spec, and the design spec does not mandate any specific ORM). Instead it adds two narrowly-scoped, currently-maintained libraries on top of the raw `pg.Pool` that `backend/libs/infrastructure` already provides: `node-pg-migrate` for versioned, reproducible, plain-SQL/JS migrations (no schema-diffing magic, so there is never an auto-sync footgun), and `kysely` as a thin, type-safe SQL query builder (not an ORM — it has no entity/change-tracking layer to accidentally auto-sync). Both are wired to reuse the existing `POSTGRES_POOL` token from Phase 01 rather than opening a second connection pool. Domain code depends on a new `backend/libs/database` library exposing a typed `Kysely<Database>` instance under a `KYSELY_DB` injection token; repositories in `backend/libs/users` and `backend/libs/trips` are the only code that imports Kysely directly, matching the design spec's layering (`controller -> application service -> domain rules -> repository -> infrastructure`).
|
||
|
||
Authorization for trip-scoped resources is enforced with a second guard, `TripMembershipGuard`, combined with a `@TripRoles(...)` method decorator read via `Reflector`. `OidcAuthGuard` establishes *who* the caller is; `TripMembershipGuard` establishes whether that local user has an `ACTIVE` `trip_members` row for the `:tripId` route param (and, if `@TripRoles('OWNER')` is present, whether their role qualifies). Both guards are applied explicitly per-controller/per-method with `@UseGuards(...)` — there is no global `APP_GUARD` — so that `/health/*` and the existing public `/api/v1/version` endpoint are unaffected. `TripMember` (an application user with access to a trip, tracked in `trip_members`) and `Traveler` (a person the trip is planned for, tracked in `travelers`, who may or may not have an account) are modeled as two independent tables with no foreign key from one to the other; `travelers.linked_user_id` is an optional, purely informational pointer to `users.id` for UI convenience ("this traveler happens to be one of our app users") and never grants trip access by itself — access always flows through a real `trip_members` row. A user can simultaneously be a `TripMember` row and be linked from a `Traveler` row; the two facts are independent and both are tested as such.
|
||
|
||
Optimistic locking is applied to `Trip.version` only, matching the design spec's `Trip` field list exactly (the spec's `TripSettings` field list has no `version` column, and Phase 02 restricts `TripSettings` writes to the trip `OWNER`, which is a single logical writer role per trip, so silent-overwrite risk is materially lower than for `Trip` itself, which any member with write access to trip metadata could edit concurrently in later phases). `Trip` updates require the caller to submit the `version` they last read; the update statement's `WHERE version = :expectedVersion` clause is what actually enforces the check — if zero rows match, the service raises `ConflictException` (HTTP 409) and the caller must reload. This is proven both by a unit test against a mocked repository and by an integration test against a real `compose.dev.yml` PostgreSQL instance.
|
||
|
||
---
|
||
|
||
## Tech Stack
|
||
|
||
Backend additions (installed into `backend/package.json` `dependencies` unless noted):
|
||
- `kysely` (`^0.28.0`) — type-safe SQL query builder over the existing `pg.Pool`.
|
||
- `node-pg-migrate` (`^7.9.0`) — versioned migration runner (JS/CJS migration files, programmatic `runner()` API), no schema auto-sync capability of any kind.
|
||
- `jose` (`^5.9.0`) — JWT verification (`jwtVerify`, `createRemoteJWKSet`, `createLocalJWKSet`) without pulling in the full Passport ecosystem for a single bearer-token strategy.
|
||
- Node's built-in `node:crypto` (`randomBytes`, `createHash`, `randomUUID`) for invitation token generation/hashing — no extra dependency.
|
||
|
||
Frontend additions (installed into `frontend/package.json` `dependencies`):
|
||
- `oidc-client-ts` (`^3.x`, latest maintained release — verify current version at implementation time) — framework-agnostic Authorization Code + PKCE client; wrapped by a small Angular `AuthService` rather than adopting an Angular-specific OIDC wrapper package, to avoid coupling the OIDC library's peer-dependency support matrix to this repository's Angular 21.2.x version.
|
||
|
||
No changes to NestJS core packages, Terminus, `pg`, or `ioredis` versions pinned in Phase 01.
|
||
|
||
---
|
||
|
||
## Global Constraints
|
||
|
||
- No local password storage, ever. Authentication is OIDC Authorization Code + PKCE only.
|
||
- `User.externalSubjectId` (the OIDC `sub` claim) is the only identity key trusted for authentication; it is immutable once provisioned.
|
||
- No ORM `synchronize: true` / schema auto-sync in any environment, including tests. All schema changes go through a versioned `node-pg-migrate` migration file.
|
||
- The compiled migration entry point contract from Phase 01 is preserved: `docker compose run --rm --no-deps api node backend/dist/apps/api/src/migration.js` must still work; only the body of `runMigrations()` changes.
|
||
- No new TypeScript path aliases. Continue the Phase 01 convention of relative imports between `apps/*` and `libs/*` (e.g. `'../../../libs/configuration/src'`), matching what `backend/apps/api/src/api.module.ts` already does.
|
||
- Backend tests continue to use `ts-jest` (not `@swc/jest`); do not introduce a second test-runner configuration for new libs.
|
||
- Frontend stays on Angular 21.2.x; do not upgrade to 22.x as part of this phase. New components follow the existing `*.ts`/`*.html`/`*.scss`/`*.spec.ts` naming convention (no `.component.` infix).
|
||
- Reuse the existing `POSTGRES_POOL` (`backend/libs/infrastructure/src/postgres/postgres.module.ts`) for all Kysely access; do not open a second PostgreSQL connection pool.
|
||
- Only public, non-secret OIDC configuration (issuer URL, client id, redirect URI, scopes) may be embedded in the Angular production bundle. No client secret exists for this PKCE public client and none is ever introduced.
|
||
- `OidcAuthGuard` and `TripMembershipGuard` are applied explicitly per controller/method; there is no global `APP_GUARD`. `/health/live`, `/health/ready`, and `/api/v1/version` remain unauthenticated, exactly as Phase 01 left them.
|
||
- Trip membership/role checks happen in guards/services, never only in the frontend and never only implied by the shape of a request.
|
||
- `Trip.version` optimistic locking is mandatory; a stale write (mismatched `version`) must be rejected with HTTP 409, never silently overwritten.
|
||
- Do not add Mistral, agent tools/logic, web research, itinerary/activities, watch items, notifications, bookings, or budget entities in this phase.
|
||
- Exactly one TCP port from production Compose is published (`edge`); this phase does not change that invariant, and `pnpm test:compose` must keep passing.
|
||
- Secrets (OIDC client secret if the IdP ever requires one for a confidential flow, database credentials) are never committed; only non-secret configuration names/defaults go into `.env.example`.
|
||
|
||
---
|
||
|
||
## File Structure Locked by This Phase
|
||
|
||
```text
|
||
travel-planner/
|
||
├── .env.example # + OIDC_* names/placeholders
|
||
├── compose.yml # + api/worker OIDC env wiring
|
||
├── docker/
|
||
│ └── api.Dockerfile # + COPY backend/migrations
|
||
├── backend/
|
||
│ ├── package.json # + kysely, node-pg-migrate, jose; + test:integration script
|
||
│ ├── jest-integration.json # new: integration test project (real Postgres)
|
||
│ ├── migrations/
|
||
│ │ ├── <timestamp>_create-users.cjs
|
||
│ │ ├── <timestamp>_create-user-preferences.cjs
|
||
│ │ ├── <timestamp>_create-trips.cjs
|
||
│ │ ├── <timestamp>_create-trip-settings.cjs
|
||
│ │ ├── <timestamp>_create-trip-members.cjs
|
||
│ │ ├── <timestamp>_create-trip-invitations.cjs
|
||
│ │ ├── <timestamp>_create-travelers.cjs
|
||
│ │ └── <timestamp>_create-trip-preference-overrides.cjs
|
||
│ ├── apps/
|
||
│ │ ├── api/src/
|
||
│ │ │ ├── api.module.ts # + imports for new feature modules
|
||
│ │ │ ├── migration.ts # replaced: real node-pg-migrate runner
|
||
│ │ │ ├── auth/
|
||
│ │ │ │ └── current-user.decorator.ts
|
||
│ │ │ ├── users/
|
||
│ │ │ │ ├── users.controller.ts
|
||
│ │ │ │ ├── users.controller.spec.ts
|
||
│ │ │ │ └── users.module.ts
|
||
│ │ │ └── trips/
|
||
│ │ │ ├── trips.controller.ts
|
||
│ │ │ ├── trips.controller.spec.ts
|
||
│ │ │ ├── trip-members.controller.ts
|
||
│ │ │ ├── trip-members.controller.spec.ts
|
||
│ │ │ ├── trip-invitations.controller.ts
|
||
│ │ │ ├── trip-invitations.controller.spec.ts
|
||
│ │ │ ├── travelers.controller.ts
|
||
│ │ │ ├── travelers.controller.spec.ts
|
||
│ │ │ ├── trip-preference-overrides.controller.ts
|
||
│ │ │ ├── trip-preference-overrides.controller.spec.ts
|
||
│ │ │ └── trips.module.ts
|
||
│ │ └── worker/src/worker.module.ts # unchanged in this phase
|
||
│ └── libs/
|
||
│ ├── database/src/
|
||
│ │ ├── schema.ts # Kysely `Database` interface
|
||
│ │ ├── kysely.module.ts # KYSELY_DB provider (wraps POSTGRES_POOL)
|
||
│ │ └── index.ts
|
||
│ ├── auth/src/
|
||
│ │ ├── oidc-discovery.service.ts
|
||
│ │ ├── oidc-discovery.service.spec.ts
|
||
│ │ ├── oidc-auth.guard.ts
|
||
│ │ ├── oidc-auth.guard.spec.ts
|
||
│ │ ├── auth.module.ts
|
||
│ │ └── index.ts
|
||
│ ├── users/src/
|
||
│ │ ├── user.types.ts
|
||
│ │ ├── users.repository.ts
|
||
│ │ ├── users.service.ts
|
||
│ │ ├── users.service.spec.ts
|
||
│ │ ├── user-preferences.repository.ts
|
||
│ │ ├── user-preferences.service.ts
|
||
│ │ ├── user-preferences.service.spec.ts
|
||
│ │ ├── users.module.ts
|
||
│ │ └── index.ts
|
||
│ └── trips/src/
|
||
│ ├── trip.types.ts
|
||
│ ├── trips.repository.ts
|
||
│ ├── trips.service.ts
|
||
│ ├── trips.service.spec.ts
|
||
│ ├── trip-settings.repository.ts
|
||
│ ├── trip-settings.service.ts
|
||
│ ├── trip-members.repository.ts
|
||
│ ├── trip-members.service.ts
|
||
│ ├── trip-membership.guard.ts
|
||
│ ├── trip-membership.guard.spec.ts
|
||
│ ├── trip-roles.decorator.ts
|
||
│ ├── trip-invitations.repository.ts
|
||
│ ├── trip-invitations.service.ts
|
||
│ ├── trip-invitations.service.spec.ts
|
||
│ ├── travelers.repository.ts
|
||
│ ├── travelers.service.ts
|
||
│ ├── travelers.service.spec.ts
|
||
│ ├── trip-preference-overrides.repository.ts
|
||
│ ├── trip-preference-overrides.service.ts
|
||
│ ├── trip-preference-overrides.service.spec.ts
|
||
│ ├── preference-precedence.ts
|
||
│ ├── preference-precedence.spec.ts
|
||
│ ├── trips.module.ts
|
||
│ └── index.ts
|
||
├── frontend/
|
||
│ └── src/
|
||
│ ├── environments/
|
||
│ │ ├── environment.ts # dev OIDC/API config
|
||
│ │ └── environment.production.ts # prod OIDC/API config (non-secret)
|
||
│ └── app/
|
||
│ ├── app.routes.ts # + /auth/callback, /trips, /trips/:tripId
|
||
│ ├── app.config.ts # + provideHttpClient(withInterceptors([authInterceptor]))
|
||
│ ├── auth/
|
||
│ │ ├── auth.service.ts
|
||
│ │ ├── auth.service.spec.ts
|
||
│ │ ├── auth.guard.ts
|
||
│ │ ├── auth.interceptor.ts
|
||
│ │ ├── auth.interceptor.spec.ts
|
||
│ │ └── callback/
|
||
│ │ ├── callback.ts
|
||
│ │ ├── callback.html
|
||
│ │ └── callback.spec.ts
|
||
│ └── trips/
|
||
│ ├── trips-api.service.ts
|
||
│ ├── trips-api.service.spec.ts
|
||
│ ├── trips-list/
|
||
│ │ ├── trips-list.ts
|
||
│ │ ├── trips-list.html
|
||
│ │ └── trips-list.spec.ts
|
||
│ └── trip-detail/
|
||
│ ├── trip-detail.ts
|
||
│ ├── trip-detail.html
|
||
│ └── trip-detail.spec.ts
|
||
└── docs/architecture/deployment.md # + migration entry point note
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Database Migration Tooling and the Kysely Query Layer
|
||
|
||
**Files:**
|
||
- Create: `backend/migrations/*.cjs` (eight files listed above)
|
||
- Create: `backend/libs/database/src/schema.ts`, `kysely.module.ts`, `index.ts`
|
||
- Create: `backend/jest-integration.json`
|
||
- Create: `backend/libs/database/test/migration-runner.integration-spec.ts`
|
||
- Modify: `backend/apps/api/src/migration.ts`
|
||
- Modify: `backend/package.json` (add `kysely`, `node-pg-migrate`; add `"test:integration": "jest --config jest-integration.json --runInBand"`)
|
||
- Modify: `docker/api.Dockerfile` (`COPY --from=build /app/backend/migrations ./backend/migrations` in the runtime stage)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `POSTGRES_POOL` from `backend/libs/infrastructure`; `AppEnvironment.databaseUrl`.
|
||
- Produces: `runMigrations(): Promise<void>` (real body); `KYSELY_DB` injection token exporting `Kysely<Database>`; `pnpm --filter backend test:integration` runnable against `compose.dev.yml`.
|
||
|
||
- [ ] **Step 1: Write the failing migration-runner integration test**
|
||
|
||
Create `backend/libs/database/test/migration-runner.integration-spec.ts`:
|
||
|
||
```ts
|
||
import { Pool } from 'pg';
|
||
import { runMigrations } from '../../../apps/api/src/migration';
|
||
|
||
describe('runMigrations (integration)', () => {
|
||
it('applies all pending migrations idempotently against a real database', async () => {
|
||
await runMigrations();
|
||
await runMigrations(); // must be safe to run twice
|
||
|
||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||
try {
|
||
const tables = await pool.query<{ table_name: string }>(
|
||
`select table_name from information_schema.tables where table_schema = 'public'`,
|
||
);
|
||
const names = tables.rows.map((r) => r.table_name);
|
||
for (const expected of [
|
||
'users', 'user_preferences', 'trips', 'trip_settings',
|
||
'trip_members', 'trip_invitations', 'travelers', 'trip_preference_overrides',
|
||
]) {
|
||
expect(names).toContain(expected);
|
||
}
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
Create `backend/jest-integration.json`:
|
||
|
||
```json
|
||
{
|
||
"moduleFileExtensions": ["js", "json", "ts"],
|
||
"rootDir": ".",
|
||
"testEnvironment": "node",
|
||
"testRegex": ".*\\.integration-spec\\.ts$",
|
||
"transform": { "^.+\\.(t|j)s$": "ts-jest" }
|
||
}
|
||
```
|
||
|
||
Note: this project intentionally does **not** use `test/setup-env.ts`'s dummy `DATABASE_URL` fallback — the caller must export a real `DATABASE_URL` pointing at `compose.dev.yml` before running it.
|
||
|
||
- [ ] **Step 2: Run and verify failure**
|
||
|
||
```bash
|
||
pnpm dev:infra
|
||
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner \
|
||
pnpm --filter backend test:integration
|
||
```
|
||
|
||
Expected: FAIL — `test:integration` script and `runMigrations` real implementation do not exist yet.
|
||
|
||
- [ ] **Step 3: Install dependencies and define the Kysely schema**
|
||
|
||
```bash
|
||
pnpm --filter backend add kysely node-pg-migrate
|
||
```
|
||
|
||
Create `backend/libs/database/src/schema.ts` with full column definitions for `users` and `trips`, and the remaining tables listed by field name (implementer fills in the same `ColumnType`/`Generated` pattern):
|
||
|
||
```ts
|
||
import type { ColumnType, Generated } from 'kysely';
|
||
|
||
export interface UsersTable {
|
||
id: Generated<string>;
|
||
external_subject_id: string;
|
||
display_name: string;
|
||
email: string;
|
||
created_at: ColumnType<Date, string | undefined, never>;
|
||
updated_at: ColumnType<Date, string | undefined, string>;
|
||
}
|
||
|
||
export interface TripsTable {
|
||
id: Generated<string>;
|
||
name: string;
|
||
description: string | null;
|
||
owner_id: string;
|
||
start_date: string | null;
|
||
end_date: string | null;
|
||
status: string; // DRAFT | PLANNING | BOOKING | UPCOMING | ACTIVE | COMPLETED | ARCHIVED
|
||
planning_stage: string | null;
|
||
currency: string;
|
||
version: Generated<number>;
|
||
created_at: ColumnType<Date, string | undefined, never>;
|
||
updated_at: ColumnType<Date, string | undefined, string>;
|
||
}
|
||
|
||
// UserPreferencesTable: user_id (pk/fk), preferred_pace, preferred_budget_level,
|
||
// max_walking_distance_km, preferred_start_time, child_friendly_preferred,
|
||
// interests (text[]), notes, created_at, updated_at.
|
||
// TripSettingsTable: trip_id (pk/fk), web_research_enabled, periodic_agent_review_enabled,
|
||
// notification_email_enabled, notification_push_enabled, default_research_depth,
|
||
// default_planning_style, created_at, updated_at. No `version` column (see Architecture).
|
||
// TripMembersTable: id, trip_id, user_id, role ('OWNER'|'MEMBER'), status
|
||
// ('INVITED'|'ACTIVE'|'DECLINED'), joined_at, created_at, updated_at.
|
||
// TripInvitationsTable: id, trip_id, email, invited_by_user_id, token_hash, expires_at,
|
||
// accepted_at, created_at, updated_at.
|
||
// TravelersTable: id, trip_id, linked_user_id, display_name, traveler_type
|
||
// ('ADULT'|'CHILD'|'INFANT'), created_by_user_id, created_at, updated_at.
|
||
// TripPreferenceOverridesTable: id, trip_id, user_id, traveler_id, overrides (jsonb),
|
||
// created_at, updated_at.
|
||
|
||
export interface Database {
|
||
users: UsersTable;
|
||
trips: TripsTable;
|
||
// user_preferences, trip_settings, trip_members, trip_invitations,
|
||
// travelers, trip_preference_overrides — same pattern, one interface each.
|
||
}
|
||
```
|
||
|
||
Create `backend/libs/database/src/kysely.module.ts`:
|
||
|
||
```ts
|
||
import { Module } from '@nestjs/common';
|
||
import { Kysely, PostgresDialect } from 'kysely';
|
||
import type { Pool } from 'pg';
|
||
import { PostgresModule, POSTGRES_POOL } from '../../infrastructure/src';
|
||
import type { Database } from './schema';
|
||
|
||
export const KYSELY_DB = Symbol('KYSELY_DB');
|
||
|
||
export const kyselyDbProvider = {
|
||
provide: KYSELY_DB,
|
||
inject: [POSTGRES_POOL],
|
||
useFactory: (pool: Pool) => new Kysely<Database>({ dialect: new PostgresDialect({ pool }) }),
|
||
};
|
||
|
||
@Module({
|
||
imports: [PostgresModule],
|
||
providers: [kyselyDbProvider],
|
||
exports: [kyselyDbProvider],
|
||
})
|
||
export class DatabaseModule {}
|
||
```
|
||
|
||
Export both from `backend/libs/database/src/index.ts`.
|
||
|
||
- [ ] **Step 4: Write one full migration and the remaining seven by the same pattern**
|
||
|
||
Create `backend/migrations/<timestamp>_create-users.cjs`:
|
||
|
||
```js
|
||
exports.up = (pgm) => {
|
||
pgm.createExtension('pgcrypto', { ifNotExists: true });
|
||
pgm.createTable('users', {
|
||
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||
external_subject_id: { type: 'text', notNull: true },
|
||
display_name: { type: 'text', notNull: true },
|
||
email: { type: 'text', notNull: true },
|
||
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||
});
|
||
pgm.addConstraint('users', 'users_external_subject_id_key', 'UNIQUE(external_subject_id)');
|
||
};
|
||
|
||
exports.down = (pgm) => {
|
||
pgm.dropTable('users');
|
||
};
|
||
```
|
||
|
||
Create the remaining seven migrations (`user_preferences`, `trips`, `trip_settings`, `trip_members`, `trip_invitations`, `travelers`, `trip_preference_overrides`) using the exact field lists in Step 3's schema comment, plus:
|
||
- `trips.version integer not null default 1`.
|
||
- `trip_members`: `UNIQUE(trip_id, user_id)`, `CHECK (role IN ('OWNER','MEMBER'))`, `CHECK (status IN ('INVITED','ACTIVE','DECLINED'))`, indexes on `trip_id` and `user_id`.
|
||
- `trip_invitations`: `UNIQUE(token_hash)`, index on `trip_id`, index on `lower(email)`.
|
||
- `travelers`: `CHECK (traveler_type IN ('ADULT','CHILD','INFANT'))`, index on `trip_id`.
|
||
- `trip_preference_overrides`: `CHECK (num_nonnulls(user_id, traveler_id) = 1)`, partial unique indexes `UNIQUE(trip_id, user_id) WHERE user_id IS NOT NULL` and `UNIQUE(trip_id, traveler_id) WHERE traveler_id IS NOT NULL`.
|
||
- All child tables `REFERENCES trips(id) ON DELETE CASCADE` where the row is trip-scoped.
|
||
|
||
- [ ] **Step 5: Implement the real migration runner**
|
||
|
||
Replace `backend/apps/api/src/migration.ts`:
|
||
|
||
```ts
|
||
import { join } from 'node:path';
|
||
import { runner } from 'node-pg-migrate';
|
||
import { loadEnvironment } from '../../../libs/configuration/src';
|
||
|
||
export async function runMigrations(): Promise<void> {
|
||
const env = loadEnvironment(process.env);
|
||
await runner({
|
||
databaseUrl: env.databaseUrl,
|
||
dir: join(__dirname, '../../../../migrations'),
|
||
direction: 'up',
|
||
count: Infinity,
|
||
migrationsTable: 'pgmigrations',
|
||
checkOrder: true,
|
||
log: (message: string) => console.log(message),
|
||
});
|
||
}
|
||
|
||
if (require.main === module) {
|
||
void runMigrations().catch((error) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|
||
}
|
||
```
|
||
|
||
The `dir` path is resolved relative to `__dirname` (not `process.cwd()`) so it works identically whether invoked as `node backend/dist/apps/api/src/migration.js` from the repository root (as `scripts/teamcity/deploy.sh` does) or from any other working directory.
|
||
|
||
- [ ] **Step 6: Run the integration test against real infrastructure**
|
||
|
||
```bash
|
||
pnpm --filter backend build:api
|
||
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner \
|
||
pnpm --filter backend test:integration
|
||
```
|
||
|
||
Expected: PASS; running `runMigrations()` twice is a no-op the second time (node-pg-migrate skips already-applied entries in `pgmigrations`).
|
||
|
||
- [ ] **Step 7: Update the runtime image and commit**
|
||
|
||
Add `COPY --from=build /app/backend/migrations ./backend/migrations` to the runtime stage of `docker/api.Dockerfile` (migrations must ship in the `travel-api` image because `deploy.sh` runs the migration command against that image).
|
||
|
||
```bash
|
||
git add backend/migrations backend/libs/database backend/apps/api/src/migration.ts backend/package.json backend/jest-integration.json docker/api.Dockerfile pnpm-lock.yaml
|
||
git commit -m "feat: add versioned migrations and kysely query layer"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: User Entity, UserPreference, and JIT Provisioning Service
|
||
|
||
**Files:**
|
||
- Create: `backend/libs/users/src/user.types.ts`, `users.repository.ts`, `users.service.ts`, `users.service.spec.ts`, `user-preferences.repository.ts`, `user-preferences.service.ts`, `user-preferences.service.spec.ts`, `users.module.ts`, `index.ts`
|
||
|
||
**Interfaces:**
|
||
- `UsersService.findOrCreateByExternalSubjectId(sub: string, claims: { email: string; displayName: string }): Promise<User>` — idempotent upsert.
|
||
- `UserPreferencesService.getOrDefault(userId): Promise<UserPreference>`, `upsert(userId, dto): Promise<UserPreference>`.
|
||
- `User = { id: string; externalSubjectId: string; displayName: string; email: string; createdAt: Date; updatedAt: Date }`.
|
||
|
||
- [ ] **Step 1: Write failing JIT-provisioning tests**
|
||
|
||
```ts
|
||
describe('UsersService.findOrCreateByExternalSubjectId', () => {
|
||
it('creates a new local user on first login', async () => {
|
||
const repo = { upsertByExternalSubjectId: jest.fn().mockResolvedValue({ id: 'u1', externalSubjectId: 'sub-1', displayName: 'Alex', email: 'a@example.com', createdAt: new Date(), updatedAt: new Date() }) };
|
||
const service = new UsersService(repo as never);
|
||
|
||
const user = await service.findOrCreateByExternalSubjectId('sub-1', { email: 'a@example.com', displayName: 'Alex' });
|
||
|
||
expect(repo.upsertByExternalSubjectId).toHaveBeenCalledWith('sub-1', { email: 'a@example.com', displayName: 'Alex' });
|
||
expect(user.id).toBe('u1');
|
||
});
|
||
|
||
it('refreshes displayName/email on subsequent logins without changing the id', async () => {
|
||
const repo = { upsertByExternalSubjectId: jest.fn().mockResolvedValue({ id: 'u1', externalSubjectId: 'sub-1', displayName: 'Alex Renamed', email: 'a@example.com', createdAt: new Date(), updatedAt: new Date() }) };
|
||
const service = new UsersService(repo as never);
|
||
|
||
const user = await service.findOrCreateByExternalSubjectId('sub-1', { email: 'a@example.com', displayName: 'Alex Renamed' });
|
||
|
||
expect(user.id).toBe('u1');
|
||
expect(user.displayName).toBe('Alex Renamed');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run and verify failure**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/users/src/users.service.spec.ts
|
||
```
|
||
|
||
Expected: FAIL — module does not exist.
|
||
|
||
- [ ] **Step 3: Implement the repository and service**
|
||
|
||
`UsersRepository.upsertByExternalSubjectId` executes:
|
||
|
||
```sql
|
||
insert into users (external_subject_id, display_name, email)
|
||
values ($1, $2, $3)
|
||
on conflict (external_subject_id)
|
||
do update set display_name = excluded.display_name, email = excluded.email, updated_at = now()
|
||
returning *;
|
||
```
|
||
|
||
via Kysely's `.onConflict((oc) => oc.column('external_subject_id').doUpdateSet({...}))`. `UsersService` is a thin pass-through with no branching logic (the single SQL upsert is the whole JIT-provisioning rule), keeping the unit test above meaningful without needing a real database.
|
||
|
||
`UserPreferencesService.getOrDefault(userId)` returns a stored row if present, otherwise an in-memory default object (`childFriendlyPreferred: false`, `interests: []`, all else `null`) without writing a row — a row is only persisted once the user explicitly saves preferences.
|
||
|
||
- [ ] **Step 4: Run tests, wire the module, commit**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/users
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
```bash
|
||
git add backend/libs/users
|
||
git commit -m "feat: add user jit provisioning and preferences service"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: OIDC Discovery, JWT Verification, and OidcAuthGuard
|
||
|
||
**Files:**
|
||
- Create: `backend/libs/auth/src/oidc-discovery.service.ts`, `oidc-discovery.service.spec.ts`, `oidc-auth.guard.ts`, `oidc-auth.guard.spec.ts`, `auth.module.ts`, `index.ts`
|
||
- Create: `backend/apps/api/src/auth/current-user.decorator.ts`
|
||
- Modify: `backend/libs/configuration/src/environment.ts`, `environment.spec.ts` (add `oidcIssuer: string`, `oidcAudience: string` to `AppEnvironment`, required like `DATABASE_URL`)
|
||
|
||
**Interfaces:**
|
||
- `OidcDiscoveryService.getVerificationKeySet(): JWTVerifyGetKey` (production: `createRemoteJWKSet(new URL(jwksUri))`, memoized after first discovery fetch).
|
||
- `OidcAuthGuard implements CanActivate` — verifies `Authorization: Bearer <token>`, populates `req.user`.
|
||
- `@CurrentUser() user: AuthenticatedUser` param decorator.
|
||
|
||
- [ ] **Step 1: Write failing guard tests using a local JWKS (no network calls)**
|
||
|
||
```ts
|
||
import { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } from 'jose';
|
||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||
import { OidcAuthGuard } from './oidc-auth.guard';
|
||
|
||
const issuer = 'https://idp.example.test/';
|
||
const audience = 'travel-planner-api';
|
||
|
||
function contextWithHeader(authorization?: string): ExecutionContext {
|
||
const req: Record<string, unknown> = authorization ? { headers: { authorization } } : { headers: {} };
|
||
return {
|
||
switchToHttp: () => ({ getRequest: () => req }),
|
||
} as unknown as ExecutionContext;
|
||
}
|
||
|
||
describe('OidcAuthGuard', () => {
|
||
let privateKey: CryptoKey;
|
||
let discovery: { getVerificationKeySet: jest.Mock; getIssuer: jest.Mock; getAudience: jest.Mock };
|
||
let usersService: { findOrCreateByExternalSubjectId: jest.Mock };
|
||
|
||
beforeAll(async () => {
|
||
const { publicKey, privateKey: pk } = await generateKeyPair('RS256');
|
||
privateKey = pk;
|
||
const jwk = await exportJWK(publicKey);
|
||
(jwk as Record<string, string>).kid = 'test-key';
|
||
const jwks = createLocalJWKSet({ keys: [jwk as never] });
|
||
discovery = {
|
||
getVerificationKeySet: jest.fn().mockReturnValue(jwks),
|
||
getIssuer: jest.fn().mockReturnValue(issuer),
|
||
getAudience: jest.fn().mockReturnValue(audience),
|
||
};
|
||
});
|
||
|
||
beforeEach(() => {
|
||
usersService = { findOrCreateByExternalSubjectId: jest.fn().mockResolvedValue({ id: 'local-1', externalSubjectId: 'idp-sub-1', displayName: 'A', email: 'a@example.com' }) };
|
||
});
|
||
|
||
async function sign(claims: Record<string, unknown>, expires = '5m') {
|
||
return new SignJWT(claims)
|
||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||
.setIssuer(issuer)
|
||
.setAudience(audience)
|
||
.setIssuedAt()
|
||
.setExpirationTime(expires)
|
||
.sign(privateKey);
|
||
}
|
||
|
||
it('rejects a request with no Authorization header', async () => {
|
||
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||
await expect(guard.canActivate(contextWithHeader())).rejects.toThrow(UnauthorizedException);
|
||
});
|
||
|
||
it('rejects an expired token', async () => {
|
||
const token = await sign({ sub: 'idp-sub-1', email: 'a@example.com', name: 'A' }, '-10s');
|
||
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||
await expect(guard.canActivate(contextWithHeader(`Bearer ${token}`))).rejects.toThrow(UnauthorizedException);
|
||
});
|
||
|
||
it('rejects a token issued for a different audience', async () => {
|
||
const token = await new SignJWT({ sub: 'idp-sub-1' })
|
||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||
.setIssuer(issuer)
|
||
.setAudience('some-other-api')
|
||
.setIssuedAt()
|
||
.setExpirationTime('5m')
|
||
.sign(privateKey);
|
||
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||
await expect(guard.canActivate(contextWithHeader(`Bearer ${token}`))).rejects.toThrow(UnauthorizedException);
|
||
});
|
||
|
||
it('provisions the local user and attaches req.user on a valid token', async () => {
|
||
const token = await sign({ sub: 'idp-sub-1', email: 'a@example.com', name: 'A' });
|
||
const req: Record<string, unknown> = { headers: { authorization: `Bearer ${token}` } };
|
||
const context = { switchToHttp: () => ({ getRequest: () => req }) } as unknown as ExecutionContext;
|
||
|
||
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||
|
||
expect(usersService.findOrCreateByExternalSubjectId).toHaveBeenCalledWith('idp-sub-1', { email: 'a@example.com', displayName: 'A' });
|
||
expect(req.user).toEqual({ id: 'local-1', externalSubjectId: 'idp-sub-1', displayName: 'A', email: 'a@example.com' });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run and verify failure**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/auth/src/oidc-auth.guard.spec.ts
|
||
```
|
||
|
||
Expected: FAIL — module does not exist.
|
||
|
||
- [ ] **Step 3: Install `jose` and add OIDC config**
|
||
|
||
```bash
|
||
pnpm --filter backend add jose
|
||
```
|
||
|
||
Extend `AppEnvironment`/`loadEnvironment` with `oidcIssuer` (required, from `OIDC_ISSUER`) and `oidcAudience` (required, from `OIDC_AUDIENCE`), following the exact `required(env, name)` pattern already used for `DATABASE_URL`/`REDIS_URL`. Add matching cases to `environment.spec.ts`.
|
||
|
||
`OidcDiscoveryService` fetches `${issuer}/.well-known/openid-configuration` once (lazily, memoized), reads `jwks_uri`, and exposes `getVerificationKeySet()` returning `createRemoteJWKSet(new URL(jwksUri))`. It is injected into `OidcAuthGuard` so tests can substitute a fake with `createLocalJWKSet` as above, and production wiring uses the real HTTP-backed one.
|
||
|
||
- [ ] **Step 4: Implement `OidcAuthGuard`**
|
||
|
||
```ts
|
||
@Injectable()
|
||
export class OidcAuthGuard implements CanActivate {
|
||
constructor(
|
||
private readonly discovery: OidcDiscoveryService,
|
||
private readonly users: UsersService,
|
||
) {}
|
||
|
||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||
const request = context.switchToHttp().getRequest();
|
||
const header = request.headers?.authorization as string | undefined;
|
||
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
|
||
if (!token) throw new UnauthorizedException('Missing bearer token');
|
||
|
||
let payload: JWTPayload;
|
||
try {
|
||
const result = await jwtVerify(token, this.discovery.getVerificationKeySet(), {
|
||
issuer: this.discovery.getIssuer(),
|
||
audience: this.discovery.getAudience(),
|
||
});
|
||
payload = result.payload;
|
||
} catch {
|
||
throw new UnauthorizedException('Invalid or expired token');
|
||
}
|
||
|
||
const sub = payload.sub;
|
||
if (!sub) throw new UnauthorizedException('Token has no subject claim');
|
||
|
||
const user = await this.users.findOrCreateByExternalSubjectId(sub, {
|
||
email: (payload.email as string) ?? '',
|
||
displayName: (payload.name as string) ?? (payload.email as string) ?? sub,
|
||
});
|
||
|
||
request.user = { id: user.id, externalSubjectId: user.externalSubjectId, displayName: user.displayName, email: user.email };
|
||
return true;
|
||
}
|
||
}
|
||
```
|
||
|
||
`@CurrentUser()` is a `createParamDecorator` reading `request.user`.
|
||
|
||
- [ ] **Step 5: Run tests and commit**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/auth libs/configuration
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
```bash
|
||
git add backend/libs/auth backend/libs/configuration backend/apps/api/src/auth pnpm-lock.yaml
|
||
git commit -m "feat: verify oidc bearer tokens and jit-provision users"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `/api/v1/users/me` and Preferences Endpoints
|
||
|
||
**Files:**
|
||
- Create: `backend/apps/api/src/users/users.controller.ts`, `users.controller.spec.ts`, `users.module.ts`
|
||
- Modify: `backend/apps/api/src/api.module.ts` (import `UsersApiModule`)
|
||
|
||
**Interfaces:**
|
||
- `GET /api/v1/users/me` → `{ id, displayName, email, createdAt, updatedAt }` (guarded by `OidcAuthGuard`; the guard's JIT provisioning already ran, so this is a direct read of `req.user`/a fresh repository read for accuracy).
|
||
- `GET /api/v1/users/me/preferences` → `UserPreferenceResponseDto`.
|
||
- `PUT /api/v1/users/me/preferences` body `UpdateUserPreferenceDto` → `UserPreferenceResponseDto`.
|
||
|
||
```ts
|
||
interface UpdateUserPreferenceDto {
|
||
preferredPace?: string | null;
|
||
preferredBudgetLevel?: string | null;
|
||
maxWalkingDistanceKm?: number | null;
|
||
preferredStartTime?: string | null; // "HH:MM"
|
||
childFriendlyPreferred?: boolean;
|
||
interests?: string[];
|
||
notes?: string | null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1: Write failing controller tests** with `OidcAuthGuard` overridden via `overrideGuard` to inject a fixed `req.user`, and a mocked `UsersService`/`UserPreferencesService`, asserting the route responses shape and that `PUT` round-trips the DTO through the service.
|
||
|
||
- [ ] **Step 2: Run and verify failure.**
|
||
|
||
- [ ] **Step 3: Implement `UsersController`** with `@UseGuards(OidcAuthGuard)` at the controller level and `@CurrentUser()` to read `req.user.id`.
|
||
|
||
- [ ] **Step 4: Run tests, wire into `ApiModule`, commit.**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- apps/api/src/users
|
||
git add backend/apps/api/src/users backend/apps/api/src/api.module.ts
|
||
git commit -m "feat: expose current user and preference endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Trip and TripSettings Entities with Optimistic Locking
|
||
|
||
**Files:**
|
||
- Create: `backend/libs/trips/src/trip.types.ts`, `trips.repository.ts`, `trips.service.ts`, `trips.service.spec.ts`, `trip-settings.repository.ts`, `trip-settings.service.ts`, `trips.module.ts`, `index.ts`
|
||
- Create: `backend/apps/api/src/trips/trips.controller.ts`, `trips.controller.spec.ts`, `trips.module.ts`
|
||
- Create: `backend/libs/trips/test/trips.optimistic-locking.integration-spec.ts`
|
||
- Modify: `backend/apps/api/src/api.module.ts`
|
||
|
||
**Interfaces:**
|
||
|
||
```ts
|
||
interface CreateTripDto { name: string; description?: string; startDate?: string; endDate?: string; currency?: string }
|
||
interface UpdateTripDto {
|
||
name?: string; description?: string | null; startDate?: string | null; endDate?: string | null;
|
||
status?: TripStatus; planningStage?: string | null; currency?: string;
|
||
version: number; // required — the caller's last-known version
|
||
}
|
||
type TripStatus = 'DRAFT' | 'PLANNING' | 'BOOKING' | 'UPCOMING' | 'ACTIVE' | 'COMPLETED' | 'ARCHIVED';
|
||
```
|
||
|
||
- `TripsService.createTrip(ownerId, dto)` — inside one Kysely transaction: inserts `trips` (status `DRAFT`, `version = 1`), inserts a default `trip_settings` row (`webResearchEnabled: false`, `periodicAgentReviewEnabled: false`, `notificationEmailEnabled: true`, `notificationPushEnabled: true`), inserts a `trip_members` row for the owner (`role: 'OWNER'`, `status: 'ACTIVE'`, `joinedAt: now()`).
|
||
- `TripsService.updateTrip(tripId, dto)` — `UPDATE trips SET ..., version = version + 1 WHERE id = :tripId AND version = :dto.version RETURNING *`; if no row returned, throw `ConflictException`.
|
||
- `TripsService.listTripsForUser(userId)` — trips joined through `trip_members` where `user_id = :userId AND status = 'ACTIVE'`.
|
||
- Routes: `GET /api/v1/trips`, `POST /api/v1/trips`, `GET /api/v1/trips/:tripId`, `PATCH /api/v1/trips/:tripId`, `DELETE /api/v1/trips/:tripId`. `PATCH`/`DELETE` additionally require `@UseGuards(TripMembershipGuard)` + `@TripRoles('OWNER')` (guard itself lands in Task 6; this task's controller tests mock it via `overrideGuard`).
|
||
|
||
- [ ] **Step 1: Write the failing unit test proving a stale write is rejected (mocked repository)**
|
||
|
||
```ts
|
||
describe('TripsService.updateTrip', () => {
|
||
it('throws ConflictException when the repository update matches no row', async () => {
|
||
const repo = { updateWithVersionCheck: jest.fn().mockResolvedValue(undefined) };
|
||
const service = new TripsService(repo as never);
|
||
|
||
await expect(
|
||
service.updateTrip('trip-1', { name: 'New name', version: 1 }),
|
||
).rejects.toThrow(ConflictException);
|
||
|
||
expect(repo.updateWithVersionCheck).toHaveBeenCalledWith('trip-1', 1, expect.objectContaining({ name: 'New name' }));
|
||
});
|
||
|
||
it('returns the updated trip when the version matches', async () => {
|
||
const updated = { id: 'trip-1', name: 'New name', version: 2 /* ...rest */ };
|
||
const repo = { updateWithVersionCheck: jest.fn().mockResolvedValue(updated) };
|
||
const service = new TripsService(repo as never);
|
||
|
||
await expect(service.updateTrip('trip-1', { name: 'New name', version: 1 })).resolves.toEqual(updated);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run and verify failure.**
|
||
|
||
- [ ] **Step 3: Implement `TripsRepository.updateWithVersionCheck`** using Kysely:
|
||
|
||
```ts
|
||
async updateWithVersionCheck(tripId: string, expectedVersion: number, patch: Partial<TripUpdateFields>) {
|
||
return this.db
|
||
.updateTable('trips')
|
||
.set({ ...patch, version: sql`version + 1`, updated_at: new Date() })
|
||
.where('id', '=', tripId)
|
||
.where('version', '=', expectedVersion)
|
||
.returningAll()
|
||
.executeTakeFirst();
|
||
}
|
||
```
|
||
|
||
`TripsService.updateTrip` calls this and throws `ConflictException('Trip was modified by someone else. Reload and retry.')` when the result is `undefined`.
|
||
|
||
- [ ] **Step 4: Write the integration test proving the real SQL enforces the check**
|
||
|
||
Create `backend/libs/trips/test/trips.optimistic-locking.integration-spec.ts` (runs under `backend/jest-integration.json` against `compose.dev.yml`):
|
||
|
||
```ts
|
||
describe('Trip optimistic locking (integration)', () => {
|
||
it('rejects a second update that used a stale version', async () => {
|
||
const trip = await tripsService.createTrip(ownerId, { name: 'Slovenia 2027' });
|
||
expect(trip.version).toBe(1);
|
||
|
||
const firstUpdate = await tripsService.updateTrip(trip.id, { name: 'Slovenia 2027 (v2)', version: 1 });
|
||
expect(firstUpdate.version).toBe(2);
|
||
|
||
await expect(
|
||
tripsService.updateTrip(trip.id, { name: 'Conflicting concurrent edit', version: 1 }),
|
||
).rejects.toThrow(ConflictException);
|
||
});
|
||
});
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner \
|
||
pnpm --filter backend test:integration
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Implement `TripSettingsService`/`TripSettingsRepository`** (`GET`/`PUT /api/v1/trips/:tripId/settings`, `OWNER`-only, full-replace semantics, no `version` field — see Architecture for the rationale).
|
||
|
||
- [ ] **Step 6: Run all Task 5 tests, wire into `ApiModule`, commit.**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/trips apps/api/src/trips
|
||
git add backend/libs/trips backend/apps/api/src/trips backend/apps/api/src/api.module.ts
|
||
git commit -m "feat: add trip and trip settings with optimistic locking"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: TripMember Entity and the Trip-Membership Authorization Guard
|
||
|
||
**Files:**
|
||
- Create: `backend/libs/trips/src/trip-members.repository.ts`, `trip-members.service.ts`, `trip-membership.guard.ts`, `trip-membership.guard.spec.ts`, `trip-roles.decorator.ts`
|
||
- Create: `backend/apps/api/src/trips/trip-members.controller.ts`, `trip-members.controller.spec.ts`
|
||
- Modify: `backend/apps/api/src/trips/trips.controller.ts` (apply the real guard, replacing the mocked one from Task 5)
|
||
|
||
**Interfaces:**
|
||
- `@TripRoles(...roles: TripRole[])` — `SetMetadata('tripRoles', roles)`.
|
||
- `TripMembershipGuard implements CanActivate` — reads `req.params.tripId` and `req.user.id` (set by `OidcAuthGuard`, which must run first), loads the caller's `trip_members` row, throws `ForbiddenException` if missing/`INVITED`/`DECLINED`, or if `@TripRoles` is present and the caller's role is not included; otherwise attaches `req.tripMembership = { role, status }`.
|
||
- Routes: `GET /api/v1/trips/:tripId/members`, `PATCH /api/v1/trips/:tripId/members/:memberId` (`@TripRoles('OWNER')`), `DELETE /api/v1/trips/:tripId/members/:memberId` (`@TripRoles('OWNER')`, or the caller removing themself).
|
||
|
||
- [ ] **Step 1: Write failing guard tests**
|
||
|
||
```ts
|
||
describe('TripMembershipGuard', () => {
|
||
function context(params: Record<string, string>, user: { id: string }, handlerRoles?: string[]) {
|
||
const req = { params, user };
|
||
return {
|
||
switchToHttp: () => ({ getRequest: () => req }),
|
||
getHandler: () => ({}),
|
||
getClass: () => ({}),
|
||
} as unknown as ExecutionContext;
|
||
}
|
||
|
||
it('denies a user who has no trip_members row for the trip', async () => {
|
||
const members = { findByTripAndUser: jest.fn().mockResolvedValue(undefined) };
|
||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) };
|
||
const guard = new TripMembershipGuard(members as never, reflector as never);
|
||
|
||
await expect(guard.canActivate(context({ tripId: 't1' }, { id: 'u1' }))).rejects.toThrow(ForbiddenException);
|
||
});
|
||
|
||
it('denies an ACTIVE MEMBER when the route requires OWNER', async () => {
|
||
const members = { findByTripAndUser: jest.fn().mockResolvedValue({ role: 'MEMBER', status: 'ACTIVE' }) };
|
||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['OWNER']) };
|
||
const guard = new TripMembershipGuard(members as never, reflector as never);
|
||
|
||
await expect(guard.canActivate(context({ tripId: 't1' }, { id: 'u1' }))).rejects.toThrow(ForbiddenException);
|
||
});
|
||
|
||
it('denies an INVITED (not yet ACTIVE) member', async () => {
|
||
const members = { findByTripAndUser: jest.fn().mockResolvedValue({ role: 'MEMBER', status: 'INVITED' }) };
|
||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) };
|
||
const guard = new TripMembershipGuard(members as never, reflector as never);
|
||
|
||
await expect(guard.canActivate(context({ tripId: 't1' }, { id: 'u1' }))).rejects.toThrow(ForbiddenException);
|
||
});
|
||
|
||
it('allows an ACTIVE OWNER through an OWNER-only route', async () => {
|
||
const members = { findByTripAndUser: jest.fn().mockResolvedValue({ role: 'OWNER', status: 'ACTIVE' }) };
|
||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['OWNER']) };
|
||
const guard = new TripMembershipGuard(members as never, reflector as never);
|
||
|
||
await expect(guard.canActivate(context({ tripId: 't1' }, { id: 'u1' }))).resolves.toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run and verify failure.**
|
||
|
||
- [ ] **Step 3: Implement `TripMembersRepository.findByTripAndUser` and `TripMembershipGuard`** as specified above, using `Reflector.getAllAndOverride<TripRole[]>('tripRoles', [context.getHandler(), context.getClass()])`.
|
||
|
||
- [ ] **Step 4: Apply the guard to trip-scoped routes**
|
||
|
||
`@UseGuards(OidcAuthGuard)` stays at the controller level for list/create; `@UseGuards(OidcAuthGuard, TripMembershipGuard)` plus `@TripRoles('OWNER')` where noted is applied per-method for `GET/PATCH/DELETE /trips/:tripId`, `PUT /trips/:tripId/settings`, and the members endpoints.
|
||
|
||
- [ ] **Step 5: Run tests, commit.**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/trips/src/trip-membership.guard.spec.ts apps/api/src/trips
|
||
git add backend/libs/trips backend/apps/api/src/trips
|
||
git commit -m "feat: enforce trip membership and role authorization"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: TripInvitation Entity and Invitation Flow
|
||
|
||
**Files:**
|
||
- Create: `backend/libs/trips/src/trip-invitations.repository.ts`, `trip-invitations.service.ts`, `trip-invitations.service.spec.ts`
|
||
- Create: `backend/apps/api/src/trips/trip-invitations.controller.ts`, `trip-invitations.controller.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- `TripInvitationsService.createInvitation(tripId, invitedByUserId, email, ttlMs = 7 * 24 * 60 * 60 * 1000): Promise<{ invitation: TripInvitation; rawToken: string }>` — generates `rawToken = crypto.randomBytes(32).toString('base64url')`, stores `tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex')`. The raw token is returned once in the API response body so the inviter can share it manually; email delivery is out of scope until Phase 08 (Notifications/SMTP) — this is a deliberate, documented gap, not an oversight.
|
||
- `TripInvitationsService.acceptInvitation(rawToken, acceptingUserId): Promise<TripMember>` — hashes the presented token, looks up by `tokenHash`, verifies `acceptedAt IS NULL` and `expiresAt > now()`, then in one transaction sets `acceptedAt = now()` and upserts a `trip_members` row (`role: 'MEMBER'`, `status: 'ACTIVE'`, `joinedAt: now()`) for `acceptingUserId`.
|
||
- Routes: `POST /api/v1/trips/:tripId/invitations` (`@TripRoles('OWNER')`), `GET /api/v1/trips/:tripId/invitations` (`@TripRoles('OWNER')`), `DELETE /api/v1/trips/:tripId/invitations/:invitationId` (`@TripRoles('OWNER')`), `POST /api/v1/invitations/:token/accept` (any authenticated user — no trip membership required yet, since accepting *creates* membership).
|
||
|
||
- [ ] **Step 1: Write failing tests for single-use and expiry rules**
|
||
|
||
```ts
|
||
describe('TripInvitationsService.acceptInvitation', () => {
|
||
it('rejects accepting the same invitation twice', async () => {
|
||
const repo = fakeInvitationsRepo({ tokenHash: hashOf('raw-token'), expiresAt: future, acceptedAt: null });
|
||
const members = { upsertActiveMember: jest.fn() };
|
||
const service = new TripInvitationsService(repo as never, members as never);
|
||
|
||
await service.acceptInvitation('raw-token', 'user-2');
|
||
repo.markAccepted(); // simulate persisted state after first accept
|
||
|
||
await expect(service.acceptInvitation('raw-token', 'user-3')).rejects.toThrow(ConflictException);
|
||
});
|
||
|
||
it('rejects an expired invitation', async () => {
|
||
const repo = fakeInvitationsRepo({ tokenHash: hashOf('raw-token'), expiresAt: past, acceptedAt: null });
|
||
const members = { upsertActiveMember: jest.fn() };
|
||
const service = new TripInvitationsService(repo as never, members as never);
|
||
|
||
await expect(service.acceptInvitation('raw-token', 'user-2')).rejects.toThrow(BadRequestException);
|
||
expect(members.upsertActiveMember).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects an unknown token without leaking whether the trip exists', async () => {
|
||
const repo = { findByTokenHash: jest.fn().mockResolvedValue(undefined) };
|
||
const members = { upsertActiveMember: jest.fn() };
|
||
const service = new TripInvitationsService(repo as never, members as never);
|
||
|
||
await expect(service.acceptInvitation('does-not-exist', 'user-2')).rejects.toThrow(NotFoundException);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run and verify failure.**
|
||
|
||
- [ ] **Step 3: Implement the repository/service** per the interfaces above, hashing with `node:crypto`.
|
||
|
||
- [ ] **Step 4: Run tests, wire controller, commit.**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/trips/src/trip-invitations.service.spec.ts apps/api/src/trips/trip-invitations.controller.spec.ts
|
||
git add backend/libs/trips/src/trip-invitations.* backend/apps/api/src/trips/trip-invitations.*
|
||
git commit -m "feat: add trip invitation creation and acceptance flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Traveler Entity and Traveler Endpoints
|
||
|
||
**Files:**
|
||
- Create: `backend/libs/trips/src/travelers.repository.ts`, `travelers.service.ts`, `travelers.service.spec.ts`
|
||
- Create: `backend/apps/api/src/trips/travelers.controller.ts`, `travelers.controller.spec.ts`
|
||
|
||
**Interfaces:**
|
||
|
||
```ts
|
||
interface CreateTravelerDto { displayName: string; travelerType: 'ADULT' | 'CHILD' | 'INFANT'; linkedUserId?: string }
|
||
interface UpdateTravelerDto { displayName?: string; travelerType?: 'ADULT' | 'CHILD' | 'INFANT'; linkedUserId?: string | null }
|
||
```
|
||
|
||
- Routes: `GET/POST /api/v1/trips/:tripId/travelers`, `PATCH/DELETE /api/v1/trips/:tripId/travelers/:travelerId` — any `ACTIVE` trip member (any role) may manage travelers; this is a deliberate YAGNI simplification (see the open questions in the final report) rather than inventing a third permission tier.
|
||
|
||
- [ ] **Step 1: Write the failing test proving `TripMember` and `Traveler` are distinct concepts**
|
||
|
||
```ts
|
||
describe('TripMember vs Traveler', () => {
|
||
it('creating a traveler does not create or require a trip_members row', async () => {
|
||
const travelersRepo = { create: jest.fn().mockResolvedValue({ id: 'trav-1', tripId: 't1', linkedUserId: null, displayName: 'Mila', travelerType: 'CHILD', createdByUserId: 'u1' }) };
|
||
const membersRepo = { create: jest.fn(), findByTripAndUser: jest.fn() };
|
||
const service = new TravelersService(travelersRepo as never);
|
||
|
||
const traveler = await service.createTraveler('t1', { displayName: 'Mila', travelerType: 'CHILD' }, 'u1');
|
||
|
||
expect(traveler.linkedUserId).toBeNull();
|
||
expect(membersRepo.create).not.toHaveBeenCalled();
|
||
expect(membersRepo.findByTripAndUser).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('a Traveler can be linked to a user who is independently a TripMember, without either row implying the other', async () => {
|
||
const travelersRepo = { create: jest.fn().mockResolvedValue({ id: 'trav-2', tripId: 't1', linkedUserId: 'u2', displayName: 'Alex', travelerType: 'ADULT', createdByUserId: 'u1' }) };
|
||
const service = new TravelersService(travelersRepo as never);
|
||
|
||
const traveler = await service.createTraveler('t1', { displayName: 'Alex', travelerType: 'ADULT', linkedUserId: 'u2' }, 'u1');
|
||
|
||
// linkedUserId is informational only; TripMembershipGuard never consults the travelers table.
|
||
expect(traveler.linkedUserId).toBe('u2');
|
||
expect(travelersRepo.create).toHaveBeenCalledWith('t1', expect.objectContaining({ linkedUserId: 'u2' }), 'u1');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run and verify failure.**
|
||
|
||
- [ ] **Step 3: Implement `TravelersRepository`/`TravelersService`.**
|
||
|
||
- [ ] **Step 4: Run tests, wire controller, commit.**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/trips/src/travelers.service.spec.ts apps/api/src/trips/travelers.controller.spec.ts
|
||
git add backend/libs/trips/src/travelers.* backend/apps/api/src/trips/travelers.*
|
||
git commit -m "feat: add traveler entity distinct from trip membership"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: TripPreferenceOverride Entity and Precedence Resolution
|
||
|
||
**Files:**
|
||
- Create: `backend/libs/trips/src/trip-preference-overrides.repository.ts`, `trip-preference-overrides.service.ts`, `trip-preference-overrides.service.spec.ts`, `preference-precedence.ts`, `preference-precedence.spec.ts`
|
||
- Create: `backend/apps/api/src/trips/trip-preference-overrides.controller.ts`, `trip-preference-overrides.controller.spec.ts`
|
||
|
||
**Interfaces:**
|
||
|
||
```ts
|
||
type PreferenceSubject = { type: 'USER'; userId: string } | { type: 'TRAVELER'; travelerId: string };
|
||
interface UpsertPreferenceOverrideDto { subject: PreferenceSubject; overrides: Partial<UserPreferenceFields> }
|
||
|
||
// Precedence per design spec §5.8: trip override > persistent user preference > application default.
|
||
function resolveEffectivePreference(
|
||
override: Partial<UserPreferenceFields> | undefined,
|
||
userPreference: UserPreferenceFields | undefined,
|
||
appDefault: UserPreferenceFields,
|
||
): UserPreferenceFields;
|
||
```
|
||
|
||
- Routes: `GET /api/v1/trips/:tripId/preference-overrides` (any `ACTIVE` member), `PUT /api/v1/trips/:tripId/preference-overrides` (any `ACTIVE` member may set their own `USER` override or an override for a `Traveler` they created; `OWNER` may set any), `DELETE /api/v1/trips/:tripId/preference-overrides/:overrideId`.
|
||
|
||
- [ ] **Step 1: Write the failing precedence unit test**
|
||
|
||
```ts
|
||
describe('resolveEffectivePreference', () => {
|
||
const appDefault: UserPreferenceFields = { preferredPace: 'moderate', preferredBudgetLevel: 'medium', maxWalkingDistanceKm: 5, preferredStartTime: null, childFriendlyPreferred: false, interests: [], notes: null };
|
||
|
||
it('falls back to the application default when nothing is set', () => {
|
||
expect(resolveEffectivePreference(undefined, undefined, appDefault)).toEqual(appDefault);
|
||
});
|
||
|
||
it('prefers the persistent user preference over the application default', () => {
|
||
const userPref = { ...appDefault, preferredPace: 'relaxed' };
|
||
expect(resolveEffectivePreference(undefined, userPref, appDefault).preferredPace).toBe('relaxed');
|
||
});
|
||
|
||
it('prefers a trip-specific override over the persistent user preference', () => {
|
||
const userPref = { ...appDefault, preferredPace: 'relaxed' };
|
||
const override = { preferredPace: 'fast' };
|
||
expect(resolveEffectivePreference(override, userPref, appDefault).preferredPace).toBe('fast');
|
||
});
|
||
|
||
it('merges field-by-field rather than replacing the whole object', () => {
|
||
const userPref = { ...appDefault, preferredPace: 'relaxed', childFriendlyPreferred: true };
|
||
const override = { preferredPace: 'fast' };
|
||
const result = resolveEffectivePreference(override, userPref, appDefault);
|
||
expect(result.preferredPace).toBe('fast');
|
||
expect(result.childFriendlyPreferred).toBe(true); // untouched field falls through to user preference
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run and verify failure.**
|
||
|
||
- [ ] **Step 3: Implement `resolveEffectivePreference`** as a pure function (`{ ...appDefault, ...userPreference, ...override }`), plus the repository (enforcing the "exactly one of `userId`/`travelerId`" rule already backed by the DB `CHECK` constraint from Task 1) and service.
|
||
|
||
- [ ] **Step 4: Run tests, wire controller, commit.**
|
||
|
||
```bash
|
||
pnpm --filter backend test -- libs/trips/src/preference-precedence.spec.ts libs/trips/src/trip-preference-overrides.service.spec.ts apps/api/src/trips/trip-preference-overrides.controller.spec.ts
|
||
git add backend/libs/trips/src/trip-preference-overrides.* backend/libs/trips/src/preference-precedence.* backend/apps/api/src/trips/trip-preference-overrides.*
|
||
git commit -m "feat: add trip preference overrides with precedence resolution"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Frontend OIDC Login Flow (Authorization Code + PKCE)
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/environments/environment.ts`, `environment.production.ts`
|
||
- Create: `frontend/src/app/auth/auth.service.ts`, `auth.service.spec.ts`, `auth.guard.ts`, `auth.interceptor.ts`, `auth.interceptor.spec.ts`, `callback/callback.ts`, `callback/callback.html`, `callback/callback.spec.ts`
|
||
- Modify: `frontend/src/app/app.routes.ts`, `frontend/src/app/app.config.ts`, `frontend/angular.json` (add `fileReplacements` for the two environment files)
|
||
|
||
**Interfaces:**
|
||
- `AuthService.login(): void` — redirects to the IdP via `UserManager.signinRedirect()`.
|
||
- `AuthService.completeLogin(): Promise<void>` — called from the callback route; `UserManager.signinRedirectCallback()`.
|
||
- `AuthService.logout(): void`.
|
||
- `AuthService.getAccessToken(): Promise<string | undefined>`.
|
||
- `AuthService.isAuthenticated: Signal<boolean>`.
|
||
- `authInterceptor: HttpInterceptorFn` — attaches `Authorization: Bearer <token>` only to requests whose URL starts with the configured API base URL.
|
||
- `authGuard: CanActivateFn` — redirects unauthenticated users to a login prompt instead of into a route that needs a user.
|
||
|
||
- [ ] **Step 1: Write failing tests for the interceptor and the guard's unauthenticated-redirect behavior**
|
||
|
||
```ts
|
||
describe('authInterceptor', () => {
|
||
it('attaches a bearer token to API requests', async () => {
|
||
const authService = { getAccessToken: jest.fn().mockResolvedValue('token-123') } as unknown as AuthService;
|
||
const req = new HttpRequest('GET', '/api/v1/trips');
|
||
const next = jest.fn().mockReturnValue(of(new HttpResponse()));
|
||
|
||
await firstValueFrom(runInInjectionContext(injector, () => authInterceptor(req, next)));
|
||
|
||
expect(next).toHaveBeenCalledWith(expect.objectContaining({ headers: expect.any(HttpHeaders) }));
|
||
const forwarded = next.mock.calls[0][0] as HttpRequest<unknown>;
|
||
expect(forwarded.headers.get('Authorization')).toBe('Bearer token-123');
|
||
});
|
||
|
||
it('does not attach a token to non-API requests', async () => {
|
||
// request to an external/unrelated URL passes through unchanged
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run and verify failure.**
|
||
|
||
- [ ] **Step 3: Install `oidc-client-ts` and implement `AuthService`**
|
||
|
||
```bash
|
||
pnpm --filter frontend add oidc-client-ts
|
||
```
|
||
|
||
`environment.ts`/`environment.production.ts` export:
|
||
|
||
```ts
|
||
export const environment = {
|
||
apiBaseUrl: '/api/v1',
|
||
oidc: {
|
||
issuer: 'https://idp.example.invalid/realms/travel-planner',
|
||
clientId: 'travel-planner-web',
|
||
redirectUri: `${window.location.origin}/auth/callback`,
|
||
scope: 'openid profile email',
|
||
},
|
||
};
|
||
```
|
||
|
||
`AuthService` wraps a single `UserManager` instance configured from `environment.oidc` with `response_type: 'code'` (PKCE is automatic in `oidc-client-ts` for the code flow), exposes `login()`, `completeLogin()`, `logout()`, `getAccessToken()`, and an `isAuthenticated` signal updated from `UserManager` events (`userLoaded`, `userUnloaded`).
|
||
|
||
- [ ] **Step 4: Wire the callback route and guard**
|
||
|
||
`app.routes.ts` gains `{ path: 'auth/callback', component: Callback }`; `Callback` calls `authService.completeLogin()` then navigates to `/trips`. `authGuard` is applied to `/trips` and `/trips/:tripId`.
|
||
|
||
- [ ] **Step 5: Register the interceptor**
|
||
|
||
```ts
|
||
providers: [
|
||
provideHttpClient(withInterceptors([authInterceptor])),
|
||
// ...existing providers
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests, production build, verify PWA artifacts still present, commit**
|
||
|
||
```bash
|
||
pnpm --filter frontend test -- --watch=false
|
||
pnpm --filter frontend build
|
||
find frontend/dist -name ngsw.json -o -name manifest.webmanifest
|
||
git add frontend/src/environments frontend/src/app/auth frontend/src/app/app.routes.ts frontend/src/app/app.config.ts frontend/angular.json pnpm-lock.yaml
|
||
git commit -m "feat: add oidc authorization code with pkce login flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Frontend Trips List, Create Trip, and Members UI Slice
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/app/trips/trips-api.service.ts`, `trips-api.service.spec.ts`, `trips-list/trips-list.ts`, `trips-list.html`, `trips-list.spec.ts`, `trip-detail/trip-detail.ts`, `trip-detail.html`, `trip-detail.spec.ts`
|
||
- Modify: `frontend/src/app/app.routes.ts`, `frontend/src/app/app.html`
|
||
|
||
**Interfaces:**
|
||
- `TripsApiService.listTrips(): Observable<TripSummary[]>`, `createTrip(dto): Observable<Trip>`, `getTrip(id): Observable<Trip>`, `updateTrip(id, dto): Observable<Trip>`, `listMembers(id): Observable<TripMember[]>`.
|
||
- Routes: `/trips` (list + create form), `/trips/:tripId` (detail + members list), both behind `authGuard`.
|
||
|
||
- [ ] **Step 1: Write a failing test asserting the trips list renders trip names from a mocked `TripsApiService` and that submitting the create form calls `createTrip`.**
|
||
|
||
- [ ] **Step 2: Run and verify failure.**
|
||
|
||
- [ ] **Step 3: Implement `TripsApiService`** as a thin `HttpClient` wrapper against `${environment.apiBaseUrl}/trips`, and the two minimal standalone components (no drag/drop, no dashboard — that is Phase 03/11 scope). `trip-detail` surfaces the optimistic-locking conflict as a plain error message when a `409` is returned, without a merge UI (that refinement is future-phase polish).
|
||
|
||
- [ ] **Step 4: Wire routes, run tests, production build, commit.**
|
||
|
||
```bash
|
||
pnpm --filter frontend test -- --watch=false
|
||
pnpm --filter frontend build
|
||
git add frontend/src/app/trips frontend/src/app/app.routes.ts frontend/src/app/app.html
|
||
git commit -m "feat: add minimal trips list, create trip, and members ui"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Environment, Compose, and Docker Wiring for OIDC
|
||
|
||
**Files:**
|
||
- Modify: `.env.example`
|
||
- Modify: `compose.yml` (add `OIDC_ISSUER`, `OIDC_AUDIENCE` to the `api` service `environment:` block)
|
||
- Modify: `docs/architecture/deployment.md` (document the new required env vars and the migration entry point's real behavior)
|
||
|
||
- [ ] **Step 1: Add non-secret OIDC configuration names to `.env.example`**
|
||
|
||
```dotenv
|
||
OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
|
||
OIDC_AUDIENCE=travel-planner-api
|
||
OIDC_CLIENT_ID=travel-planner-web
|
||
```
|
||
|
||
`OIDC_CLIENT_ID` is consumed by the frontend build (baked into `environment.production.ts` at build time, matching the existing "browser-safe config only" rule); `OIDC_ISSUER`/`OIDC_AUDIENCE` are consumed by the `api` container to verify tokens. Neither is a secret — this is a public PKCE client with no client secret.
|
||
|
||
- [ ] **Step 2: Wire `compose.yml`**
|
||
|
||
Add to the `api` service's `environment:` block:
|
||
|
||
```yaml
|
||
OIDC_ISSUER: "${OIDC_ISSUER}"
|
||
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
|
||
```
|
||
|
||
- [ ] **Step 3: Run the compose invariant test to confirm the topology is unaffected**
|
||
|
||
```bash
|
||
pnpm test:compose
|
||
```
|
||
|
||
Expected: PASS — no `ports:` changes were made.
|
||
|
||
- [ ] **Step 4: Update deployment docs and commit**
|
||
|
||
```bash
|
||
git add .env.example compose.yml docs/architecture/deployment.md
|
||
git commit -m "chore: wire oidc environment configuration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Phase 02 Verification
|
||
|
||
**Files:** none new; this task only runs checks.
|
||
|
||
- [ ] **Step 1: Full quality gate**
|
||
|
||
```bash
|
||
pnpm install --frozen-lockfile
|
||
pnpm lint
|
||
pnpm test
|
||
pnpm test:compose
|
||
pnpm build
|
||
```
|
||
|
||
Expected: all PASS, including every unit test added in Tasks 1–11.
|
||
|
||
- [ ] **Step 2: Real-database integration smoke test**
|
||
|
||
```bash
|
||
pnpm dev:infra
|
||
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner \
|
||
pnpm --filter backend test:integration
|
||
```
|
||
|
||
Expected: PASS, including the migration idempotency test (Task 1) and the optimistic-locking conflict test (Task 5) against a real PostgreSQL instance.
|
||
|
||
- [ ] **Step 3: End-to-end manual/API smoke pass**
|
||
|
||
With the API running against `compose.dev.yml` and a real or stubbed IdP issuing a valid token for the configured `OIDC_ISSUER`/`OIDC_AUDIENCE`:
|
||
|
||
```bash
|
||
curl --fail -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/api/v1/users/me
|
||
curl --fail -H "Authorization: Bearer $TOKEN" -X POST -H 'Content-Type: application/json' \
|
||
-d '{"name":"Slovenia 2027"}' http://127.0.0.1:3000/api/v1/trips
|
||
curl -i http://127.0.0.1:3000/api/v1/trips # no Authorization header
|
||
```
|
||
|
||
Expected: first two calls succeed (200/201); the third returns 401; a `PATCH /api/v1/trips/:tripId` sent twice with the same stale `version` returns 409 on the second call; a `GET /api/v1/trips/:tripId` from a token belonging to a user with no `trip_members` row for that trip returns 403.
|
||
|
||
- [ ] **Step 4: Update `README.md`**
|
||
|
||
Document: OIDC env vars, `pnpm --filter backend test:integration` usage, the new `/api/v1/users/me`, `/api/v1/trips*` routes, and that the migration entry point is now real (superseding the Phase 01 "no-op" note).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs: record phase 02 completion"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 02 Acceptance Checklist
|
||
|
||
```bash
|
||
pnpm install --frozen-lockfile
|
||
pnpm lint
|
||
pnpm test
|
||
pnpm test:compose
|
||
pnpm build
|
||
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner pnpm --filter backend test:integration
|
||
```
|
||
|
||
All commands must pass. Then verify:
|
||
|
||
- [ ] A user can complete OIDC Authorization Code + PKCE login from the Angular app and reach `/trips`.
|
||
- [ ] No local password is stored anywhere; `User` is keyed only by `externalSubjectId` (`sub`).
|
||
- [ ] First login for a new `sub` creates a `User` row; a second login with the same `sub` and a changed `name`/`email` claim updates the existing row rather than creating a duplicate.
|
||
- [ ] `GET /api/v1/trips` and any `Bearer`-protected route reject requests with a missing, expired, wrong-audience, or badly-signed token with HTTP 401.
|
||
- [ ] A user who is not an `ACTIVE` `trip_members` row for a given trip receives HTTP 403 from every `:tripId`-scoped route, including `GET`.
|
||
- [ ] `PATCH /api/v1/trips/:tripId` with a stale `version` returns HTTP 409 and does not mutate the row; the real SQL-level check is proven by an integration test, not only a mock.
|
||
- [ ] `TripSettings` writes are restricted to the trip `OWNER`.
|
||
- [ ] `TripMember` and `Traveler` are backed by independent tables; creating a `Traveler` never creates or requires a `trip_members` row, and a user can hold both simultaneously.
|
||
- [ ] `TripInvitation` tokens are single-use, expire, and are stored only as a hash (never the raw token).
|
||
- [ ] `TripPreferenceOverride` precedence resolves override > user preference > application default, proven by a unit test.
|
||
- [ ] No `synchronize: true` or any other schema auto-sync exists anywhere in the codebase; every schema object is created by a file under `backend/migrations/`.
|
||
- [ ] `node backend/dist/apps/api/src/migration.js` applies all pending migrations and is safe to run twice.
|
||
- [ ] OIDC configuration (`OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_CLIENT_ID`) is read only from environment variables; no issuer/client id is hardcoded in backend source.
|
||
- [ ] The Angular production build still emits `ngsw.json`/`manifest.webmanifest`; the PWA is not broken by the new routes/interceptor.
|
||
- [ ] `compose.yml` still publishes exactly one port, owned by `edge` (`pnpm test:compose` passes unchanged).
|
||
- [ ] No Mistral, agent, web-research, itinerary, watch-item, notification, booking, or budget code exists in the repository.
|
||
|
||
## Phase 02 Review Boundary
|
||
|
||
Do not start any of the following during this phase; each belongs to a later, explicitly scoped phase:
|
||
|
||
- Mistral / `LlmProvider` / any agent tool, `AgentRun`, `AgentAction`, or tool registry (Phase 04+).
|
||
- Web research providers, `ResearchFact`/`ResearchSource` (Phase 06).
|
||
- Activities, itinerary days/items, drag-and-drop, or locking beyond the `Trip.version` optimistic-lock mechanism itself (Phase 03).
|
||
- Watch items, background trip review, BullMQ processors beyond the worker skeleton already in place (Phase 07).
|
||
- Notifications, SMTP, web push (Phase 08).
|
||
- Bookings, document upload/extraction (Phase 09).
|
||
- Budget (Phase 10).
|
||
- Trip dashboard, planning/travel-mode UI emphasis, or dark-theme/full visual polish (Phase 11).
|
||
- Payment/booking automation (permanent non-goal).
|
||
|
||
Phase 03 begins only after the Phase 02 acceptance checklist is reviewed and green.
|