68 KiB
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 existingpg.Pool.node-pg-migrate(^7.9.0) — versioned migration runner (JS/CJS migration files, programmaticrunner()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 AngularAuthServicerather 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 OIDCsubclaim) 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 versionednode-pg-migratemigration 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.jsmust still work; only the body ofrunMigrations()changes. - No new TypeScript path aliases. Continue the Phase 01 convention of relative imports between
apps/*andlibs/*(e.g.'../../../libs/configuration/src'), matching whatbackend/apps/api/src/api.module.tsalready 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.tsnaming 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.
OidcAuthGuardandTripMembershipGuardare applied explicitly per controller/method; there is no globalAPP_GUARD./health/live,/health/ready, and/api/v1/versionremain 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.versionoptimistic locking is mandatory; a stale write (mismatchedversion) 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, andpnpm test:composemust 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
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(addkysely,node-pg-migrate; add"test:integration": "jest --config jest-integration.json --runInBand") - Modify:
docker/api.Dockerfile(COPY --from=build /app/backend/migrations ./backend/migrationsin the runtime stage)
Interfaces:
-
Consumes:
POSTGRES_POOLfrombackend/libs/infrastructure;AppEnvironment.databaseUrl. -
Produces:
runMigrations(): Promise<void>(real body);KYSELY_DBinjection token exportingKysely<Database>;pnpm --filter backend test:integrationrunnable againstcompose.dev.yml. -
Step 1: Write the failing migration-runner integration test
Create backend/libs/database/test/migration-runner.integration-spec.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:
{
"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
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
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):
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:
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:
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 ontrip_idanduser_id. -
trip_invitations:UNIQUE(token_hash), index ontrip_id, index onlower(email). -
travelers:CHECK (traveler_type IN ('ADULT','CHILD','INFANT')), index ontrip_id. -
trip_preference_overrides:CHECK (num_nonnulls(user_id, traveler_id) = 1), partial unique indexesUNIQUE(trip_id, user_id) WHERE user_id IS NOT NULLandUNIQUE(trip_id, traveler_id) WHERE traveler_id IS NOT NULL. -
All child tables
REFERENCES trips(id) ON DELETE CASCADEwhere the row is trip-scoped. -
Step 5: Implement the real migration runner
Replace backend/apps/api/src/migration.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
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).
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
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
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:
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
pnpm --filter backend test -- libs/users
Expected: PASS.
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(addoidcIssuer: string,oidcAudience: stringtoAppEnvironment, required likeDATABASE_URL)
Interfaces:
-
OidcDiscoveryService.getVerificationKeySet(): JWTVerifyGetKey(production:createRemoteJWKSet(new URL(jwksUri)), memoized after first discovery fetch). -
OidcAuthGuard implements CanActivate— verifiesAuthorization: Bearer <token>, populatesreq.user. -
@CurrentUser() user: AuthenticatedUserparam decorator. -
Step 1: Write failing guard tests using a local JWKS (no network calls)
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
pnpm --filter backend test -- libs/auth/src/oidc-auth.guard.spec.ts
Expected: FAIL — module does not exist.
- Step 3: Install
joseand add OIDC config
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
@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
pnpm --filter backend test -- libs/auth libs/configuration
Expected: PASS.
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(importUsersApiModule)
Interfaces:
GET /api/v1/users/me→{ id, displayName, email, createdAt, updatedAt }(guarded byOidcAuthGuard; the guard's JIT provisioning already ran, so this is a direct read ofreq.user/a fresh repository read for accuracy).GET /api/v1/users/me/preferences→UserPreferenceResponseDto.PUT /api/v1/users/me/preferencesbodyUpdateUserPreferenceDto→UserPreferenceResponseDto.
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
OidcAuthGuardoverridden viaoverrideGuardto inject a fixedreq.user, and a mockedUsersService/UserPreferencesService, asserting the route responses shape and thatPUTround-trips the DTO through the service. -
Step 2: Run and verify failure.
-
Step 3: Implement
UsersControllerwith@UseGuards(OidcAuthGuard)at the controller level and@CurrentUser()to readreq.user.id. -
Step 4: Run tests, wire into
ApiModule, commit.
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:
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: insertstrips(statusDRAFT,version = 1), inserts a defaulttrip_settingsrow (webResearchEnabled: false,periodicAgentReviewEnabled: false,notificationEmailEnabled: true,notificationPushEnabled: true), inserts atrip_membersrow 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, throwConflictException. -
TripsService.listTripsForUser(userId)— trips joined throughtrip_memberswhereuser_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/DELETEadditionally require@UseGuards(TripMembershipGuard)+@TripRoles('OWNER')(guard itself lands in Task 6; this task's controller tests mock it viaoverrideGuard). -
Step 1: Write the failing unit test proving a stale write is rejected (mocked repository)
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.updateWithVersionCheckusing Kysely:
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):
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:
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, noversionfield — see Architecture for the rationale). -
Step 6: Run all Task 5 tests, wire into
ApiModule, commit.
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— readsreq.params.tripIdandreq.user.id(set byOidcAuthGuard, which must run first), loads the caller'strip_membersrow, throwsForbiddenExceptionif missing/INVITED/DECLINED, or if@TripRolesis present and the caller's role is not included; otherwise attachesreq.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
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.findByTripAndUserandTripMembershipGuardas specified above, usingReflector.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.
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 }>— generatesrawToken = crypto.randomBytes(32).toString('base64url'), storestokenHash = 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 bytokenHash, verifiesacceptedAt IS NULLandexpiresAt > now(), then in one transaction setsacceptedAt = now()and upserts atrip_membersrow (role: 'MEMBER',status: 'ACTIVE',joinedAt: now()) foracceptingUserId. -
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
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.
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:
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— anyACTIVEtrip 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
TripMemberandTravelerare distinct concepts
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.
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:
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(anyACTIVEmember),PUT /api/v1/trips/:tripId/preference-overrides(anyACTIVEmember may set their ownUSERoverride or an override for aTravelerthey created;OWNERmay set any),DELETE /api/v1/trips/:tripId/preference-overrides/:overrideId. -
Step 1: Write the failing precedence unit test
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
resolveEffectivePreferenceas a pure function ({ ...appDefault, ...userPreference, ...override }), plus the repository (enforcing the "exactly one ofuserId/travelerId" rule already backed by the DBCHECKconstraint from Task 1) and service. -
Step 4: Run tests, wire controller, commit.
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(addfileReplacementsfor the two environment files)
Interfaces:
-
AuthService.login(): void— redirects to the IdP viaUserManager.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— attachesAuthorization: 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
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-tsand implementAuthService
pnpm --filter frontend add oidc-client-ts
environment.ts/environment.production.ts export:
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
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
// ...existing providers
]
- Step 6: Run tests, production build, verify PWA artifacts still present, commit
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 behindauthGuard. -
Step 1: Write a failing test asserting the trips list renders trip names from a mocked
TripsApiServiceand that submitting the create form callscreateTrip. -
Step 2: Run and verify failure.
-
Step 3: Implement
TripsApiServiceas a thinHttpClientwrapper against${environment.apiBaseUrl}/trips, and the two minimal standalone components (no drag/drop, no dashboard — that is Phase 03/11 scope).trip-detailsurfaces the optimistic-locking conflict as a plain error message when a409is returned, without a merge UI (that refinement is future-phase polish). -
Step 4: Wire routes, run tests, production build, commit.
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(addOIDC_ISSUER,OIDC_AUDIENCEto theapiserviceenvironment: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
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:
OIDC_ISSUER: "${OIDC_ISSUER}"
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
- Step 3: Run the compose invariant test to confirm the topology is unaffected
pnpm test:compose
Expected: PASS — no ports: changes were made.
- Step 4: Update deployment docs and commit
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
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
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:
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
git add README.md
git commit -m "docs: record phase 02 completion"
Phase 02 Acceptance Checklist
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;
Useris keyed only byexternalSubjectId(sub). - First login for a new
subcreates aUserrow; a second login with the samesuband a changedname/emailclaim updates the existing row rather than creating a duplicate. GET /api/v1/tripsand anyBearer-protected route reject requests with a missing, expired, wrong-audience, or badly-signed token with HTTP 401.- A user who is not an
ACTIVEtrip_membersrow for a given trip receives HTTP 403 from every:tripId-scoped route, includingGET. PATCH /api/v1/trips/:tripIdwith a staleversionreturns HTTP 409 and does not mutate the row; the real SQL-level check is proven by an integration test, not only a mock.TripSettingswrites are restricted to the tripOWNER.TripMemberandTravelerare backed by independent tables; creating aTravelernever creates or requires atrip_membersrow, and a user can hold both simultaneously.TripInvitationtokens are single-use, expire, and are stored only as a hash (never the raw token).TripPreferenceOverrideprecedence resolves override > user preference > application default, proven by a unit test.- No
synchronize: trueor any other schema auto-sync exists anywhere in the codebase; every schema object is created by a file underbackend/migrations/. node backend/dist/apps/api/src/migration.jsapplies 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.ymlstill publishes exactly one port, owned byedge(pnpm test:composepasses 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.versionoptimistic-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.