mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
feat(demo): the shared demo account keeps its keys, account and language
Every visitor of a demo instance signs in as the same account, so one visitor's API key, MCP token, OAuth client, passkey or new email address became everybody's. These writes now answer 403 for the demo account through refuseForDemoAccount in demo-write.ts: creating API keys, creating and deleting MCP tokens, registering, rotating and deleting OAuth clients, approving an app and ending a connected app, changing the name, address or picture, and enrolling, renaming or deleting passkeys. Password, 2FA, account deletion, sessions and the own AI connection were refused already. The demo account's language is fixed to English: a single settings write of the language answers unchanged, a bulk save drops it and keeps the rest. The MCP settings tool is held back for the demo account by the tool gate, as every write tool is. The admin of a demo instance and every real instance are untouched.
This commit is contained in:
@@ -6,7 +6,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { currentSessionId, decodeSessionClaims } from '../auth-core/jwt-verify';
|
||||
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
|
||||
import { DEMO_ACCOUNT_REFUSAL, DEMO_WRITE_ERROR, isDemoWriteBlocked, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { ManagedForbidden } from '../common/managed';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { sessionClientFrom } from '../sessions/sessions.service';
|
||||
@@ -194,6 +194,11 @@ export class AuthController {
|
||||
|
||||
@Put('me/settings')
|
||||
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
||||
// The name and the address belong to the Account tab. Every visitor of a
|
||||
// demo instance shares this account, so they stay what the demo seeded.
|
||||
if (body.username !== undefined || body.email !== undefined) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account);
|
||||
}
|
||||
const result = await this.profile.updateSettings(user.id, body);
|
||||
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
||||
// The address itself stays out of the row, like the key values above; the row
|
||||
@@ -228,6 +233,7 @@ export class AuthController {
|
||||
|
||||
@Delete('avatar')
|
||||
async deleteAvatar(@CurrentUser() user: User) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account);
|
||||
return this.profile.deleteAvatar(user.id);
|
||||
}
|
||||
|
||||
@@ -312,6 +318,7 @@ export class AuthController {
|
||||
@Post('mcp-tokens')
|
||||
@HttpCode(201)
|
||||
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
await this.limit('login', req, 5);
|
||||
const result = await this.tokens.createMcpToken(user.id, body.name);
|
||||
return { token: result.token };
|
||||
@@ -319,6 +326,7 @@ export class AuthController {
|
||||
|
||||
@Delete('mcp-tokens/:id')
|
||||
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
await this.tokens.deleteMcpToken(user.id, id);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -343,6 +351,7 @@ export class AuthController {
|
||||
@Post('api-tokens')
|
||||
@HttpCode(201)
|
||||
async createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.apiKeys);
|
||||
await this.limit('login', req, 5);
|
||||
// No `scopes` means the key reads everything, which is what every key minted
|
||||
// before this field existed does. Narrowing stays opt-in so the change
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
||||
import { setAuthCookie } from '../common/cookie';
|
||||
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { sessionClientFrom } from '../sessions/sessions.service';
|
||||
import {
|
||||
@@ -69,6 +70,9 @@ export class PasskeyController {
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) {
|
||||
// Every visitor shares the demo account: a passkey enrolled on it would be
|
||||
// one stranger's key to everybody's demo.
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.limit('mfa', req, 5);
|
||||
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
|
||||
// — the RP ID itself is never derived from request headers.
|
||||
@@ -81,6 +85,7 @@ export class PasskeyController {
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
||||
return { success: true, credential: result.credential };
|
||||
@@ -142,6 +147,7 @@ export class PasskeyController {
|
||||
@Patch('credentials/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.passkeys.renamePasskey(user.id, id, body?.name);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -154,6 +160,7 @@ export class PasskeyController {
|
||||
@Body() body: PasskeyDeleteDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.limit('login', req, 5);
|
||||
await this.passkeys.deletePasskey(user.id, id, body?.password);
|
||||
await this.audit.writeAudit({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readEnv } from '../../app-config';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { isDemoEmail } from './demo';
|
||||
import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo';
|
||||
import { DomainError } from './domain-error';
|
||||
|
||||
/** The exact 403 body the six upload endpoints have always returned. */
|
||||
export const DEMO_WRITE_ERROR = {
|
||||
@@ -29,3 +31,48 @@ export function isDemoWriteBlocked(env: RuntimeEnvService, email: string | null
|
||||
// `this.demo.isDemoUserId(userId)` instead: the same check, resolving `env`/
|
||||
// the repository via its own constructor rather than taking them as
|
||||
// parameters.
|
||||
|
||||
/**
|
||||
* The shared account every visitor of a demo instance signs in as. The same
|
||||
* reading of the environment as the password, MFA and session refusals in
|
||||
* `auth/`, so a controller that has no RuntimeEnvService of its own (passkeys,
|
||||
* OAuth) asks the same question without a new constructor parameter.
|
||||
*/
|
||||
export function isDemoAccount(user: { email?: string | null } | null | undefined): boolean {
|
||||
return readEnv().demo.enabled && isDemoEmail(user?.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the demo account is told when it tries to change something every other
|
||||
* visitor shares. One sentence per area, so the client can show it as it is.
|
||||
*/
|
||||
export const DEMO_ACCOUNT_REFUSAL = {
|
||||
apiKeys: 'API keys cannot be created in demo mode.',
|
||||
mcp: 'MCP clients cannot be connected in demo mode.',
|
||||
account: 'The demo account cannot be changed in demo mode.',
|
||||
passkeys: 'Passkeys cannot be changed in demo mode.',
|
||||
} as const;
|
||||
|
||||
/** Refuses the write with a 403 for the demo account; does nothing for anybody else. */
|
||||
export function refuseForDemoAccount(user: { email?: string | null } | null | undefined, message: string): void {
|
||||
if (isDemoAccount(user)) throw new DomainError(403, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a settings write of this key is one the demo account does not get to
|
||||
* make (`DEMO_ACCOUNT_SETTINGS`). The route answers as if it saved nothing,
|
||||
* rather than refusing: a client that sends the language along with other keys
|
||||
* keeps the rest of its save.
|
||||
*/
|
||||
export function isDemoLockedSetting(user: { email?: string | null } | null | undefined, key: string): boolean {
|
||||
return Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key) && isDemoAccount(user);
|
||||
}
|
||||
|
||||
/** The settings of a bulk save without the keys the demo account keeps fixed. */
|
||||
export function withoutDemoLockedSettings(
|
||||
user: { email?: string | null } | null | undefined,
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!isDemoAccount(user)) return settings;
|
||||
return Object.fromEntries(Object.entries(settings).filter(([key]) => !Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key)));
|
||||
}
|
||||
|
||||
@@ -28,3 +28,12 @@ export const DEMO_EMAILS: ReadonlySet<string> = new Set([DEMO_EMAIL_PRIMARY, 'de
|
||||
export function isDemoEmail(email: string | null | undefined): boolean {
|
||||
return !!email && DEMO_EMAILS.has(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* User settings the shared demo account always holds, whatever a visitor picks.
|
||||
* The demo seed writes them on every boot and after every hourly reset, and the
|
||||
* settings routes leave them alone for the demo account (`demo-write.ts`), so a
|
||||
* visitor cannot switch the demo every other visitor sees into another
|
||||
* language.
|
||||
*/
|
||||
export const DEMO_ACCOUNT_SETTINGS: Readonly<Record<string, string>> = { language: 'en' };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CookieAuthGuard } from '../auth-core/cookie-auth.guard';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { OptionalJwtGuard } from '../auth-core/optional-jwt.guard';
|
||||
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto';
|
||||
import { OauthService } from './oauth.service';
|
||||
@@ -122,6 +123,8 @@ export class OauthApiController {
|
||||
if (body.state) url.searchParams.set('state', body.state);
|
||||
return { redirect: url.toString() };
|
||||
}
|
||||
// Saying no stays possible. Handing the shared demo account to an app does not.
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const scopes = validation.scopes!;
|
||||
await this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
|
||||
const code = await this.oauth.createAuthCode({
|
||||
@@ -157,6 +160,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.createOAuthClient(
|
||||
user.id,
|
||||
body.name,
|
||||
@@ -176,6 +180,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
@@ -187,6 +192,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
@@ -205,6 +211,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getClientIp } from '../audit/client-ip';
|
||||
import { AdminGuard } from '../auth-core/admin.guard';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { isDemoLockedSetting, withoutDemoLockedSettings } from '../common/demo-write';
|
||||
import { isManagedLockedKey, splitManagedKeys } from '../common/managed';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { SettingsWritesService } from './settings-writes.service';
|
||||
@@ -99,6 +100,10 @@ export class SettingsController {
|
||||
if (isManagedLockedKey(body.key) && this.env.isManaged()) {
|
||||
return { success: true, key: body.key, managed: true };
|
||||
}
|
||||
// The demo account keeps its language: every visitor sees the same demo.
|
||||
if (isDemoLockedSetting(user, body.key)) {
|
||||
return { success: true, key: body.key, unchanged: true };
|
||||
}
|
||||
// The client echoes a redacted secret back unchanged — treat as a no-op.
|
||||
if (body.value === MASKED_SETTING_VALUE) {
|
||||
return { success: true, key: body.key, unchanged: true };
|
||||
@@ -113,7 +118,7 @@ export class SettingsController {
|
||||
async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto, @Headers('x-socket-id') socketId?: string) {
|
||||
this.assertMayWriteInstanceEndpoint(body.settings);
|
||||
this.assertValidValues(body.settings);
|
||||
const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged());
|
||||
const { allowed, blocked } = splitManagedKeys(withoutDemoLockedSettings(user, body.settings), this.env.isManaged());
|
||||
try {
|
||||
const updated = await this.writes.saveMany(user.id, allowed, socketId);
|
||||
return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) };
|
||||
|
||||
Reference in New Issue
Block a user