Demo Mode — Architecture
Module 15, task #352.
Goal
Let a visitor try the product with zero signup friction — a single "Try the demo" button that drops them straight into a populated, fully-navigable dashboard with a real session.
Entry point: POST /demo/session
apps/api/src/modules/demo/controllers/demo.controller.ts — @Public()
(bypasses the global JwtAuthGuard) and @Throttle({ limit: 5, ttl: 60_000 }) per caller IP. Returns 404 DEMO_MODE_DISABLED if the
DEMO_MODE_ENABLED env flag is off, so operators can ship the code
disabled by default and turn it on deliberately.
Why an unauthenticated write endpoint is safe here
A public endpoint that creates a User + Workspace on every call is
normally a request-forgery / resource-exhaustion concern. Two
architectural choices contain that:
- Demo workspaces never run anything live.
DemoSeedService.seed()writes pre-built, already-COMPLETEDReconJob/VulnScanJobrows with synthetic findings directly viaPrismaService— it never goes through the normal job-creation command path, so no real network scan, AI call, or worker dispatch is ever triggered by a demo session. - The read-only guard is enforced at the one shared choke point real
scans go through.
resolveTargetForCaller()(apps/api/src/modules/targets/commands/target-lifecycle.commands.ts) — used by every Target-scoped command — now also returnsisDemoWorkspace: boolean.CreateReconJobHandlerandCreateVulnScanJobHandlerboth check it first and throwAppException('DEMO_WORKSPACE_READ_ONLY', ..., 403)before touching their repositories. A demo visitor can browse and click freely; the moment they try to start a new scan they get a clear "sign up for a real account" error instead of a scan silently running against whatever target they typed in.
@Throttle bounds the rate of new demo sessions per caller; the two
points above bound the damage any single session (or a burst of them)
can do.
Session issuance
DemoSessionService.start() mirrors RegisterUseCase's shape (User +
Workspace + default UserPreferences in one @Transactional() unit) with
three deliberate differences: the user has passwordHash: null (same
shape as an OAuth-only account — nothing distinguishes it at the schema
level), emailVerified: true with no real inbox behind the synthetic
demo+<uuid>@demo.pentesthub.local address, and the workspace is marked
isDemo: true with a demoExpiresAt. It then reuses
SessionIssuerService.issue() — the same primitive login, mfa-challenge,
and oauth-exchange already share — so the visitor gets a real,
fully-functional JWT session with no separate demo-only auth code path
to keep in sync. Onboarding (task #351) is marked pre-completed for
demo users rather than shown, since the demo is the walkthrough.
Cleanup: DemoCleanupService
Mirrors McpSessionCleanupService's shape exactly: OnModuleInit /
OnModuleDestroy, a 60s setInterval, and
PollerLeaseService.withLease('demo-cleanup', 90_000, ...) so only one
node in a multi-instance deployment runs the sweep at a time. Each tick
finds Workspace rows where isDemo: true and demoExpiresAt <= now
and deletes the owning User (cascades to the workspace and everything
seeded under it), falling back to deleting the Workspace directly if
no owner is found. Per-workspace errors are caught individually so one
bad row doesn't stop the rest of the sweep from running.
Frontend
apps/web/features/auth/components/login-form.tsx — a
"Try the demo — no account needed" button next to the OAuth buttons,
calling startDemoSession() (apps/web/lib/api/auth.ts, a public
POST /demo/session) then completing login exactly like a normal
credential/OAuth login.
apps/web/features/workspace/components/demo-banner.tsx — a persistent
strip shown across the dashboard whenever the active workspace has
isDemo: true, linking to /register, so a visitor is never confused
about whether their changes are being saved to a permanent account.
Mounted once in DashboardShell, same pattern as OnboardingFlow.
Known gaps (tracked, not silently dropped)
apps/desktopandapps/mobilehave no demo-mode UI. The API endpoint is platform-agnostic, but only the web login screen has a "Try the demo" entry point, matching the scope pattern used by #348–#351 (one real reference implementation per task rather than three shallow ones).- No admin visibility into active/expiring demo sessions. The
Admin Console (Module 14) has no panel listing current demo
workspaces or letting an operator force-expire one early; the only
lever today is
DEMO_SESSION_TTL_HOURSand the periodic sweep. - Per-visitor demo abuse protection is limited to
@Throttle(5/min per caller). There's no additional fingerprinting, CAPTCHA, or global daily cap on demo sessions created. Acceptable for a read-mostly synthetic sandbox with no live tool execution, but worth revisiting if abuse is observed in production. - No jest run to verify the three new spec files in this sandbox
(
create-recon-job.command.spec.ts,create-vuln-scan-job.command.spec.ts,demo-session.service.spec.ts) — thejestpackage itself is absent from the local pnpm store in this sandbox (not just corrupted; the target directory under.pnpm/jest@30.4.2.../does not exist at all). Substitute verification: full manual trace of each spec's assertions against the real handler/service implementation, plus a cleantsc --noEmitpass covering all three spec files.