From 0c80c802830158b97961446465dc6aac5e559bb4 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Fri, 6 Mar 2026 19:52:21 +0800 Subject: [PATCH 01/27] chore: remove dependabot auto-PRs, keep security alerts only Dependabot auto-PRs are noisy and often fail with pnpm workspaces. Security alerts remain enabled in repo settings for vulnerability notifications. Dependencies will be updated manually as needed. Co-Authored-By: Claude Opus 4.6 --- .github/dependabot.yml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 38a0331..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - groups: - nestjs: - patterns: - - "@nestjs/*" - next: - patterns: - - "next" - - "react" - - "react-dom" - open-pull-requests-limit: 10 From 421e5732e09a52ff3da62ee48275387845a9bb37 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Sat, 7 Mar 2026 01:09:46 +0800 Subject: [PATCH 02/27] feat: add customizable subdomain slug in project creation Slug auto-generates from project name by default (read-only). Click "Customize" to edit independently for shorter email domains. Click "Reset to auto" to re-sync with project name. Co-Authored-By: Claude Opus 4.6 --- .../devinbox/CreateProjectModal.tsx | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/web/components/devinbox/CreateProjectModal.tsx b/apps/web/components/devinbox/CreateProjectModal.tsx index d039fa8..1c162c3 100644 --- a/apps/web/components/devinbox/CreateProjectModal.tsx +++ b/apps/web/components/devinbox/CreateProjectModal.tsx @@ -118,21 +118,46 @@ export function CreateProjectModal({ {/* Slug Field */}
- +
+ + {autoGenerateSlug ? ( + + ) : ( + + )} +
handleSlugChange(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm text-gray-900" + className={`w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm text-gray-900 ${ + autoGenerateSlug ? 'bg-gray-50' : '' + }`} placeholder="my-awesome-project" required disabled={loading} + readOnly={autoGenerateSlug} pattern="[a-z0-9-]+" />

From ae41ecbfe94392650d8d5d07cb7c8ca58a7954f4 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 05:41:05 +0800 Subject: [PATCH 03/27] docs: add demo account design Ephemeral per-session demo accounts with 1-hour TTL, L2 capability (sandbox writes, no real SMTP), and an extensibility hook so future tools in the deck can plug in their own seed data. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/plans/2026-04-15-demo-account-design.md | 156 +++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/plans/2026-04-15-demo-account-design.md diff --git a/docs/plans/2026-04-15-demo-account-design.md b/docs/plans/2026-04-15-demo-account-design.md new file mode 100644 index 0000000..f9c9d56 --- /dev/null +++ b/docs/plans/2026-04-15-demo-account-design.md @@ -0,0 +1,156 @@ +# Demo Account — Design + +**Date:** 2026-04-15 +**Status:** Approved, ready for implementation plan +**Scope:** My Dev Deck (umbrella app). Current tool: DevInbox. Future tools must fit the same demo pattern. + +## Goal + +Let anyone click "Try Demo" on the login page and explore a real, writable instance of My Dev Deck without signing up. The demo must showcase real write flows (create project, receive/inject email, mark read, delete) without exposing real SMTP to anonymous traffic, and must be safe to run in a public open-source deployment. + +## Model + +**Ephemeral per-session demo accounts with a 1-hour hard TTL.** + +- User clicks "Try Demo" → backend creates a throwaway `User` row flagged `isDemo=true`, seeds data, returns JWT cookies. +- The user experiences the full product UI with capped resources. +- After 1 hour from creation, a cron deletes the user and all their data via Prisma cascade. + +**Capability level: L2 (sandbox writes, no real SMTP).** Demo users can create projects and manage emails, but cannot receive real inbound SMTP email. Instead, an "Inject test email" button adds realistic fake emails into their projects. This avoids exposing SMTP to anonymous abuse while still letting the user feel the write flows. + +## Data model + +Add one column to `User`: + +```prisma +model User { + // ...existing fields... + isDemo Boolean @default(false) @map("is_demo") + // createdAt already exists and is sufficient for TTL calculation +} +``` + +No separate table. The flag is enough to filter for cleanup, block sensitive routes, and drive UI. + +## Auth flow + +**New endpoint:** `POST /api/auth/demo` (public, rate-limited). + +- Creates a `User` with: + - `email`: `demo-@demo.local` (unique, unused for real delivery) + - `password`: random unusable bcrypt hash (no password is ever issued, so login by password is impossible) + - `name`: `Demo User` + - `isDemo`: `true` +- Runs demo seeders (see Extensibility). +- Returns JWT cookies via the same `setAuthCookies` path as real login. +- Rate-limited to **2 demo creations per IP per hour** via `@nestjs/throttler` (same pattern as `/login`). + +Because there is no password, there is no brute-force or enumeration surface on demo accounts. + +## Blocked routes for demo users + +A `@BlockDemo()` decorator + guard rejects routes that don't make sense for demo users or that could be used to escalate/claim the account. Returns `403 Forbidden` with `{ message: "Not available in demo mode" }`. + +Initial blocked list: +- `POST /api/auth/change-password` +- Any future account-settings / billing / convert-to-real-account route + +The guard reads `req.user.isDemo` (populated by the existing JWT strategy). + +## Resource caps + +Enforced in the service layer (UI shows them but is not the source of truth): + +| Resource | Cap | +|---|---| +| Projects per demo user | 2 (1 pre-seeded + 1 user-created) | +| Seeded emails per project | 5 | +| Injected emails per project | 20 | + +On cap breach, service returns `403` with a message like `"Demo accounts are limited to 2 projects. Sign up to create more."`. + +## Seeding + +At demo creation, the seeder creates: + +- **Project:** `demo-inbox` (slug), name `Demo Inbox`, description explaining this is a demo. +- **5 sample emails** in that project: + 1. Welcome / onboarding email (plain text + HTML) + 2. Password reset (HTML with button) + 3. Order receipt (HTML with table) + 4. Newsletter (rich HTML) + 5. Email with PDF attachment (to exercise attachment viewer) + +All sample emails use clearly-fake content: `noreply@example.com` senders, links pointing to `#`, no real URLs or tracking. No secrets, no PII. + +## Inject-test-email feature + +Demo users see an "Inject test email" button on any project view. It calls `POST /api/projects/:id/demo/inject-email` which: + +- Verifies caller is demo (`req.user.isDemo`). +- Verifies the project belongs to the caller. +- Verifies the injected-count cap hasn't been hit. +- Picks a random template from a pool of ~10 realistic fake emails and writes it to the `emails` table as if received. + +This route is also available to demo users for projects they create themselves, so the "create a project" demo flow ends with visible emails arriving. + +## UI + +- **Login page**: adds "Try Demo" button next to the login form. +- **Dashboard banner** (only when `user.isDemo`): shows "Demo expires in 0h 57m — sign up to keep your data." Live countdown, dismissible per session but re-shown on reload. Countdown is derived from `user.createdAt + 1h` served by `/api/auth/me`. +- **Blocked actions**: UI disables or hides buttons for blocked routes and shows a tooltip "Not available in demo mode." Server still enforces. +- **Cap indicators**: project list shows `1/2 projects used` for demo users. + +## TTL and cleanup + +**Cron job** runs every 5 minutes in the API: + +``` +DELETE FROM users WHERE is_demo = TRUE AND created_at < NOW() - INTERVAL '1 hour' +``` + +Via Prisma (not raw SQL) so cascade deletes run for `RefreshToken`, `Project`, `Email`, `Attachment`. Idempotent. Logs the count of users deleted, no PII. + +Implementation: `@nestjs/schedule` with `@Cron('*/5 * * * *')` in a new `DemoModule`. + +## Environment flag + +`DEMO_MODE_ENABLED=true|false` (default `false`). + +- When `false`: `/api/auth/demo` returns 404, cleanup cron does not register, "Try Demo" button hidden on the web app. +- When `true`: everything described above is active. + +Self-hosters running their own My Dev Deck deployment can opt out of demo mode entirely. + +## Extensibility (future tools) + +Each tool that has data a demo user should see implements a `DemoSeeder` interface: + +```ts +interface DemoSeeder { + seed(userId: string): Promise; + getBlockedActions?(): string[]; // optional — route paths to block +} +``` + +`DemoModule` discovers all `DemoSeeder` providers and runs their `seed(userId)` in sequence on demo creation. Cleanup needs no per-tool hook because Prisma cascade deletes handle it. + +DevInbox ships with `DevInboxDemoSeeder`. Future tools (webhooks, file sharing, API mocking) each add their own. + +## Security summary + +- Passwordless demo accounts → no brute-force / enumeration surface +- Rate-limit (2/IP/hour) → no DB flooding +- Service-layer cap enforcement → client can't bypass +- Blocked routes via guard → no account escalation +- Sample data is clearly-fake → no accidental secret leak in a public repo +- Cascade-delete cleanup → no orphan data, no PII in logs +- Env flag default `false` → self-hosters opt in explicitly +- No real SMTP exposure to anonymous users → no spam relay risk + +## Out of scope + +- **Landing page.** Separate task. For now "Try Demo" lives on the login page; when the landing page ships it moves there. +- **Converting a demo account to a real account.** Not supported. Users sign up fresh; their demo data is discarded. +- **Demo analytics / conversion tracking.** Can be added later. +- **Real SMTP for demo users (L3).** Explicitly rejected — abuse vector too large. From 55122feaddac1bc692c97b834b81151ba2f5b2b8 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 05:44:21 +0800 Subject: [PATCH 04/27] docs: add demo account implementation plan 16 tasks across 4 phases: schema, demo module core, web UI, verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/plans/2026-04-15-demo-account.md | 1115 +++++++++++++++++++++++++ 1 file changed, 1115 insertions(+) create mode 100644 docs/plans/2026-04-15-demo-account.md diff --git a/docs/plans/2026-04-15-demo-account.md b/docs/plans/2026-04-15-demo-account.md new file mode 100644 index 0000000..0fb160b --- /dev/null +++ b/docs/plans/2026-04-15-demo-account.md @@ -0,0 +1,1115 @@ +# Demo Account Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an ephemeral "Try Demo" flow to My Dev Deck — one-click passwordless demo account with 1-hour TTL, seeded DevInbox data, and extensible seeder interface for future tools. + +**Architecture:** Add `isDemo` flag to `User`. New `DemoModule` in the API owns: the passwordless `/api/auth/demo` endpoint, a `DemoSeeder` interface that each tool module implements, a `@BlockDemo()` decorator + guard, cap-enforcement helpers, and a 5-minute cron that cascade-deletes expired demo users. The web app adds a "Try Demo" button, a live countdown banner, cap indicators, and an "Inject test email" button. + +**Tech Stack:** NestJS 11, Prisma 6, Postgres, `@nestjs/schedule` (new dep), `@nestjs/throttler`, passport-jwt, Next.js 16, React 19. + +**Design doc:** `docs/plans/2026-04-15-demo-account-design.md` + +**Reference design decisions (do not re-litigate):** +- TTL: 1 hour hard from `createdAt` +- Capability: L2 (sandbox writes, no real SMTP) +- Caps: 2 projects per demo user, 5 seeded + 20 injected emails per project +- Rate limit: 2 demo creations per IP per hour +- Env flag: `DEMO_MODE_ENABLED` (default `false`) +- Blocked routes (initial): `change-password` + +**Conventions observed in this repo:** +- Tests are `*.spec.ts`, colocated with source, run via `pnpm --filter api test` +- Service errors use NestJS exceptions (`ForbiddenException`, `ConflictException`, etc.) +- Logging uses `Logger` from `@nestjs/common` +- Prisma models use `snake_case` `@map` for columns, `camelCase` in TS +- Web app pages live in `apps/web/app/`, dashboard in `apps/web/app/dashboard/` + +--- + +## Phase 1 — Schema change + +### Task 1: Add `isDemo` to User model + +**Files:** +- Modify: `apps/api/prisma/schema.prisma` (User model, around line 18–31) +- Create (generated): `apps/api/prisma/migrations/_add_is_demo_to_users/migration.sql` + +**Step 1: Edit schema** + +In `apps/api/prisma/schema.prisma`, add to the `User` model (right before the blank line that precedes `// Relations`): + +```prisma + isDemo Boolean @default(false) @map("is_demo") +``` + +Add an index so cleanup queries are fast: + +```prisma + @@map("users") + @@index([isDemo, createdAt]) +``` + +**Step 2: Create migration** + +Run: `pnpm --filter api exec prisma migrate dev --name add_is_demo_to_users` + +Expected: migration applied, Prisma Client regenerated, new folder under `apps/api/prisma/migrations/`. + +**Step 3: Verify** + +Run: `pnpm --filter api exec prisma format` then `pnpm --filter api check-types` (or `pnpm check-types`). +Expected: no errors. + +**Step 4: Commit** + +```bash +git add apps/api/prisma/schema.prisma apps/api/prisma/migrations +git commit -m "feat(api): add isDemo flag to users" +``` + +--- + +### Task 2: Expose `isDemo` from JWT strategies + +The existing JWT strategies return `{ id, email, name }`. The `@BlockDemo` guard will read `req.user.isDemo`, so the strategies must surface it. + +**Files:** +- Modify: `apps/api/src/auth/strategies/jwt.strategy.ts:20-30` +- Modify: `apps/api/src/auth/strategies/jwt-cookie.strategy.ts` (analogous `validate` method) +- Modify: `apps/api/src/auth/guards/jwt-auth.guard.ts:44-46` (update TUser default type) + +**Step 1: Write the failing test** + +Create `apps/api/src/auth/strategies/jwt.strategy.spec.ts` (if it doesn't exist) and add: + +```ts +import { Test } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { JwtStrategy } from './jwt.strategy'; +import { PrismaService } from '../../prisma/prisma.service'; + +describe('JwtStrategy', () => { + let strategy: JwtStrategy; + let prisma: { user: { findUnique: jest.Mock } }; + + beforeEach(async () => { + prisma = { user: { findUnique: jest.fn() } }; + const module = await Test.createTestingModule({ + providers: [ + JwtStrategy, + { provide: PrismaService, useValue: prisma }, + { provide: ConfigService, useValue: { get: () => 'secret' } }, + ], + }).compile(); + strategy = module.get(JwtStrategy); + }); + + it('returns isDemo on the authenticated user', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'u1', email: 'a@b.c', name: 'A', isDemo: true, + }); + const result = await strategy.validate({ userId: 'u1', email: 'a@b.c' }); + expect(result).toEqual({ id: 'u1', email: 'a@b.c', name: 'A', isDemo: true }); + }); +}); +``` + +**Step 2: Run it to confirm it fails** + +Run: `pnpm --filter api test -- jwt.strategy` +Expected: FAIL (current strategy does not select `isDemo`). + +**Step 3: Update `jwt.strategy.ts` validate()** + +Change the `select` to include `isDemo`: + +```ts +select: { id: true, email: true, name: true, isDemo: true }, +``` + +**Step 4: Apply the same change to `jwt-cookie.strategy.ts`** (mirror the `select` and any return typing). + +**Step 5: Update the `TUser` default in `jwt-auth.guard.ts:44`** + +```ts +handleRequest( +``` + +**Step 6: Run tests** + +Run: `pnpm --filter api test` +Expected: all green. + +**Step 7: Commit** + +```bash +git add apps/api/src/auth +git commit -m "feat(api): expose isDemo on authenticated user" +``` + +--- + +## Phase 2 — Demo module core + +### Task 3: Scaffold `DemoModule` and install `@nestjs/schedule` + +**Files:** +- Modify: `apps/api/package.json` +- Create: `apps/api/src/demo/demo.module.ts` +- Modify: `apps/api/src/app.module.ts` + +**Step 1: Install the scheduler** + +Run: `pnpm --filter api add @nestjs/schedule` + +**Step 2: Create `apps/api/src/demo/demo.module.ts`** + +```ts +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), PrismaModule, AuthModule], + providers: [], + controllers: [], +}) +export class DemoModule {} +``` + +(Providers/controllers are added in later tasks.) + +**Step 3: Register in `app.module.ts`** + +Add `DemoModule` to the `imports` array. + +**Step 4: Verify boot** + +Run: `pnpm --filter api build` +Expected: no errors. + +**Step 5: Commit** + +```bash +git add apps/api/package.json apps/api/src/demo/demo.module.ts apps/api/src/app.module.ts pnpm-lock.yaml +git commit -m "feat(api): scaffold demo module" +``` + +--- + +### Task 4: Environment flag + config + +**Files:** +- Modify: `apps/api/.env.example` +- Create: `apps/api/src/demo/demo.config.ts` + +**Step 1: Add to `.env.example`** + +``` +# Demo mode (opt-in). When true, exposes POST /api/auth/demo and runs cleanup cron. +DEMO_MODE_ENABLED=false +# Demo account TTL in minutes +DEMO_TTL_MINUTES=60 +# Max demo account creations per IP per hour +DEMO_RATE_LIMIT_PER_HOUR=2 +``` + +**Step 2: Create `demo.config.ts`** + +```ts +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class DemoConfig { + constructor(private config: ConfigService) {} + + get enabled(): boolean { + return this.config.get('DEMO_MODE_ENABLED') === 'true'; + } + + get ttlMinutes(): number { + return parseInt(this.config.get('DEMO_TTL_MINUTES') || '60', 10); + } + + get rateLimitPerHour(): number { + return parseInt(this.config.get('DEMO_RATE_LIMIT_PER_HOUR') || '2', 10); + } +} +``` + +**Step 3: Add `DemoConfig` to `DemoModule` providers and exports.** + +**Step 4: Commit** + +```bash +git add apps/api/.env.example apps/api/src/demo +git commit -m "feat(api): add demo mode config" +``` + +--- + +### Task 5: `DemoSeeder` interface + registry + +**Files:** +- Create: `apps/api/src/demo/demo-seeder.interface.ts` +- Create: `apps/api/src/demo/demo-seeder.registry.ts` +- Create: `apps/api/src/demo/demo-seeder.registry.spec.ts` + +**Step 1: Write the failing test** + +```ts +// demo-seeder.registry.spec.ts +import { Test } from '@nestjs/testing'; +import { DemoSeederRegistry } from './demo-seeder.registry'; +import { DemoSeeder, DEMO_SEEDER } from './demo-seeder.interface'; + +class FakeSeeder implements DemoSeeder { + public called: string[] = []; + async seed(userId: string) { this.called.push(userId); } +} + +describe('DemoSeederRegistry', () => { + it('runs every registered seeder in order', async () => { + const a = new FakeSeeder(); + const b = new FakeSeeder(); + const module = await Test.createTestingModule({ + providers: [ + DemoSeederRegistry, + { provide: DEMO_SEEDER, useValue: a }, + { provide: DEMO_SEEDER, useValue: b }, + ], + }).compile(); + const reg = module.get(DemoSeederRegistry); + await reg.seedAll('user-1'); + expect(a.called).toEqual(['user-1']); + expect(b.called).toEqual(['user-1']); + }); +}); +``` + +**Step 2: Run to confirm failure** + +Run: `pnpm --filter api test -- demo-seeder.registry` +Expected: FAIL (files do not exist). + +**Step 3: Implement interface** + +`demo-seeder.interface.ts`: + +```ts +export const DEMO_SEEDER = Symbol('DEMO_SEEDER'); + +export interface DemoSeeder { + seed(userId: string): Promise; + getBlockedActions?(): string[]; +} +``` + +**Step 4: Implement registry** + +`demo-seeder.registry.ts`: + +```ts +import { Inject, Injectable, Optional } from '@nestjs/common'; +import { DEMO_SEEDER, DemoSeeder } from './demo-seeder.interface'; + +@Injectable() +export class DemoSeederRegistry { + constructor( + @Optional() @Inject(DEMO_SEEDER) private readonly seeders: DemoSeeder[] = [], + ) {} + + async seedAll(userId: string): Promise { + for (const seeder of this.seeders) { + await seeder.seed(userId); + } + } +} +``` + +> **Note:** NestJS multi-provider registration via `@Inject()` with a shared token. In `DemoModule` each seeder will be registered as `{ provide: DEMO_SEEDER, useClass: ..., multi: true }` style — for DI injection of arrays, use Nest's multi-inject pattern (`{ provide: DEMO_SEEDER, useExisting: ... }` and collect via a factory). If the simpler pattern fails, switch to the factory approach below in Task 8. + +**Step 5: Register in `DemoModule`** + +Add `DemoSeederRegistry` to providers and exports. + +**Step 6: Run tests** + +Run: `pnpm --filter api test -- demo-seeder` +Expected: PASS. + +**Step 7: Commit** + +```bash +git add apps/api/src/demo +git commit -m "feat(api): add demo seeder registry" +``` + +--- + +### Task 6: `@BlockDemo()` decorator + guard + +**Files:** +- Create: `apps/api/src/demo/decorators/block-demo.decorator.ts` +- Create: `apps/api/src/demo/guards/block-demo.guard.ts` +- Create: `apps/api/src/demo/guards/block-demo.guard.spec.ts` + +**Step 1: Write the failing test** + +```ts +// block-demo.guard.spec.ts +import { Reflector } from '@nestjs/core'; +import { ForbiddenException } from '@nestjs/common'; +import { BlockDemoGuard } from './block-demo.guard'; +import { BLOCK_DEMO_KEY } from '../decorators/block-demo.decorator'; + +function ctx(user: any, blocked: boolean) { + const reflector = new Reflector(); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(blocked); + const guard = new BlockDemoGuard(reflector); + const exec: any = { + getHandler: () => null, + getClass: () => null, + switchToHttp: () => ({ getRequest: () => ({ user }) }), + }; + return { guard, exec }; +} + +describe('BlockDemoGuard', () => { + it('allows non-demo users on blocked routes', () => { + const { guard, exec } = ctx({ id: 'u', isDemo: false }, true); + expect(guard.canActivate(exec)).toBe(true); + }); + it('blocks demo users on blocked routes', () => { + const { guard, exec } = ctx({ id: 'u', isDemo: true }, true); + expect(() => guard.canActivate(exec)).toThrow(ForbiddenException); + }); + it('allows demo users on non-blocked routes', () => { + const { guard, exec } = ctx({ id: 'u', isDemo: true }, false); + expect(guard.canActivate(exec)).toBe(true); + }); +}); +``` + +**Step 2: Run to confirm failure** + +Run: `pnpm --filter api test -- block-demo` +Expected: FAIL. + +**Step 3: Implement decorator** + +```ts +// block-demo.decorator.ts +import { SetMetadata } from '@nestjs/common'; +export const BLOCK_DEMO_KEY = 'blockDemo'; +export const BlockDemo = () => SetMetadata(BLOCK_DEMO_KEY, true); +``` + +**Step 4: Implement guard** + +```ts +// block-demo.guard.ts +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { BLOCK_DEMO_KEY } from '../decorators/block-demo.decorator'; + +@Injectable() +export class BlockDemoGuard implements CanActivate { + constructor(private reflector: Reflector) {} + canActivate(context: ExecutionContext): boolean { + const blocked = this.reflector.getAllAndOverride(BLOCK_DEMO_KEY, [ + context.getHandler(), context.getClass(), + ]); + if (!blocked) return true; + const req = context.switchToHttp().getRequest(); + if (req.user?.isDemo) { + throw new ForbiddenException('Not available in demo mode'); + } + return true; + } +} +``` + +**Step 5: Register the guard globally via `APP_GUARD` in `DemoModule`** + +```ts +import { APP_GUARD } from '@nestjs/core'; +// in providers: +{ provide: APP_GUARD, useClass: BlockDemoGuard }, +``` + +**Step 6: Apply `@BlockDemo()` to `change-password`** + +Modify `apps/api/src/auth/auth.controller.ts:162` (the `changePassword` handler) — import `BlockDemo` and add the decorator above `@Post('change-password')`. + +**Step 7: Run tests** + +Run: `pnpm --filter api test` +Expected: all green. + +**Step 8: Commit** + +```bash +git add apps/api/src/demo apps/api/src/auth/auth.controller.ts +git commit -m "feat(api): add BlockDemo decorator and guard" +``` + +--- + +### Task 7: Passwordless demo creation endpoint + +**Files:** +- Create: `apps/api/src/demo/demo.service.ts` +- Create: `apps/api/src/demo/demo.service.spec.ts` +- Create: `apps/api/src/demo/demo.controller.ts` + +**Step 1: Write the failing service test** + +```ts +// demo.service.spec.ts — minimum shape, expand as you implement +import { Test } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { DemoService } from './demo.service'; +import { DemoConfig } from './demo.config'; +import { DemoSeederRegistry } from './demo-seeder.registry'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthService } from '../auth/auth.service'; + +describe('DemoService', () => { + const makeModule = async (enabled: boolean) => { + const prisma = { + user: { create: jest.fn().mockResolvedValue({ id: 'u1', email: 'demo-x@demo.local', name: 'Demo User', isDemo: true }) }, + }; + const auth = { + hashPassword: jest.fn().mockResolvedValue('hashed'), + login: jest.fn(), + }; + const registry = { seedAll: jest.fn() }; + const config = { enabled, ttlMinutes: 60 }; + const module = await Test.createTestingModule({ + providers: [ + DemoService, + { provide: PrismaService, useValue: prisma }, + { provide: AuthService, useValue: auth }, + { provide: DemoSeederRegistry, useValue: registry }, + { provide: DemoConfig, useValue: config }, + ], + }).compile(); + return { svc: module.get(DemoService), prisma, auth, registry }; + }; + + it('throws NotFound when demo mode disabled', async () => { + const { svc } = await makeModule(false); + await expect(svc.createDemoUser()).rejects.toThrow(NotFoundException); + }); + + it('creates a demo user with unusable password, isDemo=true, then seeds', async () => { + const { svc, prisma, registry } = await makeModule(true); + await svc.createDemoUser(); + expect(prisma.user.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ isDemo: true }), + })); + expect(registry.seedAll).toHaveBeenCalledWith('u1'); + }); +}); +``` + +**Step 2: Run to confirm failure** + +Run: `pnpm --filter api test -- demo.service` +Expected: FAIL. + +**Step 3: Implement `DemoService`** + +```ts +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthService } from '../auth/auth.service'; +import { DemoConfig } from './demo.config'; +import { DemoSeederRegistry } from './demo-seeder.registry'; + +@Injectable() +export class DemoService { + private readonly logger = new Logger(DemoService.name); + + constructor( + private prisma: PrismaService, + private auth: AuthService, + private config: DemoConfig, + private seeders: DemoSeederRegistry, + ) {} + + async createDemoUser() { + if (!this.config.enabled) { + throw new NotFoundException(); + } + + // Random 32-byte password that is never returned or usable for login. + const unusablePassword = randomBytes(32).toString('hex'); + const hashed = await this.auth.hashPassword(unusablePassword); + + const user = await this.prisma.user.create({ + data: { + email: `demo-${randomUUID().slice(0, 8)}@demo.local`, + password: hashed, + name: 'Demo User', + isDemo: true, + }, + select: { id: true, email: true, name: true, isDemo: true, createdAt: true }, + }); + + await this.seeders.seedAll(user.id); + + this.logger.log(`Demo user created: ${user.id}`); + return user; + } +} +``` + +**Step 4: Add `issueTokens` helper to `AuthService`** (or reuse existing private methods) + +`AuthService` currently only issues tokens via `login(dto)`. We need a method that issues tokens for a known user without validating a password. In `auth.service.ts`, add: + +```ts +async issueTokensForUser(user: { id: string; email: string; name: string | null }) { + const accessToken = await this['generateAccessToken'](user.id, user.email); + const refreshToken = await this['generateRefreshToken'](user.id); + this.logger.log(`Tokens issued for user: ${user.email}`); + return { accessToken, refreshToken, user }; +} +``` + +(Or make `generateAccessToken`/`generateRefreshToken` non-private if cleaner — pick one approach.) + +**Step 5: Implement `DemoController`** + +```ts +import { Controller, Post, HttpCode, HttpStatus, Res, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; +import { Response } from 'express'; +import { Public } from '../auth/decorators/public.decorator'; +import { AuthService } from '../auth/auth.service'; +import { DemoService } from './demo.service'; +import { DemoConfig } from './demo.config'; + +@ApiTags('auth') +@Controller('api/auth') +export class DemoController { + constructor( + private demo: DemoService, + private auth: AuthService, + private config: DemoConfig, + ) {} + + @Public() + @Post('demo') + @HttpCode(HttpStatus.CREATED) + @UseGuards(ThrottlerGuard) + @Throttle({ default: { limit: 2, ttl: 60 * 60 * 1000 } }) + @ApiOperation({ summary: 'Create a passwordless demo account' }) + async createDemo(@Res({ passthrough: true }) res: Response) { + const user = await this.demo.createDemoUser(); + const tokens = await this.auth.issueTokensForUser({ + id: user.id, email: user.email, name: user.name, + }); + this.auth.setAuthCookies(res, tokens.accessToken, tokens.refreshToken); + const ttlMinutes = this.config.ttlMinutes; + const expiresAt = new Date(user.createdAt.getTime() + ttlMinutes * 60_000); + return { + user: { ...user, expiresAt }, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } +} +``` + +**Step 6: Register controller + service in `DemoModule`** + +Add `DemoService` to providers, `DemoController` to controllers. + +**Step 7: Run tests** + +Run: `pnpm --filter api test` +Expected: all green. + +**Step 8: Manual smoke** + +```bash +DEMO_MODE_ENABLED=true pnpm --filter api start:dev +# in another shell: +curl -X POST http://localhost:4000/api/auth/demo -i +``` + +Expected: 201 with cookies set and body containing `user.isDemo: true`. + +**Step 9: Commit** + +```bash +git add apps/api/src +git commit -m "feat(api): add passwordless demo account endpoint" +``` + +--- + +### Task 8: DevInbox demo seeder + +**Files:** +- Create: `apps/api/src/demo/seeders/devinbox-demo.seeder.ts` +- Create: `apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts` +- Create: `apps/api/src/demo/seeders/sample-emails.ts` + +**Step 1: Create sample email fixtures** + +`sample-emails.ts` — export an array of 5 sample emails with clearly-fake content (welcome, password-reset, receipt, newsletter, attachment). Every `from` address should be `noreply@example.com` or similar fake domain; every `href` should be `#`; no real URLs, no tracking pixels, no PII. + +**Step 2: Write the failing seeder test** + +```ts +// devinbox-demo.seeder.spec.ts +import { Test } from '@nestjs/testing'; +import { DevInboxDemoSeeder } from './devinbox-demo.seeder'; +import { PrismaService } from '../../prisma/prisma.service'; + +describe('DevInboxDemoSeeder', () => { + it('creates the demo-inbox project and 5 emails', async () => { + const prisma = { + project: { create: jest.fn().mockResolvedValue({ id: 'p1', slug: 'demo-inbox' }) }, + email: { createMany: jest.fn().mockResolvedValue({ count: 5 }) }, + }; + const module = await Test.createTestingModule({ + providers: [ + DevInboxDemoSeeder, + { provide: PrismaService, useValue: prisma }, + ], + }).compile(); + await module.get(DevInboxDemoSeeder).seed('user-1'); + expect(prisma.project.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ userId: 'user-1', slug: expect.stringContaining('demo-inbox') }), + })); + expect(prisma.email.createMany).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.arrayContaining([expect.any(Object)]), + })); + const createManyCall = prisma.email.createMany.mock.calls[0][0]; + expect(createManyCall.data).toHaveLength(5); + }); +}); +``` + +> **Note:** `slug` is globally `@unique` in the schema, so per-user seed slug must be unique per demo user. Use `demo-inbox-` where `` is 6 chars from `randomUUID()`. + +**Step 3: Run to confirm failure** + +Run: `pnpm --filter api test -- devinbox-demo.seeder` +Expected: FAIL. + +**Step 4: Implement seeder** + +```ts +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../prisma/prisma.service'; +import { DemoSeeder } from '../demo-seeder.interface'; +import { SAMPLE_EMAILS } from './sample-emails'; + +@Injectable() +export class DevInboxDemoSeeder implements DemoSeeder { + constructor(private prisma: PrismaService) {} + + async seed(userId: string): Promise { + const suffix = randomUUID().slice(0, 6); + const project = await this.prisma.project.create({ + data: { + userId, + slug: `demo-inbox-${suffix}`, + name: 'Demo Inbox', + description: 'Your personal demo project. Explore — this is temporary.', + }, + }); + + await this.prisma.email.createMany({ + data: SAMPLE_EMAILS.map((sample) => ({ + projectId: project.id, + from: sample.from, + to: sample.to, + subject: sample.subject, + bodyText: sample.bodyText, + bodyHtml: sample.bodyHtml, + headers: sample.headers, + })), + }); + } +} +``` + +**Step 5: Register in `DemoModule`** + +Add to providers: + +```ts +{ provide: DEMO_SEEDER, useClass: DevInboxDemoSeeder }, +``` + +And adjust `DemoSeederRegistry` to collect all providers registered under this token. Because NestJS does not natively support "inject all providers sharing a token" the clean pattern is: + +```ts +// In DemoModule: +{ + provide: 'DEMO_SEEDERS_ARRAY', + useFactory: (...seeders: DemoSeeder[]) => seeders, + inject: [DevInboxDemoSeeder /* , FutureToolSeeder, ... */], +}, +``` + +Then update `DemoSeederRegistry` to inject `'DEMO_SEEDERS_ARRAY'` instead of the `DEMO_SEEDER` symbol. Update Task 5's test accordingly if you need to (pass a concrete array via the factory token). If you already implemented Task 5 with the symbol pattern and it works, keep it. + +**Step 6: Run tests** + +Run: `pnpm --filter api test` +Expected: all green. + +**Step 7: Smoke check** + +Restart API (`DEMO_MODE_ENABLED=true`), call `/api/auth/demo`, verify the demo-inbox project and 5 emails exist for the new user. + +**Step 8: Commit** + +```bash +git add apps/api/src/demo +git commit -m "feat(api): add DevInbox demo seeder" +``` + +--- + +### Task 9: Resource caps in `ProjectsService` + +**Files:** +- Modify: `apps/api/src/projects/projects.service.ts:15-38` (`create` method) +- Modify: `apps/api/src/projects/projects.controller.ts` (pass `isDemo` through) +- Create: `apps/api/src/projects/projects.service.spec.ts` (if absent — add a targeted test) + +**Step 1: Write the failing test** + +Add a test that verifies: when `isDemo=true` and the user already has 2 projects, `create()` throws `ForbiddenException` with a demo-aware message. + +**Step 2: Run to confirm failure** + +Run: `pnpm --filter api test -- projects.service` +Expected: FAIL. + +**Step 3: Update `ProjectsService.create` signature** + +Change: + +```ts +async create(userId: string, dto: CreateProjectDto, isDemo = false) +``` + +Before attempting the Prisma create, if `isDemo`: + +```ts +if (isDemo) { + const count = await this.prisma.project.count({ where: { userId } }); + if (count >= 2) { + throw new ForbiddenException('Demo accounts are limited to 2 projects. Sign up to create more.'); + } +} +``` + +**Step 4: Update the controller to pass `req.user.isDemo`** + +In `projects.controller.ts` the `create` handler, pass `req.user.isDemo` as the third argument. + +**Step 5: Run tests** + +Run: `pnpm --filter api test` +Expected: all green. + +**Step 6: Commit** + +```bash +git add apps/api/src/projects +git commit -m "feat(api): enforce 2-project cap for demo accounts" +``` + +--- + +### Task 10: Inject-test-email endpoint + +**Files:** +- Create: `apps/api/src/demo/demo-emails.controller.ts` +- Modify: `apps/api/src/demo/demo.service.ts` (add `injectTestEmail`) +- Modify: `apps/api/src/demo/demo.service.spec.ts` +- Create: `apps/api/src/demo/seeders/injectable-emails.ts` (pool of ~10 fake emails) + +**Step 1: Write the failing service test** + +Service test covers: +- Non-demo user → `ForbiddenException` +- Project not owned by user → `NotFoundException` +- Cap reached (20 injected) → `ForbiddenException` +- Happy path → one email row created + +**Step 2: Run to confirm failure** + +Run: `pnpm --filter api test -- demo.service` +Expected: FAIL. + +**Step 3: Implement `injectTestEmail(userId, isDemo, projectId)`** + +Enforce: `isDemo` required; verify project ownership; count existing emails for the project; if count – seededCount ≥ 20, throw; else pick a random template from the pool and insert a new row. + +> For cap accounting, the simplest rule is: *any* email row with `from` in a special "injected" sender domain (e.g., `@inject.demo.local`) counts. That avoids needing a separate marker column. Choose this or a dedicated boolean — note the trade-off in the commit message. + +**Step 4: Implement controller** + +```ts +// demo-emails.controller.ts +@ApiTags('demo') +@Controller('api/projects') +export class DemoEmailsController { + constructor(private demo: DemoService) {} + + @Post(':id/demo/inject-email') + @HttpCode(HttpStatus.CREATED) + async inject(@Req() req: Request, @Param('id') id: string) { + const user = req.user as { id: string; isDemo: boolean }; + return this.demo.injectTestEmail(user.id, user.isDemo, id); + } +} +``` + +**Step 5: Register controller in `DemoModule`** + +**Step 6: Run tests** + +Run: `pnpm --filter api test` +Expected: all green. + +**Step 7: Commit** + +```bash +git add apps/api/src/demo +git commit -m "feat(api): add demo inject-test-email endpoint" +``` + +--- + +### Task 11: Cleanup cron + +**Files:** +- Create: `apps/api/src/demo/demo-cleanup.service.ts` +- Create: `apps/api/src/demo/demo-cleanup.service.spec.ts` + +**Step 1: Write the failing test** + +Verify: when `config.enabled=false`, the handler is a no-op. When enabled, `prisma.user.deleteMany` is called with the right filter (`isDemo: true, createdAt: { lt: }`). + +**Step 2: Run to confirm failure** + +Run: `pnpm --filter api test -- demo-cleanup` +Expected: FAIL. + +**Step 3: Implement service** + +```ts +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { DemoConfig } from './demo.config'; + +@Injectable() +export class DemoCleanupService { + private readonly logger = new Logger(DemoCleanupService.name); + + constructor(private prisma: PrismaService, private config: DemoConfig) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async handleCleanup() { + if (!this.config.enabled) return; + const cutoff = new Date(Date.now() - this.config.ttlMinutes * 60_000); + const result = await this.prisma.user.deleteMany({ + where: { isDemo: true, createdAt: { lt: cutoff } }, + }); + if (result.count > 0) { + this.logger.log(`Cleaned up ${result.count} expired demo user(s)`); + } + } +} +``` + +**Step 4: Register in `DemoModule`** + +**Step 5: Run tests** + +Run: `pnpm --filter api test` +Expected: all green. + +**Step 6: Commit** + +```bash +git add apps/api/src/demo +git commit -m "feat(api): add demo cleanup cron" +``` + +--- + +### Task 12: Surface expiration on `GET /api/auth/me` + +**Files:** +- Modify: `apps/api/src/auth/auth.service.ts:248-265` (`getCurrentUser`) + +**Step 1: Update `select` to include `isDemo` and `createdAt`.** + +**Step 2: Return `expiresAt` when `user.isDemo`** + +Inject `DemoConfig` into `AuthService`, or inline a TTL read. Preferably the former — add `DemoConfig` to `AuthModule` imports/providers (via `DemoModule` export). + +Return shape: +```ts +return { + ...user, + ...(user.isDemo + ? { expiresAt: new Date(user.createdAt.getTime() + ttlMinutes * 60_000) } + : {}), +}; +``` + +**Step 3: Commit** + +```bash +git add apps/api/src +git commit -m "feat(api): surface demo expiresAt on /auth/me" +``` + +--- + +## Phase 3 — Web UI + +### Task 13: "Try Demo" button on login page + +**Files:** +- Modify: `apps/web/app/login/page.tsx` (or equivalent — read the file to find the exact location) +- Modify: `apps/web/lib/api.ts` (or wherever the API client lives — check `apps/web/lib/`) + +**Step 1: Read existing login page and API client to understand patterns.** + +**Step 2: Add a `tryDemo()` client that calls `POST /api/auth/demo`.** + +**Step 3: Add a "Try Demo" button below the login form.** + +Visibility: hide the button if `NEXT_PUBLIC_DEMO_MODE_ENABLED !== 'true'`. (Add env var to `apps/web/.env.example`.) On click: call `tryDemo()`, on success redirect to `/dashboard`, on 429 show a clear message ("Demo limit reached, try again later"), on 404 hide the button permanently for this session. + +**Step 4: Commit** + +```bash +git add apps/web +git commit -m "feat(web): add Try Demo button on login page" +``` + +--- + +### Task 14: Demo countdown banner + +**Files:** +- Create: `apps/web/components/demo-banner.tsx` +- Modify: `apps/web/app/dashboard/layout.tsx` (or root dashboard layout — locate first) + +**Step 1: Implement `DemoBanner` component** + +Reads `user.isDemo` and `user.expiresAt` (from an existing user context — check `apps/web/contexts/`). Renders nothing when `!user.isDemo`. Otherwise shows: "Demo expires in {mm}m {ss}s — [Sign up to keep your data]". Updates every second via `setInterval`. When countdown hits 0, redirect to `/login?expired=1`. + +**Step 2: Mount in dashboard layout.** + +**Step 3: Commit** + +```bash +git add apps/web +git commit -m "feat(web): add demo countdown banner" +``` + +--- + +### Task 15: Cap indicators and "Inject test email" button + +**Files:** +- Modify: `apps/web/app/dashboard/projects/page.tsx` (or wherever the project list lives — locate first) +- Modify: the single-project view + +**Step 1: On projects list page, when `user.isDemo`, show `{count}/2 projects used` next to the "Create project" button. Disable the button and show a tooltip when `count >= 2`.** + +**Step 2: On single-project page, when `user.isDemo`, render an "Inject test email" button. On click, POST to `/api/projects/:id/demo/inject-email` and refresh email list.** + +**Step 3: Commit** + +```bash +git add apps/web +git commit -m "feat(web): demo cap indicators and inject-email button" +``` + +--- + +## Phase 4 — Verification + +### Task 16: End-to-end manual smoke + +**Step 1: Set up** + +```bash +# In apps/api/.env +DEMO_MODE_ENABLED=true +# In apps/web/.env.local +NEXT_PUBLIC_DEMO_MODE_ENABLED=true +``` + +Run: `pnpm dev` + +**Step 2: Scenarios to verify** + +- [ ] Click "Try Demo" on login page → lands in dashboard with demo-inbox project visible, 5 seeded emails +- [ ] Countdown banner visible, counts down +- [ ] Can create a 2nd project — seeing `2/2` afterward +- [ ] Cannot create a 3rd project (UI disabled; curl against API also returns 403) +- [ ] "Inject test email" button adds a new email to the list +- [ ] Can inject up to 20 then gets blocked +- [ ] `change-password` route returns 403 in demo session +- [ ] Wait for (or force) cleanup by setting `DEMO_TTL_MINUTES=0` briefly — cron deletes the user, next `/me` returns 401 +- [ ] With `DEMO_MODE_ENABLED=false`, `/api/auth/demo` returns 404 and button is hidden +- [ ] Rate limit: creating 3 demo accounts from the same IP within an hour → 3rd returns 429 + +**Step 3: Fix any regressions, then:** + +```bash +pnpm --filter api test +pnpm --filter api check-types +pnpm --filter web check-types +pnpm lint +``` + +Expected: all green. + +**Step 4: Commit any fixes** + +```bash +git commit -m "fix: address demo mode smoke-test findings" +``` + +--- + +## Verification checklist before finishing + +- [ ] All unit tests pass: `pnpm --filter api test` +- [ ] Type checks pass: `pnpm check-types` +- [ ] Lint passes: `pnpm lint` +- [ ] Build passes: `pnpm build` +- [ ] Manual smoke (Task 16) all scenarios green +- [ ] `DEMO_MODE_ENABLED=false` (default) behaves as if this feature doesn't exist — no new routes reachable, no cron running, no UI button +- [ ] All sample email content is clearly fake, no real URLs/PII/secrets +- [ ] README or docs mention demo mode env vars (optional but nice) + +After all boxes are ticked, use `superpowers:finishing-a-development-branch` to decide integration path. From 4783f91d89c0ac0b40136d8d6b4ebda18c477a75 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 05:54:29 +0800 Subject: [PATCH 05/27] feat(api): add isDemo flag to users --- .../migration.sql | 5 +++++ apps/api/prisma/schema.prisma | 14 ++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 apps/api/prisma/migrations/20260414215359_add_is_demo_to_users/migration.sql diff --git a/apps/api/prisma/migrations/20260414215359_add_is_demo_to_users/migration.sql b/apps/api/prisma/migrations/20260414215359_add_is_demo_to_users/migration.sql new file mode 100644 index 0000000..a18e06e --- /dev/null +++ b/apps/api/prisma/migrations/20260414215359_add_is_demo_to_users/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "is_demo" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "users_is_demo_created_at_idx" ON "users"("is_demo", "created_at"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b8fcd49..8ba2721 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -18,15 +18,17 @@ datasource db { model User { id String @id @default(uuid()) email String @unique - password String // bcrypt hashed + password String // bcrypt hashed name String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + isDemo Boolean @default(false) @map("is_demo") // Relations projects Project[] refreshTokens RefreshToken[] + @@index([isDemo, createdAt]) @@map("users") } @@ -41,10 +43,10 @@ model RefreshToken { // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@map("refresh_tokens") @@index([userId]) @@index([token]) @@index([expiresAt]) + @@map("refresh_tokens") } model Project { @@ -60,8 +62,8 @@ model Project { user User @relation(fields: [userId], references: [id], onDelete: Cascade) emails Email[] - @@map("projects") @@index([userId]) + @@map("projects") } model Email { @@ -72,7 +74,7 @@ model Email { subject String? bodyText String? @map("body_text") bodyHtml String? @map("body_html") - headers Json // Store email headers as JSON + headers Json // Store email headers as JSON rawMime String? @map("raw_mime") // Full RFC822 content receivedAt DateTime @default(now()) @map("received_at") isRead Boolean @default(false) @map("is_read") @@ -81,9 +83,9 @@ model Email { project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) attachments Attachment[] - @@map("emails") @@index([projectId]) @@index([receivedAt]) + @@map("emails") } model Attachment { @@ -97,6 +99,6 @@ model Attachment { // Relations email Email @relation(fields: [emailId], references: [id], onDelete: Cascade) - @@map("attachments") @@index([emailId]) + @@map("attachments") } From 6185351b8296887fc817ea37c65003ab0f297809 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 05:58:53 +0800 Subject: [PATCH 06/27] feat(api): expose isDemo on authenticated user Add isDemo to the Prisma select in both jwt and jwt-cookie strategies so req.user.isDemo is populated for downstream guards (BlockDemo). Update the JwtAuthGuard handleRequest TUser default to include isDemo: boolean. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/auth/guards/jwt-auth.guard.ts | 9 +++- .../auth/strategies/jwt-cookie.strategy.ts | 2 +- .../src/auth/strategies/jwt.strategy.spec.ts | 41 +++++++++++++++++++ apps/api/src/auth/strategies/jwt.strategy.ts | 2 +- 4 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/auth/strategies/jwt.strategy.spec.ts diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts index d81d79a..d45d11f 100644 --- a/apps/api/src/auth/guards/jwt-auth.guard.ts +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -41,7 +41,14 @@ export class JwtAuthGuard extends AuthGuard(['jwt-cookie', 'jwt']) { return super.canActivate(context); } - handleRequest( + handleRequest< + TUser = { + id: string; + email: string; + name: string | null; + isDemo: boolean; + }, + >( err: Error | null, user: TUser | false, ): TUser { diff --git a/apps/api/src/auth/strategies/jwt-cookie.strategy.ts b/apps/api/src/auth/strategies/jwt-cookie.strategy.ts index 080f450..f311f5c 100644 --- a/apps/api/src/auth/strategies/jwt-cookie.strategy.ts +++ b/apps/api/src/auth/strategies/jwt-cookie.strategy.ts @@ -45,7 +45,7 @@ export class JwtCookieStrategy extends PassportStrategy( // Fetch user from database to ensure they still exist const user = await this.prisma.user.findUnique({ where: { id: payload.userId }, - select: { id: true, email: true, name: true }, + select: { id: true, email: true, name: true, isDemo: true }, }); if (!user) { diff --git a/apps/api/src/auth/strategies/jwt.strategy.spec.ts b/apps/api/src/auth/strategies/jwt.strategy.spec.ts new file mode 100644 index 0000000..70ca1d3 --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,41 @@ +import { Test } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { JwtStrategy } from './jwt.strategy'; +import { PrismaService } from '../../prisma/prisma.service'; + +describe('JwtStrategy', () => { + let strategy: JwtStrategy; + let prisma: { user: { findUnique: jest.Mock } }; + + beforeEach(async () => { + prisma = { user: { findUnique: jest.fn() } }; + const module = await Test.createTestingModule({ + providers: [ + JwtStrategy, + { provide: PrismaService, useValue: prisma }, + { provide: ConfigService, useValue: { get: () => 'secret' } }, + ], + }).compile(); + strategy = module.get(JwtStrategy); + }); + + it('returns isDemo on the authenticated user', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'u1', + email: 'a@b.c', + name: 'A', + isDemo: true, + }); + const result = await strategy.validate({ userId: 'u1', email: 'a@b.c' }); + expect(result).toEqual({ + id: 'u1', + email: 'a@b.c', + name: 'A', + isDemo: true, + }); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'u1' }, + select: { id: true, email: true, name: true, isDemo: true }, + }); + }); +}); diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index 9313870..1537299 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -20,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { async validate(payload: { userId: string; email: string }) { const user = await this.prisma.user.findUnique({ where: { id: payload.userId }, - select: { id: true, email: true, name: true }, + select: { id: true, email: true, name: true, isDemo: true }, }); if (!user) { From 265b5febd86d623fddb7c9bf2b8422ce5f498abf Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 06:06:03 +0800 Subject: [PATCH 07/27] test(api): add jwt-cookie strategy isDemo coverage --- .../strategies/jwt-cookie.strategy.spec.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/api/src/auth/strategies/jwt-cookie.strategy.spec.ts diff --git a/apps/api/src/auth/strategies/jwt-cookie.strategy.spec.ts b/apps/api/src/auth/strategies/jwt-cookie.strategy.spec.ts new file mode 100644 index 0000000..30f473d --- /dev/null +++ b/apps/api/src/auth/strategies/jwt-cookie.strategy.spec.ts @@ -0,0 +1,41 @@ +import { Test } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { JwtCookieStrategy } from './jwt-cookie.strategy'; +import { PrismaService } from '../../prisma/prisma.service'; + +describe('JwtCookieStrategy', () => { + let strategy: JwtCookieStrategy; + let prisma: { user: { findUnique: jest.Mock } }; + + beforeEach(async () => { + prisma = { user: { findUnique: jest.fn() } }; + const module = await Test.createTestingModule({ + providers: [ + JwtCookieStrategy, + { provide: PrismaService, useValue: prisma }, + { provide: ConfigService, useValue: { get: () => 'secret' } }, + ], + }).compile(); + strategy = module.get(JwtCookieStrategy); + }); + + it('returns isDemo on the authenticated user', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'u1', + email: 'a@b.c', + name: 'A', + isDemo: true, + }); + const result = await strategy.validate({ userId: 'u1', email: 'a@b.c' }); + expect(result).toEqual({ + id: 'u1', + email: 'a@b.c', + name: 'A', + isDemo: true, + }); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'u1' }, + select: { id: true, email: true, name: true, isDemo: true }, + }); + }); +}); From 1f72bf614f63cca927caddb164425b8732a34980 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 06:10:06 +0800 Subject: [PATCH 08/27] feat(api): scaffold demo module --- apps/api/package.json | 1 + apps/api/src/app.module.ts | 2 ++ apps/api/src/demo/demo.module.ts | 11 ++++++++++ pnpm-lock.yaml | 35 ++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 apps/api/src/demo/demo.module.ts diff --git a/apps/api/package.json b/apps/api/package.json index c0cad68..1fd38c4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,7 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^11.1.16", + "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@prisma/client": "^6.19.2", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1189a88..430a677 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { DemoModule } from './demo/demo.module'; import { EmailsModule } from './emails/emails.module'; import { PrismaModule } from './prisma/prisma.module'; import { ProjectsModule } from './projects/projects.module'; @@ -21,6 +22,7 @@ import { SmtpModule } from './smtp/smtp.module'; ProjectsModule, EmailsModule, SmtpModule, + DemoModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/demo/demo.module.ts b/apps/api/src/demo/demo.module.ts new file mode 100644 index 0000000..6c5e098 --- /dev/null +++ b/apps/api/src/demo/demo.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), PrismaModule, AuthModule], + providers: [], + controllers: [], +}) +export class DemoModule {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ecd131..21ffdeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@nestjs/platform-express': specifier: ^11.1.16 version: 11.1.16(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/swagger': specifier: ^11.2.6 version: 11.2.6(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) @@ -1218,6 +1221,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.9': resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} peerDependencies: @@ -1760,6 +1769,9 @@ packages: '@types/jsonwebtoken@9.0.5': resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mailparser@3.4.6': resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==} @@ -2497,6 +2509,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3789,6 +3805,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -6079,6 +6099,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.2)': dependencies: '@angular-devkit/core': 19.2.17(chokidar@4.0.3) @@ -6581,6 +6607,8 @@ snapshots: dependencies: '@types/node': 22.18.12 + '@types/luxon@3.7.1': {} + '@types/mailparser@3.4.6': dependencies: '@types/node': 22.18.12 @@ -7481,6 +7509,11 @@ snapshots: create-require@1.1.1: {} + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9093,6 +9126,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 From 0453daa5a24d6d40dd631250f5a7ff800230be7b Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 06:22:39 +0800 Subject: [PATCH 09/27] feat(api): add demo mode config --- apps/api/.env.example | 7 +++++++ apps/api/src/demo/demo.config.ts | 19 +++++++++++++++++++ apps/api/src/demo/demo.module.ts | 4 +++- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/demo/demo.config.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 22ec775..60d171a 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -29,3 +29,10 @@ CORS_ORIGINS="http://localhost:4001,http://localhost:4000" # Swagger Documentation SWAGGER_USER="admin" SWAGGER_PASSWORD="admin123" + +# Demo mode (opt-in). When true, exposes POST /api/auth/demo and runs cleanup cron. +DEMO_MODE_ENABLED=false +# Demo account TTL in minutes +DEMO_TTL_MINUTES=60 +# Max demo account creations per IP per hour +DEMO_RATE_LIMIT_PER_HOUR=2 diff --git a/apps/api/src/demo/demo.config.ts b/apps/api/src/demo/demo.config.ts new file mode 100644 index 0000000..f2168d5 --- /dev/null +++ b/apps/api/src/demo/demo.config.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class DemoConfig { + constructor(private config: ConfigService) {} + + get enabled(): boolean { + return this.config.get('DEMO_MODE_ENABLED') === 'true'; + } + + get ttlMinutes(): number { + return parseInt(this.config.get('DEMO_TTL_MINUTES') || '60', 10); + } + + get rateLimitPerHour(): number { + return parseInt(this.config.get('DEMO_RATE_LIMIT_PER_HOUR') || '2', 10); + } +} diff --git a/apps/api/src/demo/demo.module.ts b/apps/api/src/demo/demo.module.ts index 6c5e098..f3618f1 100644 --- a/apps/api/src/demo/demo.module.ts +++ b/apps/api/src/demo/demo.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; +import { DemoConfig } from './demo.config'; @Module({ imports: [ScheduleModule.forRoot(), PrismaModule, AuthModule], - providers: [], + providers: [DemoConfig], controllers: [], + exports: [DemoConfig], }) export class DemoModule {} From b80d1401bf7669840540f3962de6ab31df480921 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 06:35:24 +0800 Subject: [PATCH 10/27] feat(api): add demo seeder registry Introduce DemoSeeder interface and DemoSeederRegistry to coordinate tool-specific demo data seeding. Uses a factory-based provider in DemoModule so concrete seeders (DevInbox, future tools) can be appended to the inject list without colliding on a shared DI token. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/demo/demo-seeder.interface.ts | 25 +++++++ .../api/src/demo/demo-seeder.registry.spec.ts | 70 +++++++++++++++++++ apps/api/src/demo/demo-seeder.registry.ts | 22 ++++++ apps/api/src/demo/demo.module.ts | 15 +++- 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/demo/demo-seeder.interface.ts create mode 100644 apps/api/src/demo/demo-seeder.registry.spec.ts create mode 100644 apps/api/src/demo/demo-seeder.registry.ts diff --git a/apps/api/src/demo/demo-seeder.interface.ts b/apps/api/src/demo/demo-seeder.interface.ts new file mode 100644 index 0000000..04dbe32 --- /dev/null +++ b/apps/api/src/demo/demo-seeder.interface.ts @@ -0,0 +1,25 @@ +/** + * Token kept for forward compatibility with potential Nest multi-provider + * wiring, but not used for DI injection of the registry (see + * DemoSeederRegistry, which uses a factory-based approach to collect seeders). + */ +export const DEMO_SEEDER = Symbol('DEMO_SEEDER'); + +/** + * Contract implemented by each tool-specific demo seeder. + * + * Each seeder is responsible for populating realistic sample data scoped to + * the given userId when a demo account is (re)created. + */ +export interface DemoSeeder { + /** + * Seed demo data for the given user. + */ + seed(userId: string): Promise; + + /** + * Optional list of action identifiers this seeder considers blocked for + * demo accounts (e.g. destructive or external-effect operations). + */ + getBlockedActions?(): string[]; +} diff --git a/apps/api/src/demo/demo-seeder.registry.spec.ts b/apps/api/src/demo/demo-seeder.registry.spec.ts new file mode 100644 index 0000000..9d8f99a --- /dev/null +++ b/apps/api/src/demo/demo-seeder.registry.spec.ts @@ -0,0 +1,70 @@ +import { DemoSeederRegistry } from './demo-seeder.registry'; +import { DemoSeeder } from './demo-seeder.interface'; + +class FakeSeeder implements DemoSeeder { + public called: string[] = []; + async seed(userId: string): Promise { + this.called.push(userId); + } +} + +describe('DemoSeederRegistry', () => { + it('runs every registered seeder with the given userId', async () => { + const a = new FakeSeeder(); + const b = new FakeSeeder(); + const registry = new DemoSeederRegistry([a, b]); + + await registry.seedAll('user-1'); + + expect(a.called).toEqual(['user-1']); + expect(b.called).toEqual(['user-1']); + }); + + it('invokes seeders in registration order', async () => { + const order: string[] = []; + const a: DemoSeeder = { + async seed() { + order.push('a'); + }, + }; + const b: DemoSeeder = { + async seed() { + order.push('b'); + }, + }; + const c: DemoSeeder = { + async seed() { + order.push('c'); + }, + }; + const registry = new DemoSeederRegistry([a, b, c]); + + await registry.seedAll('user-2'); + + expect(order).toEqual(['a', 'b', 'c']); + }); + + it('awaits each seeder sequentially (not in parallel)', async () => { + const events: string[] = []; + const makeSeeder = (name: string): DemoSeeder => ({ + async seed() { + events.push(`${name}:start`); + await new Promise((resolve) => setTimeout(resolve, 10)); + events.push(`${name}:end`); + }, + }); + const registry = new DemoSeederRegistry([ + makeSeeder('a'), + makeSeeder('b'), + ]); + + await registry.seedAll('user-3'); + + expect(events).toEqual(['a:start', 'a:end', 'b:start', 'b:end']); + }); + + it('no-ops when no seeders are registered', async () => { + const registry = new DemoSeederRegistry([]); + await expect(registry.seedAll('user-4')).resolves.toBeUndefined(); + }); +}); diff --git a/apps/api/src/demo/demo-seeder.registry.ts b/apps/api/src/demo/demo-seeder.registry.ts new file mode 100644 index 0000000..4894f51 --- /dev/null +++ b/apps/api/src/demo/demo-seeder.registry.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { DemoSeeder } from './demo-seeder.interface'; + +/** + * Aggregates all tool-specific DemoSeeder implementations and runs them in + * sequence for a given user. + * + * Seeders are provided via constructor injection so that DemoModule can wire + * them up through a factory provider (see Task 8). This avoids the + * Nest multi-provider token ambiguity that arises when several providers share + * the same DI token. + */ +@Injectable() +export class DemoSeederRegistry { + constructor(private readonly seeders: DemoSeeder[] = []) {} + + async seedAll(userId: string): Promise { + for (const seeder of this.seeders) { + await seeder.seed(userId); + } + } +} diff --git a/apps/api/src/demo/demo.module.ts b/apps/api/src/demo/demo.module.ts index f3618f1..29e671b 100644 --- a/apps/api/src/demo/demo.module.ts +++ b/apps/api/src/demo/demo.module.ts @@ -3,11 +3,22 @@ import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; import { DemoConfig } from './demo.config'; +import { DemoSeeder } from './demo-seeder.interface'; +import { DemoSeederRegistry } from './demo-seeder.registry'; @Module({ imports: [ScheduleModule.forRoot(), PrismaModule, AuthModule], - providers: [DemoConfig], + providers: [ + DemoConfig, + { + provide: DemoSeederRegistry, + // Concrete seeders (DevInbox, etc.) will be appended to `inject` and the + // factory signature as they are introduced in later tasks. + useFactory: (...seeders: DemoSeeder[]) => new DemoSeederRegistry(seeders), + inject: [], + }, + ], controllers: [], - exports: [DemoConfig], + exports: [DemoConfig, DemoSeederRegistry], }) export class DemoModule {} From 8679e4d21225c23b14074b11036b5a401cf990df Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 06:38:30 +0800 Subject: [PATCH 11/27] refactor(api): drop unused DEMO_SEEDER symbol --- apps/api/src/demo/demo-seeder.interface.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/api/src/demo/demo-seeder.interface.ts b/apps/api/src/demo/demo-seeder.interface.ts index 04dbe32..fc6a8ef 100644 --- a/apps/api/src/demo/demo-seeder.interface.ts +++ b/apps/api/src/demo/demo-seeder.interface.ts @@ -1,10 +1,3 @@ -/** - * Token kept for forward compatibility with potential Nest multi-provider - * wiring, but not used for DI injection of the registry (see - * DemoSeederRegistry, which uses a factory-based approach to collect seeders). - */ -export const DEMO_SEEDER = Symbol('DEMO_SEEDER'); - /** * Contract implemented by each tool-specific demo seeder. * From 523f499858fee4672bec264cd91eb009cfdebd6a Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 06:56:54 +0800 Subject: [PATCH 12/27] feat(api): add BlockDemo decorator and guard Introduces @BlockDemo() decorator and a globally-registered BlockDemoGuard that rejects demo users on routes marked sensitive. Applied to the change-password handler as the first protected route. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/auth/auth.controller.ts | 2 + .../demo/decorators/block-demo.decorator.ts | 4 ++ apps/api/src/demo/demo.module.ts | 3 ++ .../src/demo/guards/block-demo.guard.spec.ts | 37 +++++++++++++++++++ apps/api/src/demo/guards/block-demo.guard.ts | 26 +++++++++++++ 5 files changed, 72 insertions(+) create mode 100644 apps/api/src/demo/decorators/block-demo.decorator.ts create mode 100644 apps/api/src/demo/guards/block-demo.guard.spec.ts create mode 100644 apps/api/src/demo/guards/block-demo.guard.ts diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 1ec5849..a13faf7 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -17,6 +17,7 @@ import { } from '@nestjs/swagger'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { Request, Response } from 'express'; +import { BlockDemo } from '../demo/decorators/block-demo.decorator'; import { AuthService } from './auth.service'; import { Public } from './decorators/public.decorator'; import { ChangePasswordDto } from './dto/change-password.dto'; @@ -159,6 +160,7 @@ export class AuthController { return { message: 'Logout successful' }; } + @BlockDemo() @Post('change-password') @HttpCode(HttpStatus.OK) @ApiBearerAuth() diff --git a/apps/api/src/demo/decorators/block-demo.decorator.ts b/apps/api/src/demo/decorators/block-demo.decorator.ts new file mode 100644 index 0000000..455704b --- /dev/null +++ b/apps/api/src/demo/decorators/block-demo.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const BLOCK_DEMO_KEY = 'blockDemo'; +export const BlockDemo = () => SetMetadata(BLOCK_DEMO_KEY, true); diff --git a/apps/api/src/demo/demo.module.ts b/apps/api/src/demo/demo.module.ts index 29e671b..3868e78 100644 --- a/apps/api/src/demo/demo.module.ts +++ b/apps/api/src/demo/demo.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; import { DemoConfig } from './demo.config'; import { DemoSeeder } from './demo-seeder.interface'; import { DemoSeederRegistry } from './demo-seeder.registry'; +import { BlockDemoGuard } from './guards/block-demo.guard'; @Module({ imports: [ScheduleModule.forRoot(), PrismaModule, AuthModule], @@ -17,6 +19,7 @@ import { DemoSeederRegistry } from './demo-seeder.registry'; useFactory: (...seeders: DemoSeeder[]) => new DemoSeederRegistry(seeders), inject: [], }, + { provide: APP_GUARD, useClass: BlockDemoGuard }, ], controllers: [], exports: [DemoConfig, DemoSeederRegistry], diff --git a/apps/api/src/demo/guards/block-demo.guard.spec.ts b/apps/api/src/demo/guards/block-demo.guard.spec.ts new file mode 100644 index 0000000..38b99ae --- /dev/null +++ b/apps/api/src/demo/guards/block-demo.guard.spec.ts @@ -0,0 +1,37 @@ +import { Reflector } from '@nestjs/core'; +import { ForbiddenException, ExecutionContext } from '@nestjs/common'; +import { BlockDemoGuard } from './block-demo.guard'; +import { BLOCK_DEMO_KEY } from '../decorators/block-demo.decorator'; + +function ctx(user: { id: string; isDemo: boolean }, blocked: boolean) { + const reflector = new Reflector(); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(blocked); + const guard = new BlockDemoGuard(reflector); + const exec = { + getHandler: () => null, + getClass: () => null, + switchToHttp: () => ({ getRequest: () => ({ user }) }), + } as unknown as ExecutionContext; + return { guard, exec }; +} + +describe('BlockDemoGuard', () => { + it('uses BLOCK_DEMO_KEY metadata key', () => { + expect(BLOCK_DEMO_KEY).toBe('blockDemo'); + }); + + it('allows non-demo users on blocked routes', () => { + const { guard, exec } = ctx({ id: 'u', isDemo: false }, true); + expect(guard.canActivate(exec)).toBe(true); + }); + + it('blocks demo users on blocked routes', () => { + const { guard, exec } = ctx({ id: 'u', isDemo: true }, true); + expect(() => guard.canActivate(exec)).toThrow(ForbiddenException); + }); + + it('allows demo users on non-blocked routes', () => { + const { guard, exec } = ctx({ id: 'u', isDemo: true }, false); + expect(guard.canActivate(exec)).toBe(true); + }); +}); diff --git a/apps/api/src/demo/guards/block-demo.guard.ts b/apps/api/src/demo/guards/block-demo.guard.ts new file mode 100644 index 0000000..81a96aa --- /dev/null +++ b/apps/api/src/demo/guards/block-demo.guard.ts @@ -0,0 +1,26 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { BLOCK_DEMO_KEY } from '../decorators/block-demo.decorator'; + +@Injectable() +export class BlockDemoGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const blocked = this.reflector.getAllAndOverride(BLOCK_DEMO_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!blocked) return true; + const req = context.switchToHttp().getRequest<{ user?: { isDemo?: boolean } }>(); + if (req.user?.isDemo) { + throw new ForbiddenException('Not available in demo mode'); + } + return true; + } +} From b937f3e3441152d58295af7ce48e16b39cb937ed Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 07:02:14 +0800 Subject: [PATCH 13/27] fix(api): make BlockDemoGuard fail-closed on missing auth Throw UnauthorizedException when a @BlockDemo()-decorated route is hit without req.user, instead of silently allowing. Removes dependence on APP_GUARD ordering between JwtAuthGuard and BlockDemoGuard. Adds a unit test for the missing-user case and documents the invariant on the guard class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/demo/guards/block-demo.guard.spec.ts | 21 +++++++++++++++++-- apps/api/src/demo/guards/block-demo.guard.ts | 20 ++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/api/src/demo/guards/block-demo.guard.spec.ts b/apps/api/src/demo/guards/block-demo.guard.spec.ts index 38b99ae..f8df9f1 100644 --- a/apps/api/src/demo/guards/block-demo.guard.spec.ts +++ b/apps/api/src/demo/guards/block-demo.guard.spec.ts @@ -1,9 +1,16 @@ import { Reflector } from '@nestjs/core'; -import { ForbiddenException, ExecutionContext } from '@nestjs/common'; +import { + ForbiddenException, + UnauthorizedException, + ExecutionContext, +} from '@nestjs/common'; import { BlockDemoGuard } from './block-demo.guard'; import { BLOCK_DEMO_KEY } from '../decorators/block-demo.decorator'; -function ctx(user: { id: string; isDemo: boolean }, blocked: boolean) { +function ctx( + user: { id: string; isDemo: boolean } | undefined, + blocked: boolean, +) { const reflector = new Reflector(); jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(blocked); const guard = new BlockDemoGuard(reflector); @@ -34,4 +41,14 @@ describe('BlockDemoGuard', () => { const { guard, exec } = ctx({ id: 'u', isDemo: true }, false); expect(guard.canActivate(exec)).toBe(true); }); + + it('throws UnauthorizedException on blocked routes when req.user is missing', () => { + const { guard, exec } = ctx(undefined, true); + expect(() => guard.canActivate(exec)).toThrow(UnauthorizedException); + }); + + it('allows missing user on non-blocked routes', () => { + const { guard, exec } = ctx(undefined, false); + expect(guard.canActivate(exec)).toBe(true); + }); }); diff --git a/apps/api/src/demo/guards/block-demo.guard.ts b/apps/api/src/demo/guards/block-demo.guard.ts index 81a96aa..8d4b88f 100644 --- a/apps/api/src/demo/guards/block-demo.guard.ts +++ b/apps/api/src/demo/guards/block-demo.guard.ts @@ -3,10 +3,21 @@ import { ExecutionContext, ForbiddenException, Injectable, + UnauthorizedException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { BLOCK_DEMO_KEY } from '../decorators/block-demo.decorator'; +/** + * Guard that blocks demo users from accessing routes decorated with `@BlockDemo()`. + * + * Invariant: this guard must run on any route where `@BlockDemo()` is applied. + * It relies on `JwtAuthGuard` populating `req.user.isDemo`, but is now + * self-contained: if `req.user` is missing on a blocked route, it fails + * closed by throwing `UnauthorizedException`. This means APP_GUARD ordering + * between this guard and `JwtAuthGuard` is no longer fragile — a blocked + * route without an authenticated user will always be rejected. + */ @Injectable() export class BlockDemoGuard implements CanActivate { constructor(private reflector: Reflector) {} @@ -17,8 +28,13 @@ export class BlockDemoGuard implements CanActivate { context.getClass(), ]); if (!blocked) return true; - const req = context.switchToHttp().getRequest<{ user?: { isDemo?: boolean } }>(); - if (req.user?.isDemo) { + const req = context + .switchToHttp() + .getRequest<{ user?: { isDemo?: boolean } }>(); + if (!req.user) { + throw new UnauthorizedException('Authentication required'); + } + if (req.user.isDemo) { throw new ForbiddenException('Not available in demo mode'); } return true; From 66e3c0404a885a9bbefffe2a6fc7de75e8302fa5 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 07:19:25 +0800 Subject: [PATCH 14/27] feat(api): add passwordless demo account endpoint Adds POST /api/auth/demo that creates a demo user (isDemo=true) with a random unusable password, runs all registered DemoSeeders, and issues JWT + refresh token via auth cookies. Gated by DEMO_MODE_ENABLED (returns 404 when disabled) and rate-limited to 2 requests/hour. Also opens AuthService.generateAccessToken/generateRefreshToken for reuse and adds issueTokensForUser() for passwordless auth flows. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/auth/auth.service.ts | 21 ++++- apps/api/src/demo/demo.controller.ts | 50 ++++++++++++ apps/api/src/demo/demo.module.ts | 5 +- apps/api/src/demo/demo.service.spec.ts | 104 +++++++++++++++++++++++++ apps/api/src/demo/demo.service.ts | 59 ++++++++++++++ 5 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/demo/demo.controller.ts create mode 100644 apps/api/src/demo/demo.service.spec.ts create mode 100644 apps/api/src/demo/demo.service.ts diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index fd9a3f0..4cf2b2f 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -264,10 +264,27 @@ export class AuthService { return user; } + /** + * Issue access + refresh tokens for an already-known user (no password check). + * + * Used by flows that authenticate via a mechanism other than credentials, + * e.g. the passwordless demo account endpoint. + */ + async issueTokensForUser(user: { + id: string; + email: string; + name: string | null; + }) { + const accessToken = await this.generateAccessToken(user.id, user.email); + const refreshToken = await this.generateRefreshToken(user.id); + this.logger.log(`Tokens issued for user: ${user.email}`); + return { accessToken, refreshToken, user }; + } + /** * Generate JWT access token */ - private async generateAccessToken( + async generateAccessToken( userId: string, email: string, ): Promise { @@ -280,7 +297,7 @@ export class AuthService { /** * Generate refresh token and store in database */ - private async generateRefreshToken(userId: string): Promise { + async generateRefreshToken(userId: string): Promise { const token = randomUUID(); const hashedToken = this.hashRefreshToken(token); diff --git a/apps/api/src/demo/demo.controller.ts b/apps/api/src/demo/demo.controller.ts new file mode 100644 index 0000000..3b133a5 --- /dev/null +++ b/apps/api/src/demo/demo.controller.ts @@ -0,0 +1,50 @@ +import { + Controller, + HttpCode, + HttpStatus, + Post, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; +import { Response } from 'express'; +import { AuthService } from '../auth/auth.service'; +import { Public } from '../auth/decorators/public.decorator'; +import { DemoConfig } from './demo.config'; +import { DemoService } from './demo.service'; + +@ApiTags('auth') +@Controller('api/auth') +export class DemoController { + constructor( + private demo: DemoService, + private auth: AuthService, + private config: DemoConfig, + ) {} + + @Public() + @Post('demo') + @HttpCode(HttpStatus.CREATED) + @UseGuards(ThrottlerGuard) + @Throttle({ default: { limit: 2, ttl: 60 * 60 * 1000 } }) + @ApiOperation({ summary: 'Create a passwordless demo account' }) + async createDemo(@Res({ passthrough: true }) res: Response) { + const user = await this.demo.createDemoUser(); + const tokens = await this.auth.issueTokensForUser({ + id: user.id, + email: user.email, + name: user.name, + }); + this.auth.setAuthCookies(res, tokens.accessToken, tokens.refreshToken); + const ttlMinutes = this.config.ttlMinutes; + const expiresAt = new Date( + user.createdAt.getTime() + ttlMinutes * 60_000, + ); + return { + user: { ...user, expiresAt }, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } +} diff --git a/apps/api/src/demo/demo.module.ts b/apps/api/src/demo/demo.module.ts index 3868e78..14c0bfa 100644 --- a/apps/api/src/demo/demo.module.ts +++ b/apps/api/src/demo/demo.module.ts @@ -4,14 +4,17 @@ import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; import { DemoConfig } from './demo.config'; +import { DemoController } from './demo.controller'; import { DemoSeeder } from './demo-seeder.interface'; import { DemoSeederRegistry } from './demo-seeder.registry'; +import { DemoService } from './demo.service'; import { BlockDemoGuard } from './guards/block-demo.guard'; @Module({ imports: [ScheduleModule.forRoot(), PrismaModule, AuthModule], providers: [ DemoConfig, + DemoService, { provide: DemoSeederRegistry, // Concrete seeders (DevInbox, etc.) will be appended to `inject` and the @@ -21,7 +24,7 @@ import { BlockDemoGuard } from './guards/block-demo.guard'; }, { provide: APP_GUARD, useClass: BlockDemoGuard }, ], - controllers: [], + controllers: [DemoController], exports: [DemoConfig, DemoSeederRegistry], }) export class DemoModule {} diff --git a/apps/api/src/demo/demo.service.spec.ts b/apps/api/src/demo/demo.service.spec.ts new file mode 100644 index 0000000..a06145b --- /dev/null +++ b/apps/api/src/demo/demo.service.spec.ts @@ -0,0 +1,104 @@ +import { Test } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { DemoService } from './demo.service'; +import { DemoConfig } from './demo.config'; +import { DemoSeederRegistry } from './demo-seeder.registry'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthService } from '../auth/auth.service'; + +describe('DemoService', () => { + const makeModule = async (enabled: boolean) => { + const createdUser = { + id: 'u1', + email: 'demo-abcd1234@demo.local', + name: 'Demo User', + isDemo: true, + createdAt: new Date('2026-04-15T10:00:00.000Z'), + }; + const prisma = { + user: { create: jest.fn().mockResolvedValue(createdUser) }, + }; + const auth = { + hashPassword: jest.fn().mockResolvedValue('hashed'), + login: jest.fn(), + }; + const registry = { seedAll: jest.fn().mockResolvedValue(undefined) }; + const config = { enabled, ttlMinutes: 60 }; + const module = await Test.createTestingModule({ + providers: [ + DemoService, + { provide: PrismaService, useValue: prisma }, + { provide: AuthService, useValue: auth }, + { provide: DemoSeederRegistry, useValue: registry }, + { provide: DemoConfig, useValue: config }, + ], + }).compile(); + return { + svc: module.get(DemoService), + prisma, + auth, + registry, + createdUser, + }; + }; + + it('throws NotFound when demo mode disabled', async () => { + const { svc, prisma, auth, registry } = await makeModule(false); + await expect(svc.createDemoUser()).rejects.toThrow(NotFoundException); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(auth.hashPassword).not.toHaveBeenCalled(); + expect(registry.seedAll).not.toHaveBeenCalled(); + }); + + it('creates a demo user with unusable password, isDemo=true, then seeds', async () => { + const { svc, prisma, auth, registry } = await makeModule(true); + const result = await svc.createDemoUser(); + + expect(auth.hashPassword).toHaveBeenCalledTimes(1); + // 32 random bytes => 64 hex characters + const passwordArg = auth.hashPassword.mock.calls[0][0]; + expect(typeof passwordArg).toBe('string'); + expect(passwordArg).toMatch(/^[0-9a-f]{64}$/); + + expect(prisma.user.create).toHaveBeenCalledTimes(1); + const createArg = prisma.user.create.mock.calls[0][0]; + expect(createArg.data.isDemo).toBe(true); + expect(createArg.data.name).toBe('Demo User'); + expect(createArg.data.password).toBe('hashed'); + // Email format: demo-<8 chars>@demo.local + expect(createArg.data.email).toMatch(/^demo-[0-9a-f]{8}@demo\.local$/); + expect(createArg.select).toEqual( + expect.objectContaining({ + id: true, + email: true, + name: true, + isDemo: true, + createdAt: true, + }), + ); + + expect(registry.seedAll).toHaveBeenCalledWith('u1'); + expect(result.id).toBe('u1'); + expect(result.isDemo).toBe(true); + }); + + it('invokes seedAll AFTER the user is created', async () => { + const { svc, prisma, registry } = await makeModule(true); + const order: string[] = []; + prisma.user.create.mockImplementationOnce(async () => { + order.push('create'); + return { + id: 'u1', + email: 'demo-abcd1234@demo.local', + name: 'Demo User', + isDemo: true, + createdAt: new Date(), + }; + }); + registry.seedAll.mockImplementationOnce(async () => { + order.push('seed'); + }); + await svc.createDemoUser(); + expect(order).toEqual(['create', 'seed']); + }); +}); diff --git a/apps/api/src/demo/demo.service.ts b/apps/api/src/demo/demo.service.ts new file mode 100644 index 0000000..ee58aaf --- /dev/null +++ b/apps/api/src/demo/demo.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthService } from '../auth/auth.service'; +import { DemoConfig } from './demo.config'; +import { DemoSeederRegistry } from './demo-seeder.registry'; + +@Injectable() +export class DemoService { + private readonly logger = new Logger(DemoService.name); + + constructor( + private prisma: PrismaService, + private auth: AuthService, + private config: DemoConfig, + private seeders: DemoSeederRegistry, + ) {} + + /** + * Create a passwordless demo user. + * + * The account receives a cryptographically random, never-returned password + * so it cannot be logged into via the credentials flow. After creation, all + * registered DemoSeeders run to populate tool-specific sample data. + * + * Throws NotFoundException (404) when DEMO_MODE_ENABLED is not 'true' so the + * endpoint is indistinguishable from a non-existent route in production. + */ + async createDemoUser() { + if (!this.config.enabled) { + throw new NotFoundException(); + } + + // Random 32-byte password (64 hex chars). Never returned, never reused. + const unusablePassword = randomBytes(32).toString('hex'); + const hashed = await this.auth.hashPassword(unusablePassword); + + const user = await this.prisma.user.create({ + data: { + email: `demo-${randomUUID().slice(0, 8)}@demo.local`, + password: hashed, + name: 'Demo User', + isDemo: true, + }, + select: { + id: true, + email: true, + name: true, + isDemo: true, + createdAt: true, + }, + }); + + await this.seeders.seedAll(user.id); + + this.logger.log(`Demo user created: ${user.id}`); + return user; + } +} From 88394032850bd1b7b55d2472eb6397ab98ae5a0d Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 07:54:53 +0800 Subject: [PATCH 15/27] fix(api): address Task 7 review findings - Restore private visibility on generateAccessToken/generateRefreshToken in AuthService (same-class access from issueTokensForUser is legal). - Wire DEMO_RATE_LIMIT_PER_HOUR into the @Throttle decorator literal and drop the unused DemoConfig.rateLimitPerHour getter. - Add DemoController spec covering service wiring, token issuance, cookie setting, and expiresAt computation. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/auth/auth.service.ts | 4 +- apps/api/src/demo/demo.config.ts | 4 - apps/api/src/demo/demo.controller.spec.ts | 116 ++++++++++++++++++++++ apps/api/src/demo/demo.controller.ts | 7 +- 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/demo/demo.controller.spec.ts diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 4cf2b2f..10655f8 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -284,7 +284,7 @@ export class AuthService { /** * Generate JWT access token */ - async generateAccessToken( + private async generateAccessToken( userId: string, email: string, ): Promise { @@ -297,7 +297,7 @@ export class AuthService { /** * Generate refresh token and store in database */ - async generateRefreshToken(userId: string): Promise { + private async generateRefreshToken(userId: string): Promise { const token = randomUUID(); const hashedToken = this.hashRefreshToken(token); diff --git a/apps/api/src/demo/demo.config.ts b/apps/api/src/demo/demo.config.ts index f2168d5..9d0e488 100644 --- a/apps/api/src/demo/demo.config.ts +++ b/apps/api/src/demo/demo.config.ts @@ -12,8 +12,4 @@ export class DemoConfig { get ttlMinutes(): number { return parseInt(this.config.get('DEMO_TTL_MINUTES') || '60', 10); } - - get rateLimitPerHour(): number { - return parseInt(this.config.get('DEMO_RATE_LIMIT_PER_HOUR') || '2', 10); - } } diff --git a/apps/api/src/demo/demo.controller.spec.ts b/apps/api/src/demo/demo.controller.spec.ts new file mode 100644 index 0000000..f9a636c --- /dev/null +++ b/apps/api/src/demo/demo.controller.spec.ts @@ -0,0 +1,116 @@ +import { Test } from '@nestjs/testing'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import { Response } from 'express'; +import { AuthService } from '../auth/auth.service'; +import { DemoConfig } from './demo.config'; +import { DemoController } from './demo.controller'; +import { DemoService } from './demo.service'; + +describe('DemoController', () => { + const createdAt = new Date('2026-04-15T10:00:00.000Z'); + const user = { + id: 'u1', + email: 'demo-abcd1234@demo.local', + name: 'Demo User', + isDemo: true, + createdAt, + }; + const tokens = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { id: user.id, email: user.email, name: user.name }, + }; + + const makeModule = async (ttlMinutes = 60) => { + const demo = { + createDemoUser: jest.fn().mockResolvedValue(user), + }; + const auth = { + issueTokensForUser: jest.fn().mockResolvedValue(tokens), + setAuthCookies: jest.fn(), + }; + const config = { ttlMinutes }; + const module = await Test.createTestingModule({ + controllers: [DemoController], + providers: [ + { provide: DemoService, useValue: demo }, + { provide: AuthService, useValue: auth }, + { provide: DemoConfig, useValue: config }, + ], + }) + .overrideGuard(ThrottlerGuard) + .useValue({ canActivate: () => true }) + .compile(); + return { + ctrl: module.get(DemoController), + demo, + auth, + config, + }; + }; + + it('creates a demo user, issues tokens, sets cookies, and returns user/tokens', async () => { + const { ctrl, demo, auth } = await makeModule(60); + const res = {} as Response; + + const result = await ctrl.createDemo(res); + + expect(demo.createDemoUser).toHaveBeenCalledTimes(1); + expect(auth.issueTokensForUser).toHaveBeenCalledWith({ + id: user.id, + email: user.email, + name: user.name, + }); + expect(auth.setAuthCookies).toHaveBeenCalledWith( + res, + tokens.accessToken, + tokens.refreshToken, + ); + + expect(result.accessToken).toBe(tokens.accessToken); + expect(result.refreshToken).toBe(tokens.refreshToken); + expect(result.user).toEqual( + expect.objectContaining({ + id: user.id, + email: user.email, + name: user.name, + isDemo: true, + createdAt, + }), + ); + }); + + it('computes expiresAt as createdAt + ttlMinutes*60_000', async () => { + const ttlMinutes = 60; + const { ctrl } = await makeModule(ttlMinutes); + const res = {} as Response; + + const result = await ctrl.createDemo(res); + + const expected = new Date(createdAt.getTime() + ttlMinutes * 60_000); + expect(result.user.expiresAt).toEqual(expected); + expect(result.user.expiresAt.getTime()).toBe( + createdAt.getTime() + ttlMinutes * 60_000, + ); + }); + + it('calls DemoService.createDemoUser before AuthService.issueTokensForUser before setAuthCookies', async () => { + const { ctrl, demo, auth } = await makeModule(60); + const order: string[] = []; + demo.createDemoUser.mockImplementationOnce(async () => { + order.push('create'); + return user; + }); + auth.issueTokensForUser.mockImplementationOnce(async () => { + order.push('issue'); + return tokens; + }); + auth.setAuthCookies.mockImplementationOnce(() => { + order.push('cookies'); + }); + + await ctrl.createDemo({} as Response); + + expect(order).toEqual(['create', 'issue', 'cookies']); + }); +}); diff --git a/apps/api/src/demo/demo.controller.ts b/apps/api/src/demo/demo.controller.ts index 3b133a5..6e1b73d 100644 --- a/apps/api/src/demo/demo.controller.ts +++ b/apps/api/src/demo/demo.controller.ts @@ -27,7 +27,12 @@ export class DemoController { @Post('demo') @HttpCode(HttpStatus.CREATED) @UseGuards(ThrottlerGuard) - @Throttle({ default: { limit: 2, ttl: 60 * 60 * 1000 } }) + @Throttle({ + default: { + limit: parseInt(process.env.DEMO_RATE_LIMIT_PER_HOUR || '2', 10), + ttl: 60 * 60 * 1000, + }, + }) @ApiOperation({ summary: 'Create a passwordless demo account' }) async createDemo(@Res({ passthrough: true }) res: Response) { const user = await this.demo.createDemoUser(); From 76f69965168cb481f57974d0ffb00b3179166a86 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:00:39 +0800 Subject: [PATCH 16/27] feat(api): add DevInbox demo seeder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first concrete DemoSeeder implementation, which creates a "Demo Inbox" project and populates it with 5 clearly-fake sample emails (welcome, password-reset, receipt, newsletter, attachment reference) so new demo users have something to explore immediately. - sample-emails.ts: 5 fixtures using example.com/.org addresses and href="#" placeholders. No real brands, URLs, tracking pixels, or PII. - devinbox-demo.seeder.ts: suffixes the project slug with 6 chars of randomUUID() to sidestep the globally-unique slug constraint across multiple demo users. - Attachment row intentionally not seeded — the 5th email references a pretend PDF in its HTML only, which is enough to exercise the inbox UI without wiring storage paths. - demo.module.ts: extends the factory-based DemoSeederRegistry inject list to include the new seeder. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/demo/demo.module.ts | 9 +- .../demo/seeders/devinbox-demo.seeder.spec.ts | 36 ++++ .../src/demo/seeders/devinbox-demo.seeder.ts | 42 +++++ apps/api/src/demo/seeders/sample-emails.ts | 160 ++++++++++++++++++ 4 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts create mode 100644 apps/api/src/demo/seeders/devinbox-demo.seeder.ts create mode 100644 apps/api/src/demo/seeders/sample-emails.ts diff --git a/apps/api/src/demo/demo.module.ts b/apps/api/src/demo/demo.module.ts index 14c0bfa..db4bd98 100644 --- a/apps/api/src/demo/demo.module.ts +++ b/apps/api/src/demo/demo.module.ts @@ -9,18 +9,21 @@ import { DemoSeeder } from './demo-seeder.interface'; import { DemoSeederRegistry } from './demo-seeder.registry'; import { DemoService } from './demo.service'; import { BlockDemoGuard } from './guards/block-demo.guard'; +import { DevInboxDemoSeeder } from './seeders/devinbox-demo.seeder'; @Module({ imports: [ScheduleModule.forRoot(), PrismaModule, AuthModule], providers: [ DemoConfig, DemoService, + DevInboxDemoSeeder, { provide: DemoSeederRegistry, - // Concrete seeders (DevInbox, etc.) will be appended to `inject` and the - // factory signature as they are introduced in later tasks. + // Concrete seeders are appended to `inject` as they are introduced. + // The factory receives them in the same order and forwards the list + // to the registry. useFactory: (...seeders: DemoSeeder[]) => new DemoSeederRegistry(seeders), - inject: [], + inject: [DevInboxDemoSeeder], }, { provide: APP_GUARD, useClass: BlockDemoGuard }, ], diff --git a/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts b/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts new file mode 100644 index 0000000..ef5fafa --- /dev/null +++ b/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts @@ -0,0 +1,36 @@ +import { Test } from '@nestjs/testing'; +import { DevInboxDemoSeeder } from './devinbox-demo.seeder'; +import { PrismaService } from '../../prisma/prisma.service'; + +describe('DevInboxDemoSeeder', () => { + it('creates the demo-inbox project and 5 emails', async () => { + const prisma = { + project: { + create: jest.fn().mockResolvedValue({ id: 'p1', slug: 'demo-inbox' }), + }, + email: { createMany: jest.fn().mockResolvedValue({ count: 5 }) }, + }; + const module = await Test.createTestingModule({ + providers: [ + DevInboxDemoSeeder, + { provide: PrismaService, useValue: prisma }, + ], + }).compile(); + await module.get(DevInboxDemoSeeder).seed('user-1'); + expect(prisma.project.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user-1', + slug: expect.stringContaining('demo-inbox'), + }), + }), + ); + expect(prisma.email.createMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.arrayContaining([expect.any(Object)]), + }), + ); + const createManyCall = prisma.email.createMany.mock.calls[0][0]; + expect(createManyCall.data).toHaveLength(5); + }); +}); diff --git a/apps/api/src/demo/seeders/devinbox-demo.seeder.ts b/apps/api/src/demo/seeders/devinbox-demo.seeder.ts new file mode 100644 index 0000000..2f18e85 --- /dev/null +++ b/apps/api/src/demo/seeders/devinbox-demo.seeder.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../prisma/prisma.service'; +import { DemoSeeder } from '../demo-seeder.interface'; +import { SAMPLE_EMAILS } from './sample-emails'; + +/** + * Seeds a "Demo Inbox" project with a handful of clearly-fake sample emails + * so new demo users have something to explore in the DevInbox UI. + * + * Note: the Project model's `slug` column is globally `@unique`, so the slug + * is suffixed with 6 chars of a UUID to keep per-demo-user seeds collision-free. + */ +@Injectable() +export class DevInboxDemoSeeder implements DemoSeeder { + constructor(private prisma: PrismaService) {} + + async seed(userId: string): Promise { + const suffix = randomUUID().slice(0, 6); + const project = await this.prisma.project.create({ + data: { + userId, + slug: `demo-inbox-${suffix}`, + name: 'Demo Inbox', + description: + 'Your personal demo project. Explore — this is temporary.', + }, + }); + + await this.prisma.email.createMany({ + data: SAMPLE_EMAILS.map((sample) => ({ + projectId: project.id, + from: sample.from, + to: sample.to, + subject: sample.subject, + bodyText: sample.bodyText, + bodyHtml: sample.bodyHtml, + headers: sample.headers, + })), + }); + } +} diff --git a/apps/api/src/demo/seeders/sample-emails.ts b/apps/api/src/demo/seeders/sample-emails.ts new file mode 100644 index 0000000..e392397 --- /dev/null +++ b/apps/api/src/demo/seeders/sample-emails.ts @@ -0,0 +1,160 @@ +/** + * Sample email fixtures for the DevInbox demo seeder. + * + * All content here is deliberately fake: + * - `from` addresses use the reserved `example.com` / `example.org` domains (RFC 2606). + * - All `href` attributes point to `#` (no real URLs, no tracking pixels). + * - No PII, no real order IDs, no real brands. + * + * Shape matches the fields used by `DevInboxDemoSeeder` when calling + * `prisma.email.createMany`: from, to, subject, bodyText, bodyHtml, headers. + */ +export interface SampleEmail { + from: string; + to: string[]; + subject: string; + bodyText: string; + bodyHtml: string; + headers: Record; +} + +export const SAMPLE_EMAILS: SampleEmail[] = [ + // 1. Welcome / onboarding + { + from: 'welcome@example.com', + to: ['demo-user@example.org'], + subject: 'Welcome to the Demo Inbox', + bodyText: + 'Hi there!\n\nWelcome to your demo inbox. This is a sample email to help you explore the interface. Feel free to click around — nothing here is real.\n\nEnjoy the tour!\n— The Demo Team', + bodyHtml: + '' + + '

Welcome to the Demo Inbox

' + + '

Hi there!

' + + '

Welcome to your demo inbox. This is a sample email to help you explore the interface. Feel free to click around — nothing here is real.

' + + '

Take the tour

' + + '

Enjoy!
— The Demo Team

' + + '', + headers: { + 'Message-ID': '', + 'Content-Type': 'multipart/alternative', + 'X-Demo-Fixture': 'welcome', + }, + }, + + // 2. Password reset + { + from: 'noreply@example.com', + to: ['demo-user@example.org'], + subject: 'Reset your password', + bodyText: + 'We received a request to reset your password. If this was not you, you can safely ignore this email.\n\nReset link: (demo — not a real link)\n\nThis link would normally expire in 30 minutes.', + bodyHtml: + '' + + '

Reset your password

' + + '

We received a request to reset your password. If this was not you, you can safely ignore this email.

' + + '

' + + 'Reset password' + + '

' + + '

This link would normally expire in 30 minutes. Demo email — link is inert.

' + + '', + headers: { + 'Message-ID': '', + 'Content-Type': 'multipart/alternative', + 'X-Demo-Fixture': 'password-reset', + }, + }, + + // 3. Order receipt + { + from: 'receipts@example.com', + to: ['demo-user@example.org'], + subject: 'Your demo receipt #DEMO-0001', + bodyText: + 'Thanks for your (pretend) order!\n\nOrder: DEMO-0001\n\n1x Sample widget — $10.00\n2x Example gadget — $20.00\nSubtotal: $30.00\nTax: $2.40\nTotal: $32.40\n\nThis is a demo receipt. No charge was made.', + bodyHtml: + '' + + '

Your demo receipt

' + + '

Order DEMO-0001

' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
ItemPrice
1x Sample widget$10.00
2x Example gadget$20.00
Tax$2.40
Total$32.40
' + + '

View order

' + + '

This is a demo receipt. No charge was made.

' + + '', + headers: { + 'Message-ID': '', + 'Content-Type': 'multipart/alternative', + 'X-Demo-Fixture': 'receipt', + }, + }, + + // 4. Newsletter + { + from: 'updates@example.com', + to: ['demo-user@example.org'], + subject: 'This week in the Demo Newsletter', + bodyText: + 'This week in the Demo Newsletter:\n\n- A fake feature announcement\n- A pretend tutorial\n- Sample community highlights\n\nRead more at (demo link).', + bodyHtml: + '' + + '

Demo Newsletter

' + + '

Your weekly roundup of pretend news.

' + + '
' + + '

A fake feature announcement

' + + '

We shipped a brand new imaginary feature. It does nothing, beautifully. Read more

' + + '

A pretend tutorial

' + + '

Step-by-step guide to an entirely fictional workflow. Start tutorial

' + + '

Sample community highlights

' + + '
    ' + + '
  • Imaginary user shared a clever tip
  • ' + + '
  • Fake discussion on the forum
  • ' + + '
  • Pretend release notes
  • ' + + '
' + + '
' + + '

You are receiving this demo newsletter as part of the sample inbox. Unsubscribe (inert).

' + + '', + headers: { + 'Message-ID': '', + 'Content-Type': 'multipart/alternative', + 'List-Unsubscribe': '', + 'X-Demo-Fixture': 'newsletter', + }, + }, + + // 5. Email mentioning an attachment (HTML-only reference — no Attachment row). + // We intentionally do not seed the Attachment table: attachments require + // storage-path wiring that is out of scope for the demo UI showcase. + // The message body references a pretend attachment so the UI can still + // render an attachment-style email template. + { + from: 'reports@example.com', + to: ['demo-user@example.org'], + subject: 'Your monthly report (attachment)', + bodyText: + 'Hi,\n\nYour monthly demo report would normally be attached to this email as "demo-report.pdf".\n\nNote: this is a demo inbox — no real file is attached. The attachment reference is shown only to illustrate the UI.\n\n— Demo Reports', + bodyHtml: + '' + + '

Your monthly report

' + + '

Hi,

' + + '

Your monthly demo report would normally be attached to this email.

' + + '
' + + 'demo-report.pdf
' + + 'PDF · 128 KB · Download (inert)' + + '
' + + '

This is a demo email. No real file is attached — the reference above is illustrative only.

' + + '

— Demo Reports

' + + '', + headers: { + 'Message-ID': '', + 'Content-Type': 'multipart/mixed', + 'X-Demo-Fixture': 'attachment-reference', + }, + }, +]; From ebb74cae0b0399a0b9f3683c08e35dddc3294add Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:02:53 +0800 Subject: [PATCH 17/27] test(api): tighten DevInboxDemoSeeder contract assertions Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts b/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts index ef5fafa..e579903 100644 --- a/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts +++ b/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts @@ -21,7 +21,8 @@ describe('DevInboxDemoSeeder', () => { expect.objectContaining({ data: expect.objectContaining({ userId: 'user-1', - slug: expect.stringContaining('demo-inbox'), + name: 'Demo Inbox', + slug: expect.stringMatching(/^demo-inbox-[a-f0-9]{6}$/), }), }), ); @@ -32,5 +33,9 @@ describe('DevInboxDemoSeeder', () => { ); const createManyCall = prisma.email.createMany.mock.calls[0][0]; expect(createManyCall.data).toHaveLength(5); + for (const email of createManyCall.data) { + expect(email.projectId).toBe('p1'); + expect(email.headers).toBeTruthy(); + } }); }); From 9a170cae62bcfa16922ff3c5796c2feb12d1ea06 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:06:53 +0800 Subject: [PATCH 18/27] feat(api): enforce 2-project cap for demo accounts --- apps/api/src/projects/projects.controller.ts | 4 +- .../api/src/projects/projects.service.spec.ts | 88 +++++++++++++++++++ apps/api/src/projects/projects.service.ts | 12 ++- 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/projects/projects.service.spec.ts diff --git a/apps/api/src/projects/projects.controller.ts b/apps/api/src/projects/projects.controller.ts index 979aab0..3bde16b 100644 --- a/apps/api/src/projects/projects.controller.ts +++ b/apps/api/src/projects/projects.controller.ts @@ -49,8 +49,8 @@ export class ProjectsController { description: 'Project with this slug already exists', }) create(@Req() req: Request, @Body() createProjectDto: CreateProjectDto) { - const userId = (req.user as { id: string }).id; - return this.projectsService.create(userId, createProjectDto); + const user = req.user as { id: string; isDemo?: boolean }; + return this.projectsService.create(user.id, createProjectDto, user.isDemo); } @Get() diff --git a/apps/api/src/projects/projects.service.spec.ts b/apps/api/src/projects/projects.service.spec.ts new file mode 100644 index 0000000..e1b18d9 --- /dev/null +++ b/apps/api/src/projects/projects.service.spec.ts @@ -0,0 +1,88 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ForbiddenException } from '@nestjs/common'; +import { ProjectsService } from './projects.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('ProjectsService', () => { + let service: ProjectsService; + let prisma: { + project: { + create: jest.Mock; + count: jest.Mock; + }; + }; + + const dto = { + name: 'Test Project', + slug: 'Test-Slug', + description: 'desc', + }; + + beforeEach(async () => { + prisma = { + project: { + create: jest.fn(), + count: jest.fn(), + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProjectsService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile(); + + service = module.get(ProjectsService); + }); + + describe('create - demo account cap', () => { + it('allows a demo user to create when they have fewer than 2 projects', async () => { + prisma.project.count.mockResolvedValue(1); + prisma.project.create.mockResolvedValue({ id: 'p1' }); + + const result = await service.create('user-1', dto, true); + + expect(prisma.project.count).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + }); + expect(prisma.project.create).toHaveBeenCalled(); + expect(result).toEqual({ id: 'p1' }); + }); + + it('throws ForbiddenException when demo user already has 2 projects', async () => { + prisma.project.count.mockResolvedValue(2); + + await expect(service.create('user-1', dto, true)).rejects.toThrow( + ForbiddenException, + ); + await expect(service.create('user-1', dto, true)).rejects.toThrow( + 'Demo accounts are limited to 2 projects. Sign up to create more.', + ); + + expect(prisma.project.count).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + }); + expect(prisma.project.create).not.toHaveBeenCalled(); + }); + + it('does not apply cap for non-demo users regardless of count', async () => { + prisma.project.create.mockResolvedValue({ id: 'p99' }); + + const result = await service.create('user-1', dto, false); + + expect(prisma.project.count).not.toHaveBeenCalled(); + expect(prisma.project.create).toHaveBeenCalled(); + expect(result).toEqual({ id: 'p99' }); + }); + + it('defaults isDemo to false (no cap) when omitted', async () => { + prisma.project.create.mockResolvedValue({ id: 'p2' }); + + await service.create('user-1', dto); + + expect(prisma.project.count).not.toHaveBeenCalled(); + expect(prisma.project.create).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/projects/projects.service.ts b/apps/api/src/projects/projects.service.ts index 9cf2120..7fa9383 100644 --- a/apps/api/src/projects/projects.service.ts +++ b/apps/api/src/projects/projects.service.ts @@ -1,6 +1,7 @@ import { Injectable, ConflictException, + ForbiddenException, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; @@ -12,7 +13,16 @@ import { Prisma } from '@prisma/client'; export class ProjectsService { constructor(private prisma: PrismaService) {} - async create(userId: string, createProjectDto: CreateProjectDto) { + async create(userId: string, createProjectDto: CreateProjectDto, isDemo = false) { + if (isDemo) { + const count = await this.prisma.project.count({ where: { userId } }); + if (count >= 2) { + throw new ForbiddenException( + 'Demo accounts are limited to 2 projects. Sign up to create more.', + ); + } + } + try { const project = await this.prisma.project.create({ data: { From 3d544d0745ac92a66604aeb035c89d132cf7325d Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:13:15 +0800 Subject: [PATCH 19/27] feat(api): add demo inject-test-email endpoint Adds POST /api/projects/:id/demo/inject-email for demo users to drop a realistic fake email into one of their projects. Enforces: - caller must have isDemo=true (else 403) - project must be owned by the caller (else 404) - cap of 20 injected emails per project (else 403) Cap accounting uses a reserved `@inject.demo.local` sender domain rather than a dedicated boolean marker column. Trade-off: no schema change and self-documenting in DB inspection, at the cost of coupling cap semantics to a magic-string domain. Seeded sample emails (RFC 2606 `@example.com`/`@example.org`) never collide with this count. Ownership check is inlined as a single prisma.project.findFirst rather than depending on ProjectsService, to keep DemoModule from importing ProjectsModule. Pool of 10 injectable templates (shipping, calendar, security, invoice, mention, CI, magic link, product, survey, support) lives in seeders/injectable-emails.ts; injectTestEmail picks one at random. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/demo/demo-emails.controller.ts | 34 +++ apps/api/src/demo/demo.module.ts | 3 +- apps/api/src/demo/demo.service.spec.ts | 69 ++++- apps/api/src/demo/demo.service.ts | 81 +++++- .../api/src/demo/seeders/injectable-emails.ts | 243 ++++++++++++++++++ 5 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/demo/demo-emails.controller.ts create mode 100644 apps/api/src/demo/seeders/injectable-emails.ts diff --git a/apps/api/src/demo/demo-emails.controller.ts b/apps/api/src/demo/demo-emails.controller.ts new file mode 100644 index 0000000..0ca0651 --- /dev/null +++ b/apps/api/src/demo/demo-emails.controller.ts @@ -0,0 +1,34 @@ +import { + Controller, + HttpCode, + HttpStatus, + Param, + Post, + Req, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { DemoService } from './demo.service'; + +/** + * Demo-only endpoint for injecting a fake email into one of the caller's + * projects. Demo users cannot receive real SMTP email (sandboxed), so they + * click "Inject test email" in the UI which hits this endpoint. + * + * The global JwtAuthGuard populates `req.user` with `{ id, isDemo, ... }`; + * `DemoService.injectTestEmail` enforces the `isDemo` check so that non-demo + * callers receive 403 even though the route is authenticated. + */ +@ApiTags('demo') +@Controller('api/projects') +export class DemoEmailsController { + constructor(private demo: DemoService) {} + + @Post(':id/demo/inject-email') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Inject a fake test email into a demo project' }) + async inject(@Req() req: Request, @Param('id') id: string) { + const user = req.user as { id: string; isDemo: boolean }; + return this.demo.injectTestEmail(user.id, user.isDemo, id); + } +} diff --git a/apps/api/src/demo/demo.module.ts b/apps/api/src/demo/demo.module.ts index db4bd98..5b0ef89 100644 --- a/apps/api/src/demo/demo.module.ts +++ b/apps/api/src/demo/demo.module.ts @@ -5,6 +5,7 @@ import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; import { DemoConfig } from './demo.config'; import { DemoController } from './demo.controller'; +import { DemoEmailsController } from './demo-emails.controller'; import { DemoSeeder } from './demo-seeder.interface'; import { DemoSeederRegistry } from './demo-seeder.registry'; import { DemoService } from './demo.service'; @@ -27,7 +28,7 @@ import { DevInboxDemoSeeder } from './seeders/devinbox-demo.seeder'; }, { provide: APP_GUARD, useClass: BlockDemoGuard }, ], - controllers: [DemoController], + controllers: [DemoController, DemoEmailsController], exports: [DemoConfig, DemoSeederRegistry], }) export class DemoModule {} diff --git a/apps/api/src/demo/demo.service.spec.ts b/apps/api/src/demo/demo.service.spec.ts index a06145b..64f694c 100644 --- a/apps/api/src/demo/demo.service.spec.ts +++ b/apps/api/src/demo/demo.service.spec.ts @@ -1,5 +1,8 @@ import { Test } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { DemoService } from './demo.service'; import { DemoConfig } from './demo.config'; import { DemoSeederRegistry } from './demo-seeder.registry'; @@ -17,6 +20,8 @@ describe('DemoService', () => { }; const prisma = { user: { create: jest.fn().mockResolvedValue(createdUser) }, + project: { findFirst: jest.fn() }, + email: { count: jest.fn(), create: jest.fn() }, }; const auth = { hashPassword: jest.fn().mockResolvedValue('hashed'), @@ -101,4 +106,66 @@ describe('DemoService', () => { await svc.createDemoUser(); expect(order).toEqual(['create', 'seed']); }); + + describe('injectTestEmail', () => { + it('throws Forbidden when caller is not a demo user', async () => { + const { svc, prisma } = await makeModule(true); + await expect( + svc.injectTestEmail('u1', false, 'p1'), + ).rejects.toThrow(ForbiddenException); + expect(prisma.project.findFirst).not.toHaveBeenCalled(); + expect(prisma.email.count).not.toHaveBeenCalled(); + expect(prisma.email.create).not.toHaveBeenCalled(); + }); + + it('throws NotFound when project is not owned by the user', async () => { + const { svc, prisma } = await makeModule(true); + prisma.project.findFirst.mockResolvedValueOnce(null); + await expect( + svc.injectTestEmail('u1', true, 'p1'), + ).rejects.toThrow(NotFoundException); + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { id: 'p1', userId: 'u1' }, + select: { id: true }, + }); + expect(prisma.email.create).not.toHaveBeenCalled(); + }); + + it('throws Forbidden when the 20-email injection cap is reached', async () => { + const { svc, prisma } = await makeModule(true); + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.email.count.mockResolvedValueOnce(20); + await expect( + svc.injectTestEmail('u1', true, 'p1'), + ).rejects.toThrow(ForbiddenException); + expect(prisma.email.count).toHaveBeenCalledWith({ + where: { + projectId: 'p1', + from: { endsWith: '@inject.demo.local' }, + }, + }); + expect(prisma.email.create).not.toHaveBeenCalled(); + }); + + it('creates one email from the injectable pool on the happy path', async () => { + const { svc, prisma } = await makeModule(true); + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.email.count.mockResolvedValueOnce(5); + const createdRow = { id: 'e1' }; + prisma.email.create.mockResolvedValueOnce(createdRow); + + const result = await svc.injectTestEmail('u1', true, 'p1'); + + expect(prisma.email.create).toHaveBeenCalledTimes(1); + const arg = prisma.email.create.mock.calls[0][0]; + expect(arg.data.projectId).toBe('p1'); + expect(arg.data.from).toMatch(/@inject\.demo\.local$/); + expect(Array.isArray(arg.data.to)).toBe(true); + expect(typeof arg.data.subject).toBe('string'); + expect(typeof arg.data.bodyText).toBe('string'); + expect(typeof arg.data.bodyHtml).toBe('string'); + expect(arg.data.headers).toEqual(expect.any(Object)); + expect(result).toBe(createdRow); + }); + }); }); diff --git a/apps/api/src/demo/demo.service.ts b/apps/api/src/demo/demo.service.ts index ee58aaf..963a1b3 100644 --- a/apps/api/src/demo/demo.service.ts +++ b/apps/api/src/demo/demo.service.ts @@ -1,9 +1,25 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { randomBytes, randomUUID } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; import { AuthService } from '../auth/auth.service'; import { DemoConfig } from './demo.config'; import { DemoSeederRegistry } from './demo-seeder.registry'; +import { + INJECT_DOMAIN, + INJECTABLE_EMAILS, +} from './seeders/injectable-emails'; + +/** + * Max number of *injected* test emails allowed per project for demo users. + * Seeded sample emails (from `@example.com`) do NOT count against this cap — + * only rows whose `from` ends in `@inject.demo.local` do. + */ +const INJECT_EMAIL_CAP = 20; @Injectable() export class DemoService { @@ -56,4 +72,67 @@ export class DemoService { this.logger.log(`Demo user created: ${user.id}`); return user; } + + /** + * Inject a realistic fake email into one of the demo user's projects. + * + * Demo accounts cannot receive real SMTP mail (L2 sandbox), so this endpoint + * drops a randomly-picked template from `INJECTABLE_EMAILS` into the given + * project. Caps at 20 injected emails per project. + * + * Cap accounting: we count rows whose `from` ends in `@inject.demo.local` + * (the reserved marker domain used by every template in the pool). This + * avoids a schema change and keeps seeded sample emails (from `@example.com`) + * out of the count. Self-documenting when inspecting the DB. + * + * Ownership check is inlined (single `findFirst`) rather than depending on + * ProjectsService to avoid cross-module coupling in DemoModule. + */ + async injectTestEmail(userId: string, isDemo: boolean, projectId: string) { + if (!isDemo) { + throw new ForbiddenException( + 'Only demo accounts can inject test emails.', + ); + } + + const project = await this.prisma.project.findFirst({ + where: { id: projectId, userId }, + select: { id: true }, + }); + if (!project) { + throw new NotFoundException(`Project with ID "${projectId}" not found`); + } + + const injectedCount = await this.prisma.email.count({ + where: { + projectId, + from: { endsWith: INJECT_DOMAIN }, + }, + }); + if (injectedCount >= INJECT_EMAIL_CAP) { + throw new ForbiddenException( + `Demo projects are limited to ${INJECT_EMAIL_CAP} injected test emails. Sign up to remove the cap.`, + ); + } + + const template = + INJECTABLE_EMAILS[Math.floor(Math.random() * INJECTABLE_EMAILS.length)]; + + const email = await this.prisma.email.create({ + data: { + projectId, + from: template.from, + to: template.to, + subject: template.subject, + bodyText: template.bodyText, + bodyHtml: template.bodyHtml, + headers: template.headers, + }, + }); + + this.logger.log( + `Injected test email ${email.id} into project ${projectId} (user ${userId})`, + ); + return email; + } } diff --git a/apps/api/src/demo/seeders/injectable-emails.ts b/apps/api/src/demo/seeders/injectable-emails.ts new file mode 100644 index 0000000..8198de5 --- /dev/null +++ b/apps/api/src/demo/seeders/injectable-emails.ts @@ -0,0 +1,243 @@ +/** + * Injectable email templates for the demo "Inject test email" feature. + * + * Demo users cannot receive real SMTP mail (sandboxed), so instead they click + * an "Inject test email" button in the UI which drops one of these realistic + * fake emails into one of their projects. + * + * Cap accounting + * -------------- + * All `from` addresses in this pool use the reserved `@inject.demo.local` + * domain so injected rows can be counted without a separate marker column: + * + * await prisma.email.count({ + * where: { projectId, from: { endsWith: '@inject.demo.local' } } + * }); + * + * The 5 seeded emails from `sample-emails.ts` use `@example.com` / + * `@example.org` (RFC 2606 reserved) so they never collide with this cap. + * + * Everything here is fake: + * - `from` uses the `@inject.demo.local` reserved marker domain. + * - `href` attributes point to `#`. + * - No PII, no real brands, no tracking pixels. + */ + +export interface InjectableEmail { + from: string; + to: string[]; + subject: string; + bodyText: string; + bodyHtml: string; + headers: Record; +} + +export const INJECT_DOMAIN = '@inject.demo.local'; + +export const INJECTABLE_EMAILS: InjectableEmail[] = [ + // 1. Shipping notification + { + from: `shipping${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'Your order has shipped', + bodyText: + 'Good news — your demo order is on its way!\n\nTracking: DEMO-TRK-0001 (inert)\nEstimated delivery: in 3 pretend days.', + bodyHtml: + '' + + '

Your order has shipped

' + + '

Good news — your demo order is on its way.

' + + '

Tracking: DEMO-TRK-0001 (inert)

' + + '

Track package

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'shipping', + }, + }, + + // 2. Meeting invite + { + from: `calendar${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'Invitation: Demo sync @ Thu 2pm', + bodyText: + 'You are invited to a pretend meeting.\n\nWhen: Thursday, 2:00 PM (fake time)\nWhere: Imaginary conference room\n\nAgenda:\n- Review fictional roadmap\n- Discuss invented metrics', + bodyHtml: + '' + + '

Demo sync

' + + '

When: Thursday, 2:00 PM (fake)

' + + '

Where: Imaginary conference room

' + + '

Accept · Decline

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'calendar-invite', + }, + }, + + // 3. Security alert + { + from: `security${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'New sign-in to your demo account', + bodyText: + 'We detected a sign-in from a new (pretend) device.\n\nDevice: Demo browser on Imaginary OS\nLocation: Nowheresville\n\nIf this was not you, the demo would normally let you lock the account.', + bodyHtml: + '' + + '

New sign-in detected

' + + '

Device: Demo browser on Imaginary OS

' + + '

Location: Nowheresville

' + + '

Review activity (inert)

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'security-alert', + }, + }, + + // 4. Invoice + { + from: `billing${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'Invoice DEMO-INV-0042 is available', + bodyText: + 'Your demo invoice is ready.\n\nInvoice: DEMO-INV-0042\nAmount: $49.00 (pretend)\nDue: in 14 fake days.\n\nNo real charge — this is a demo.', + bodyHtml: + '' + + '

Invoice DEMO-INV-0042

' + + '

Amount: $49.00 (pretend)

' + + '

Due: in 14 fake days

' + + '

View invoice

' + + '

No real charge — demo only.

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'invoice', + }, + }, + + // 5. Comment / mention notification + { + from: `notifications${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'Someone mentioned you in a comment', + bodyText: + '@demo-user — "Can you take a look at this pretend PR when you get a sec?"\n\n— Imaginary teammate', + bodyHtml: + '' + + '

New mention

' + + '
' + + '@demo-user — "Can you take a look at this pretend PR when you get a sec?"' + + '
' + + '

— Imaginary teammate

' + + '

Reply

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'mention', + }, + }, + + // 6. CI build failure + { + from: `ci${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'Build failed: main @ demo-abcd123', + bodyText: + 'The pretend build for commit demo-abcd123 on main failed.\n\n1 test failing:\n- fake-suite > imaginary test > should not pretend\n\nInspect the fictional log for details.', + bodyHtml: + '' + + '

Build failed

' + + '

demo-abcd123 on main

' + + '
FAIL fake-suite > imaginary test\n  expected: truth\n  received: pretense
' + + '

View log (inert)

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'ci-failure', + }, + }, + + // 7. Magic login link + { + from: `auth${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'Your magic sign-in link', + bodyText: + 'Click the link to finish signing in. This pretend link expires in 10 imaginary minutes.\n\n(Demo — link is inert.)', + bodyHtml: + '' + + '

Sign in

' + + '

' + + 'Sign in' + + '

' + + '

Expires in 10 pretend minutes. Inert demo link.

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'magic-link', + }, + }, + + // 8. Feature announcement + { + from: `product${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'New: fictional feature now in beta', + bodyText: + 'We just shipped an entirely imaginary feature — and you can try it in (pretend) beta.\n\nHighlights:\n- Does nothing, gracefully\n- Imaginary performance gains\n- Zero real impact', + bodyHtml: + '' + + '

Fictional feature, now in beta

' + + '
    ' + + '
  • Does nothing, gracefully
  • ' + + '
  • Imaginary performance gains
  • ' + + '
  • Zero real impact
  • ' + + '
' + + '

Try the beta

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'product-announcement', + }, + }, + + // 9. Survey / feedback request + { + from: `feedback${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'Got 30 (pretend) seconds?', + bodyText: + 'We would love your imaginary feedback on the demo. No data is collected — the link is inert.\n\nThanks!', + bodyHtml: + '' + + '

Quick feedback

' + + '

Got 30 pretend seconds? Share your imaginary thoughts.

' + + '

Start survey (inert)

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'survey', + }, + }, + + // 10. Support ticket reply + { + from: `support${INJECT_DOMAIN}`, + to: ['demo-user@example.org'], + subject: 'Re: your (pretend) ticket #DEMO-7', + bodyText: + 'Thanks for reaching out to fake support.\n\nWe took a look at ticket #DEMO-7 and confirmed it was entirely imaginary. Marking as resolved.\n\n— Demo Support', + bodyHtml: + '' + + '

Ticket #DEMO-7 update

' + + '

Thanks for reaching out to fake support. We confirmed the ticket was imaginary and marked it resolved.

' + + '

View ticket

' + + '

— Demo Support

' + + '', + headers: { + 'Content-Type': 'multipart/alternative', + 'X-Demo-Injected': 'support-reply', + }, + }, +]; From 80d3ceaa1272fff51dd1abff58df97f77e7017de Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:18:47 +0800 Subject: [PATCH 20/27] feat(api): add demo cleanup cron --- .../api/src/demo/demo-cleanup.service.spec.ts | 63 +++++++++++++++++++ apps/api/src/demo/demo-cleanup.service.ts | 26 ++++++++ apps/api/src/demo/demo.module.ts | 2 + 3 files changed, 91 insertions(+) create mode 100644 apps/api/src/demo/demo-cleanup.service.spec.ts create mode 100644 apps/api/src/demo/demo-cleanup.service.ts diff --git a/apps/api/src/demo/demo-cleanup.service.spec.ts b/apps/api/src/demo/demo-cleanup.service.spec.ts new file mode 100644 index 0000000..cc11057 --- /dev/null +++ b/apps/api/src/demo/demo-cleanup.service.spec.ts @@ -0,0 +1,63 @@ +import { Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { DemoConfig } from './demo.config'; +import { DemoCleanupService } from './demo-cleanup.service'; + +describe('DemoCleanupService', () => { + let service: DemoCleanupService; + let prisma: { user: { deleteMany: jest.Mock } }; + let config: { enabled: boolean; ttlMinutes: number }; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + prisma = { user: { deleteMany: jest.fn() } }; + config = { enabled: false, ttlMinutes: 60 }; + service = new DemoCleanupService( + prisma as unknown as PrismaService, + config as unknown as DemoConfig, + ); + logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + logSpy.mockRestore(); + }); + + it('is a no-op when demo mode is disabled', async () => { + config.enabled = false; + + await service.handleCleanup(); + + expect(prisma.user.deleteMany).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('deletes expired demo users using the ttl cutoff when enabled', async () => { + config.enabled = true; + config.ttlMinutes = 60; + const fixedNow = new Date('2026-04-15T12:00:00.000Z'); + jest.useFakeTimers().setSystemTime(fixedNow); + prisma.user.deleteMany.mockResolvedValue({ count: 3 }); + + await service.handleCleanup(); + + const expectedCutoff = new Date(fixedNow.getTime() - 60 * 60_000); + expect(prisma.user.deleteMany).toHaveBeenCalledTimes(1); + expect(prisma.user.deleteMany).toHaveBeenCalledWith({ + where: { isDemo: true, createdAt: { lt: expectedCutoff } }, + }); + expect(logSpy).toHaveBeenCalledWith('Cleaned up 3 expired demo user(s)'); + }); + + it('does not log when no rows are deleted', async () => { + config.enabled = true; + config.ttlMinutes = 30; + prisma.user.deleteMany.mockResolvedValue({ count: 0 }); + + await service.handleCleanup(); + + expect(prisma.user.deleteMany).toHaveBeenCalledTimes(1); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/demo/demo-cleanup.service.ts b/apps/api/src/demo/demo-cleanup.service.ts new file mode 100644 index 0000000..219c8a8 --- /dev/null +++ b/apps/api/src/demo/demo-cleanup.service.ts @@ -0,0 +1,26 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { DemoConfig } from './demo.config'; + +@Injectable() +export class DemoCleanupService { + private readonly logger = new Logger(DemoCleanupService.name); + + constructor( + private prisma: PrismaService, + private config: DemoConfig, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async handleCleanup() { + if (!this.config.enabled) return; + const cutoff = new Date(Date.now() - this.config.ttlMinutes * 60_000); + const result = await this.prisma.user.deleteMany({ + where: { isDemo: true, createdAt: { lt: cutoff } }, + }); + if (result.count > 0) { + this.logger.log(`Cleaned up ${result.count} expired demo user(s)`); + } + } +} diff --git a/apps/api/src/demo/demo.module.ts b/apps/api/src/demo/demo.module.ts index 5b0ef89..37ab5a6 100644 --- a/apps/api/src/demo/demo.module.ts +++ b/apps/api/src/demo/demo.module.ts @@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; +import { DemoCleanupService } from './demo-cleanup.service'; import { DemoConfig } from './demo.config'; import { DemoController } from './demo.controller'; import { DemoEmailsController } from './demo-emails.controller'; @@ -17,6 +18,7 @@ import { DevInboxDemoSeeder } from './seeders/devinbox-demo.seeder'; providers: [ DemoConfig, DemoService, + DemoCleanupService, DevInboxDemoSeeder, { provide: DemoSeederRegistry, From fb0cfda5b15008ecf4bee904e47fda5ec95ce535 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:20:33 +0800 Subject: [PATCH 21/27] feat(api): surface demo expiresAt on /auth/me --- apps/api/src/auth/auth.service.spec.ts | 93 ++++++++++++++++++++++++++ apps/api/src/auth/auth.service.ts | 14 +++- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/auth/auth.service.spec.ts diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..beda2d0 --- /dev/null +++ b/apps/api/src/auth/auth.service.spec.ts @@ -0,0 +1,93 @@ +import { Test } from '@nestjs/testing'; +import { UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('AuthService.getCurrentUser', () => { + const createdAt = new Date('2026-04-15T10:00:00.000Z'); + const updatedAt = new Date('2026-04-15T10:05:00.000Z'); + + const makeService = async (userRow: any) => { + const prisma = { + user: { findUnique: jest.fn().mockResolvedValue(userRow) }, + }; + const jwt = {}; + const config = { get: jest.fn() }; + const module = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: PrismaService, useValue: prisma }, + { provide: JwtService, useValue: jwt }, + { provide: ConfigService, useValue: config }, + ], + }).compile(); + return { svc: module.get(AuthService), prisma }; + }; + + afterEach(() => { + delete process.env.DEMO_TTL_MINUTES; + }); + + it('throws UnauthorizedException when user does not exist', async () => { + const { svc } = await makeService(null); + await expect(svc.getCurrentUser('missing')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('returns user without expiresAt for non-demo users', async () => { + const { svc } = await makeService({ + id: 'u1', + email: 'alice@example.com', + name: 'Alice', + isDemo: false, + createdAt, + updatedAt, + }); + const result = await svc.getCurrentUser('u1'); + expect(result).toEqual({ + id: 'u1', + email: 'alice@example.com', + name: 'Alice', + isDemo: false, + createdAt, + updatedAt, + }); + expect((result as any).expiresAt).toBeUndefined(); + }); + + it('returns expiresAt = createdAt + ttlMinutes*60000 for demo users', async () => { + process.env.DEMO_TTL_MINUTES = '45'; + const { svc } = await makeService({ + id: 'u2', + email: 'demo-abcd1234@demo.local', + name: 'Demo User', + isDemo: true, + createdAt, + updatedAt, + }); + const result: any = await svc.getCurrentUser('u2'); + expect(result.isDemo).toBe(true); + expect(result.expiresAt).toBeInstanceOf(Date); + expect(result.expiresAt.getTime()).toBe( + createdAt.getTime() + 45 * 60_000, + ); + }); + + it('defaults ttlMinutes to 60 when DEMO_TTL_MINUTES unset', async () => { + const { svc } = await makeService({ + id: 'u3', + email: 'demo-zzzz1111@demo.local', + name: 'Demo User', + isDemo: true, + createdAt, + updatedAt, + }); + const result: any = await svc.getCurrentUser('u3'); + expect(result.expiresAt.getTime()).toBe( + createdAt.getTime() + 60 * 60_000, + ); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 10655f8..bffaf72 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -252,6 +252,7 @@ export class AuthService { id: true, email: true, name: true, + isDemo: true, createdAt: true, updatedAt: true, }, @@ -261,7 +262,18 @@ export class AuthService { throw new UnauthorizedException('User not found'); } - return user; + const ttlMinutes = parseInt(process.env.DEMO_TTL_MINUTES || '60', 10); + + return { + ...user, + ...(user.isDemo + ? { + expiresAt: new Date( + user.createdAt.getTime() + ttlMinutes * 60_000, + ), + } + : {}), + }; } /** From 8b75fb35f5340239c862c9236b7c474ac0ea1d41 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:22:49 +0800 Subject: [PATCH 22/27] feat(web): add Try Demo button on login page --- apps/web/.env.example | 3 ++ apps/web/app/login/page.tsx | 86 +++++++++++++++++++++++++++++++++++-- apps/web/lib/api.ts | 13 ++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/apps/web/.env.example b/apps/web/.env.example index 933cad4..5143910 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -4,5 +4,8 @@ NEXT_PUBLIC_API_URL="http://localhost:4000" # DevInbox Domain NEXT_PUBLIC_DEVINBOX_DOMAIN="devinbox.local" +# Demo Mode (set to "true" to show the "Try Demo" button on the login page) +NEXT_PUBLIC_DEMO_MODE_ENABLED=false + # Optional: Analytics, etc. # NEXT_PUBLIC_GA_ID="" diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 6475d77..c4329a5 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -1,18 +1,34 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import Cookies from 'js-cookie'; import { useAuth } from '../../contexts/AuthContext'; +import { tryDemo } from '../../lib/api'; + +const DEMO_MODE_ENABLED = process.env.NEXT_PUBLIC_DEMO_MODE_ENABLED === 'true'; +const DEMO_UNAVAILABLE_KEY = 'demoUnavailable'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [demoLoading, setDemoLoading] = useState(false); + const [demoVisible, setDemoVisible] = useState(DEMO_MODE_ENABLED); const router = useRouter(); const { login } = useAuth(); + // If a previous demo attempt returned 404, hide the button for this session + useEffect(() => { + if (!DEMO_MODE_ENABLED) return; + if (typeof window === 'undefined') return; + if (sessionStorage.getItem(DEMO_UNAVAILABLE_KEY) === '1') { + setDemoVisible(false); + } + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -28,6 +44,52 @@ export default function LoginPage() { } }; + const handleTryDemo = async () => { + setError(''); + setDemoLoading(true); + + try { + const res = await tryDemo(); + + if (res.status === 201 || res.ok) { + // Server set httpOnly auth cookies. Mirror the login flow by setting + // the frontend `session` cookie so middleware recognizes the user, + // then do a full navigation so AuthContext rehydrates with /api/auth/me. + Cookies.set('session', 'active', { path: '/', expires: 1 }); + window.location.href = '/dashboard'; + return; + } + + if (res.status === 429) { + setError('Demo limit reached, try again later.'); + return; + } + + if (res.status === 404) { + if (typeof window !== 'undefined') { + sessionStorage.setItem(DEMO_UNAVAILABLE_KEY, '1'); + } + setDemoVisible(false); + setError('Demo is not available right now.'); + return; + } + + // Other error + let message = 'Could not start demo. Please try again.'; + try { + const body = await res.json(); + if (body?.message) message = body.message; + } catch { + // ignore JSON parse errors + } + setError(message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not start demo.'); + } finally { + setDemoLoading(false); + } + }; + return (
@@ -51,7 +113,7 @@ export default function LoginPage() { onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900" required - disabled={loading} + disabled={loading || demoLoading} autoComplete="email" />
@@ -66,7 +128,7 @@ export default function LoginPage() { onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900" required - disabled={loading} + disabled={loading || demoLoading} autoComplete="current-password" />
@@ -74,11 +136,27 @@ export default function LoginPage() { + + {demoVisible && ( +
+

+ No account? Explore with a demo. +

+ +
+ )}
); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 6e49da4..36e2803 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -34,6 +34,19 @@ async function refreshAccessToken(): Promise { * - Adds CSRF token for state-changing requests * - Handles 401 errors with automatic token refresh */ +/** + * Start a demo session + * Calls POST /api/auth/demo - server sets httpOnly auth cookies on success. + * Returns the raw Response so callers can branch on status codes (201/429/404). + */ +export async function tryDemo(): Promise { + return fetch(`${API_URL}/api/auth/demo`, { + method: 'POST', + credentials: 'include', // Receive httpOnly cookies + headers: { 'Content-Type': 'application/json' }, + }); +} + export async function apiRequest( endpoint: string, options: RequestInit = {} From e187127173544e86174e36293b2d142c437ec50e Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:24:54 +0800 Subject: [PATCH 23/27] feat(web): add demo countdown banner Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/dashboard/layout.tsx | 2 + apps/web/components/demo-banner.tsx | 89 +++++++++++++++++++++++++++++ apps/web/contexts/AuthContext.tsx | 2 + 3 files changed, 93 insertions(+) create mode 100644 apps/web/components/demo-banner.tsx diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 9904831..14983b2 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -1,4 +1,5 @@ import { Sidebar } from '../../components/layout/Sidebar'; +import { DemoBanner } from '../../components/demo-banner'; export default function DashboardLayout({ children, @@ -9,6 +10,7 @@ export default function DashboardLayout({
+
{children}
diff --git a/apps/web/components/demo-banner.tsx b/apps/web/components/demo-banner.tsx new file mode 100644 index 0000000..d61453e --- /dev/null +++ b/apps/web/components/demo-banner.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useAuth } from '../contexts/AuthContext'; + +function formatRemaining(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours >= 1) { + return `${hours}h ${minutes}m ${seconds}s`; + } + return `${minutes}m ${seconds}s`; +} + +export function DemoBanner() { + const { user } = useAuth(); + const [remaining, setRemaining] = useState(null); + const intervalRef = useRef | null>(null); + + const expiresAt = user?.expiresAt + ? new Date(user.expiresAt as string | Date) + : null; + const expiresMs = expiresAt ? expiresAt.getTime() : null; + + useEffect(() => { + if (!user?.isDemo || !expiresMs) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setRemaining(null); + return; + } + + const tick = () => { + const ms = expiresMs - Date.now(); + if (ms <= 0) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setRemaining(0); + window.location.href = '/login?expired=1'; + return; + } + setRemaining(ms); + }; + + tick(); + intervalRef.current = setInterval(tick, 1000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [user?.isDemo, expiresMs]); + + if (!user?.isDemo || remaining === null || remaining <= 0) { + return null; + } + + return ( +
+ + Demo expires in{' '} + + {formatRemaining(remaining)} + + + + + Sign up to keep your data + +
+ ); +} diff --git a/apps/web/contexts/AuthContext.tsx b/apps/web/contexts/AuthContext.tsx index 4437c42..f8789a0 100644 --- a/apps/web/contexts/AuthContext.tsx +++ b/apps/web/contexts/AuthContext.tsx @@ -15,6 +15,8 @@ interface User { id: string; email: string; name: string | null; + isDemo?: boolean; + expiresAt?: string | Date | null; } interface AuthContextType { From 1339fe9d95355bac1c5805dd3b99d01f86b0f370 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 08:28:52 +0800 Subject: [PATCH 24/27] feat(web): demo cap indicators and inject-email button --- apps/web/app/dashboard/devinbox/page.tsx | 39 +++++++-- .../devinbox/projects/[projectId]/page.tsx | 81 ++++++++++++++++++- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/web/app/dashboard/devinbox/page.tsx b/apps/web/app/dashboard/devinbox/page.tsx index 4c070a4..980818c 100644 --- a/apps/web/app/dashboard/devinbox/page.tsx +++ b/apps/web/app/dashboard/devinbox/page.tsx @@ -4,6 +4,9 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { CreateProjectModal } from '../../../components/devinbox/CreateProjectModal'; import { apiRequest } from '../../../lib/api'; +import { useAuth } from '../../../contexts/AuthContext'; + +const DEMO_PROJECT_CAP = 2; interface Project { id: string; @@ -17,10 +20,15 @@ interface Project { } export default function DevInboxPage() { + const { user } = useAuth(); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); + const isDemo = !!user?.isDemo; + const projectCount = projects.length; + const atCap = isDemo && projectCount >= DEMO_PROJECT_CAP; + useEffect(() => { fetchProjects(); }, []); @@ -71,12 +79,31 @@ export default function DevInboxPage() {

DevInbox

- +
+ {isDemo && ( + + {atCap + ? `${projectCount}/${DEMO_PROJECT_CAP} projects used (demo limit)` + : `${projectCount}/${DEMO_PROJECT_CAP} projects used`} + + )} + +

diff --git a/apps/web/app/dashboard/devinbox/projects/[projectId]/page.tsx b/apps/web/app/dashboard/devinbox/projects/[projectId]/page.tsx index 9d46b89..640fa17 100644 --- a/apps/web/app/dashboard/devinbox/projects/[projectId]/page.tsx +++ b/apps/web/app/dashboard/devinbox/projects/[projectId]/page.tsx @@ -5,6 +5,7 @@ import { useParams } from "next/navigation"; import Link from "next/link"; import { ProjectTabs } from "../../../../../components/devinbox/ProjectTabs"; import { apiRequest } from "../../../../../lib/api"; +import { useAuth } from "../../../../../contexts/AuthContext"; interface Email { id: string; @@ -17,12 +18,16 @@ interface Email { export default function ProjectInboxPage() { const params = useParams(); const projectId = params?.projectId as string; + const { user } = useAuth(); + const isDemo = !!user?.isDemo; const [emails, setEmails] = useState([]); const [loading, setLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [totalEmails, setTotalEmails] = useState(0); + const [isInjecting, setIsInjecting] = useState(false); + const [injectError, setInjectError] = useState(null); const itemsPerPage = 20; const fetchEmails = useCallback(async (silent = false) => { @@ -48,6 +53,54 @@ export default function ProjectInboxPage() { } }, [projectId, currentPage, itemsPerPage]); + const injectTestEmail = async () => { + if (!projectId) return; + setInjectError(null); + setIsInjecting(true); + try { + const res = await apiRequest( + `/api/projects/${projectId}/demo/inject-email`, + { method: "POST" } + ); + + if (res.status === 201) { + // Reset to first page so the newly injected email is visible + if (currentPage !== 1) { + setCurrentPage(1); + } else { + await fetchEmails(true); + } + return; + } + + if (res.status === 403) { + let message = "You don't have permission to inject test emails."; + try { + const body = await res.json(); + const backendMsg = (body?.message as string | undefined) ?? ""; + if (backendMsg.toLowerCase().includes("cap") || + backendMsg.toLowerCase().includes("limit") || + backendMsg.toLowerCase().includes("20")) { + message = "You've reached the 20 email limit for this project."; + } else if (backendMsg) { + message = backendMsg; + } + } catch { + // ignore JSON parse errors + } + setInjectError(message); + return; + } + + setInjectError("Failed to inject test email. Please try again."); + } catch (error) { + console.error("Error injecting test email:", error); + setInjectError("Failed to inject test email. Please try again."); + } finally { + setIsInjecting(false); + } + }; + useEffect(() => { if (projectId) { fetchEmails(); @@ -78,7 +131,17 @@ export default function ProjectInboxPage() {

Project Inbox

- {isRefreshing && ( +
+ {isDemo && ( + + )} + {isRefreshing && (
Refreshing...
- )} + )} +
+ {injectError && ( +
+ {injectError} + +
+ )} + {/* Tabs */} From 8dbbededf13590c824bbaed900688fe8784e387e Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 11:36:38 +0800 Subject: [PATCH 25/27] refactor(api): shorten demo project slug to demo- Was demo-inbox-. Shorter slug makes the demo email address (e.g. *@demo-abc123.) less visually cluttered in the UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts | 6 +++--- apps/api/src/demo/seeders/devinbox-demo.seeder.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts b/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts index e579903..e392ae6 100644 --- a/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts +++ b/apps/api/src/demo/seeders/devinbox-demo.seeder.spec.ts @@ -3,10 +3,10 @@ import { DevInboxDemoSeeder } from './devinbox-demo.seeder'; import { PrismaService } from '../../prisma/prisma.service'; describe('DevInboxDemoSeeder', () => { - it('creates the demo-inbox project and 5 emails', async () => { + it('creates the demo project and 5 emails', async () => { const prisma = { project: { - create: jest.fn().mockResolvedValue({ id: 'p1', slug: 'demo-inbox' }), + create: jest.fn().mockResolvedValue({ id: 'p1', slug: 'demo' }), }, email: { createMany: jest.fn().mockResolvedValue({ count: 5 }) }, }; @@ -22,7 +22,7 @@ describe('DevInboxDemoSeeder', () => { data: expect.objectContaining({ userId: 'user-1', name: 'Demo Inbox', - slug: expect.stringMatching(/^demo-inbox-[a-f0-9]{6}$/), + slug: expect.stringMatching(/^demo-[a-f0-9]{6}$/), }), }), ); diff --git a/apps/api/src/demo/seeders/devinbox-demo.seeder.ts b/apps/api/src/demo/seeders/devinbox-demo.seeder.ts index 2f18e85..87f5c19 100644 --- a/apps/api/src/demo/seeders/devinbox-demo.seeder.ts +++ b/apps/api/src/demo/seeders/devinbox-demo.seeder.ts @@ -9,7 +9,8 @@ import { SAMPLE_EMAILS } from './sample-emails'; * so new demo users have something to explore in the DevInbox UI. * * Note: the Project model's `slug` column is globally `@unique`, so the slug - * is suffixed with 6 chars of a UUID to keep per-demo-user seeds collision-free. + * uses a 6-char UUID suffix (`demo-abc123`) to keep per-demo-user seeds + * collision-free while keeping the resulting email address short. */ @Injectable() export class DevInboxDemoSeeder implements DemoSeeder { @@ -20,7 +21,7 @@ export class DevInboxDemoSeeder implements DemoSeeder { const project = await this.prisma.project.create({ data: { userId, - slug: `demo-inbox-${suffix}`, + slug: `demo-${suffix}`, name: 'Demo Inbox', description: 'Your personal demo project. Explore — this is temporary.', From f9233210097bd54cbb9ccbea7d854fbb131f3720 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 11:47:38 +0800 Subject: [PATCH 26/27] refactor(web): promote demo banner to root layout So demo expiry redirect fires from any page, not only dashboard. Body becomes flex-col so the banner docks above a flex-1 content area; dashboard switches from h-screen to flex-1 min-h-0 to fill that slot. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/dashboard/layout.tsx | 4 +--- apps/web/app/layout.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 14983b2..00bebd5 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -1,5 +1,4 @@ import { Sidebar } from '../../components/layout/Sidebar'; -import { DemoBanner } from '../../components/demo-banner'; export default function DashboardLayout({ children, @@ -7,10 +6,9 @@ export default function DashboardLayout({ children: React.ReactNode; }) { return ( -
+
-
{children}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f4e721a..ddea5f4 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import type { Metadata } from "next"; import { Geist } from "next/font/google"; import { AuthProvider } from "../contexts/AuthContext"; +import { DemoBanner } from "../components/demo-banner"; const geist = Geist({ subsets: ["latin"] }); @@ -18,9 +19,12 @@ export default function RootLayout({ }) { return ( - + - {children} + +
+ {children} +
From 1f057087ee50fbf877a16f021102a71751f7a5f3 Mon Sep 17 00:00:00 2001 From: Rowee13 Date: Wed, 15 Apr 2026 12:38:06 +0800 Subject: [PATCH 27/27] fix(web): make "Mark as Read/Unread" button text visible Button inherited text color against white background, rendering invisible. Explicit text-gray-700 matches the neutral palette. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../devinbox/projects/[projectId]/emails/[emailId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/dashboard/devinbox/projects/[projectId]/emails/[emailId]/page.tsx b/apps/web/app/dashboard/devinbox/projects/[projectId]/emails/[emailId]/page.tsx index 93cc50c..fbc58d3 100644 --- a/apps/web/app/dashboard/devinbox/projects/[projectId]/emails/[emailId]/page.tsx +++ b/apps/web/app/dashboard/devinbox/projects/[projectId]/emails/[emailId]/page.tsx @@ -157,7 +157,7 @@ export default function EmailDetailPage() {