Bernt Christian Egeland 22 часов назад
Родитель
Сommit
f3fb79e2d3
59 измененных файлов с 849 добавлено и 60 удалено
  1. 3 1
      .github/workflows/deploy-demo.yml
  2. 1 0
      messages/de/settings.json
  3. 1 0
      messages/en/settings.json
  4. 1 0
      messages/es/settings.json
  5. 1 0
      messages/fr/settings.json
  6. 1 0
      messages/it/settings.json
  7. 1 0
      messages/lt/settings.json
  8. 1 0
      messages/nb/settings.json
  9. 1 0
      messages/nl/settings.json
  10. 1 0
      messages/pl/settings.json
  11. 1 0
      messages/pt-BR/settings.json
  12. 1 0
      messages/pt-PT/settings.json
  13. 1 0
      messages/ru/settings.json
  14. 1 0
      messages/tr/settings.json
  15. 37 1
      prisma/seed_dummy_data.ts
  16. 28 0
      src/__tests__/lib/demo-guard-coverage.test.ts
  17. 240 0
      src/__tests__/lib/demo-hardening.test.ts
  18. 64 0
      src/__tests__/lib/files/manager.test.ts
  19. 4 1
      src/app/(authenticated)/settings/account/account-settings.tsx
  20. 2 0
      src/app/(authenticated)/settings/account/page.tsx
  21. 20 1
      src/app/(authenticated)/settings/account/signed-in-devices.tsx
  22. 5 0
      src/app/api/protected/ai/chat/route.ts
  23. 7 0
      src/app/api/protected/ai/transcribe/live/route.ts
  24. 4 0
      src/app/api/protected/ai/transcribe/route.ts
  25. 12 1
      src/app/api/protected/cleanup/status-reports/route.ts
  26. 6 0
      src/app/api/protected/subscription/upgrade-preview/route.ts
  27. 7 2
      src/app/api/protected/upload/email-image/route.ts
  28. 9 2
      src/app/api/protected/upload/email-logo/route.ts
  29. 9 2
      src/app/api/protected/upload/inventory/route.ts
  30. 9 2
      src/app/api/protected/upload/logo/route.ts
  31. 10 3
      src/app/api/protected/upload/portal-background/route.ts
  32. 7 2
      src/app/api/protected/upload/quote-files/route.ts
  33. 9 2
      src/app/api/protected/upload/route.ts
  34. 9 2
      src/app/api/protected/upload/service-files/route.ts
  35. 7 2
      src/app/api/protected/upload/tire-hotel/route.ts
  36. 7 0
      src/app/api/protected/whatsapp/media/[messageId]/route.ts
  37. 20 19
      src/app/api/public/auth/[...all]/route.ts
  38. 5 0
      src/app/api/public/email-image/[orgId]/[file]/route.ts
  39. 5 0
      src/app/api/public/email-logo/[orgId]/[file]/route.ts
  40. 5 1
      src/app/api/public/inspection-handoff/[token]/route.ts
  41. 5 0
      src/app/api/public/logo/[orgId]/route.ts
  42. 5 0
      src/app/api/public/logo/route.ts
  43. 6 1
      src/app/api/public/photo-handoff/[token]/route.ts
  44. 5 0
      src/app/api/public/portal-bg/[orgId]/route.ts
  45. 11 2
      src/app/api/v1/tech/jobs/[id]/attachments/route.ts
  46. 14 6
      src/app/api/v1/tech/jobs/[id]/status-report/route.ts
  47. 14 2
      src/features/audit/Actions/auditActions.ts
  48. 7 0
      src/features/integrations/Lib/ai.ts
  49. 6 0
      src/features/integrations/Lib/speech.ts
  50. 7 1
      src/features/portal/Components/CustomerPortalSettings.tsx
  51. 10 2
      src/features/settings/Actions/sessionActions.ts
  52. 7 0
      src/lib/ai.ts
  53. 23 0
      src/lib/default-logo.ts
  54. 51 0
      src/lib/demo-auth-paths.ts
  55. 21 0
      src/lib/demo.ts
  56. 7 1
      src/lib/files/manager.ts
  57. 7 1
      src/lib/rate-limit.ts
  58. 9 0
      src/lib/torqvoice-com.ts
  59. 71 0
      src/lib/upload-guard.ts

+ 3 - 1
.github/workflows/deploy-demo.yml

@@ -180,7 +180,9 @@ jobs:
           exit 1
 
       - name: Seed demo data
-        if: ${{ github.event_name == 'push' || inputs.reseed }}
+        # workflow_dispatch is the only trigger, so the old `push` clause was
+        # dead. A boolean input is a real boolean in the `inputs` context.
+        if: ${{ github.event_name == 'workflow_dispatch' && inputs.reseed }}
         run: |
           docker exec \
             -e DEMO_USER_EMAIL="demo@torqvoice.com" \

+ 1 - 0
messages/de/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Angemeldete Geräte",
     "devicesDescription": "Jeder Browser und jedes Telefon mit einer offenen Sitzung auf deinem Konto.",
+    "devicesDemoHidden": "Alle in der Demo teilen sich dieses Konto, daher wird nur dieser Browser angezeigt. Die Sitzungen anderer Besucher sind ausgeblendet.",
     "thisDevice": "Dieses Gerät",
     "signedInAt": "Angemeldet {date}",
     "lastActiveAt": "Zuletzt aktiv {date}",

+ 1 - 0
messages/en/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Signed-in devices",
     "devicesDescription": "Every browser and phone with an open session on your account.",
+    "devicesDemoHidden": "Everyone on the demo shares this account, so only this browser is shown. Other visitors’ sessions are hidden.",
     "thisDevice": "This device",
     "signedInAt": "Signed in {date}",
     "lastActiveAt": "Last active {date}",

+ 1 - 0
messages/es/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Dispositivos con sesión iniciada",
     "devicesDescription": "Todos los navegadores y teléfonos con una sesión abierta en tu cuenta.",
+    "devicesDemoHidden": "Todos en la demo comparten esta cuenta, así que solo se muestra este navegador. Las sesiones de otros visitantes están ocultas.",
     "thisDevice": "Este dispositivo",
     "signedInAt": "Sesión iniciada {date}",
     "lastActiveAt": "Última actividad {date}",

+ 1 - 0
messages/fr/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Appareils connectés",
     "devicesDescription": "Tous les navigateurs et téléphones ayant une session ouverte sur votre compte.",
+    "devicesDemoHidden": "Tout le monde partage ce compte sur la démo, donc seul ce navigateur est affiché. Les sessions des autres visiteurs sont masquées.",
     "thisDevice": "Cet appareil",
     "signedInAt": "Connecté le {date}",
     "lastActiveAt": "Dernière activité {date}",

+ 1 - 0
messages/it/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Dispositivi connessi",
     "devicesDescription": "Tutti i browser e i telefoni con una sessione aperta sul tuo account.",
+    "devicesDemoHidden": "Nella demo tutti condividono questo account, quindi viene mostrato solo questo browser. Le sessioni degli altri visitatori sono nascoste.",
     "thisDevice": "Questo dispositivo",
     "signedInAt": "Accesso effettuato {date}",
     "lastActiveAt": "Ultima attività {date}",

+ 1 - 0
messages/lt/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Prisijungę įrenginiai",
     "devicesDescription": "Visos naršyklės ir telefonai su atvira sesija jūsų paskyroje.",
+    "devicesDemoHidden": "Demonstracinėje versijoje visi naudoja šią paskyrą, todėl rodoma tik ši naršyklė. Kitų lankytojų sesijos paslėptos.",
     "thisDevice": "Šis įrenginys",
     "signedInAt": "Prisijungta {date}",
     "lastActiveAt": "Paskutinį kartą aktyvus {date}",

+ 1 - 0
messages/nb/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Innloggede enheter",
     "devicesDescription": "Alle nettlesere og telefoner med en åpen økt på kontoen din.",
+    "devicesDemoHidden": "Alle på demoen deler denne kontoen, så bare denne nettleseren vises. Andre besøkendes økter er skjult.",
     "thisDevice": "Denne enheten",
     "signedInAt": "Logget inn {date}",
     "lastActiveAt": "Sist aktiv {date}",

+ 1 - 0
messages/nl/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Ingelogde apparaten",
     "devicesDescription": "Elke browser en telefoon met een open sessie op je account.",
+    "devicesDemoHidden": "Iedereen op de demo deelt dit account, dus alleen deze browser wordt getoond. Sessies van andere bezoekers zijn verborgen.",
     "thisDevice": "Dit apparaat",
     "signedInAt": "Ingelogd {date}",
     "lastActiveAt": "Laatst actief {date}",

+ 1 - 0
messages/pl/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Zalogowane urządzenia",
     "devicesDescription": "Każda przeglądarka i telefon z otwartą sesją na Twoim koncie.",
+    "devicesDemoHidden": "Wszyscy w wersji demo korzystają z tego konta, więc widoczna jest tylko ta przeglądarka. Sesje innych odwiedzających są ukryte.",
     "thisDevice": "To urządzenie",
     "signedInAt": "Zalogowano {date}",
     "lastActiveAt": "Ostatnia aktywność {date}",

+ 1 - 0
messages/pt-BR/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Dispositivos conectados",
     "devicesDescription": "Todos os navegadores e telefones com uma sessão aberta na sua conta.",
+    "devicesDemoHidden": "Todos na demonstração compartilham esta conta, então só este navegador é mostrado. As sessões de outros visitantes ficam ocultas.",
     "thisDevice": "Este dispositivo",
     "signedInAt": "Conectado em {date}",
     "lastActiveAt": "Última atividade {date}",

+ 1 - 0
messages/pt-PT/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Dispositivos com sessão iniciada",
     "devicesDescription": "Todos os navegadores e telemóveis com uma sessão aberta na sua conta.",
+    "devicesDemoHidden": "Todos na demonstração partilham esta conta, por isso só este navegador é mostrado. As sessões de outros visitantes estão ocultas.",
     "thisDevice": "Este dispositivo",
     "signedInAt": "Sessão iniciada em {date}",
     "lastActiveAt": "Última atividade {date}",

+ 1 - 0
messages/ru/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Устройства с входом",
     "devicesDescription": "Все браузеры и телефоны с открытой сессией в вашем аккаунте.",
+    "devicesDemoHidden": "В демоверсии все пользуются этим аккаунтом, поэтому показан только этот браузер. Сессии других посетителей скрыты.",
     "thisDevice": "Это устройство",
     "signedInAt": "Вход {date}",
     "lastActiveAt": "Последняя активность {date}",

+ 1 - 0
messages/tr/settings.json

@@ -206,6 +206,7 @@
   "account": {
     "devicesTitle": "Oturum açık cihazlar",
     "devicesDescription": "Hesabınızda açık oturumu olan tüm tarayıcılar ve telefonlar.",
+    "devicesDemoHidden": "Demoda herkes bu hesabı paylaşıyor, bu yüzden yalnızca bu tarayıcı gösteriliyor. Diğer ziyaretçilerin oturumları gizlidir.",
     "thisDevice": "Bu cihaz",
     "signedInAt": "Giriş {date}",
     "lastActiveAt": "Son etkinlik {date}",

Разница между файлами не показана из-за своего большого размера
+ 37 - 1
prisma/seed_dummy_data.ts


+ 28 - 0
src/__tests__/lib/demo-guard-coverage.test.ts

@@ -65,6 +65,34 @@ const EGRESS_PATHS: Array<{ file: string; stoppedBy: RegExp }> = [
     file: 'src/features/integrations/Lib/connections.ts',
     stoppedBy: /assertConnectorAllowed\(\)/,
   },
+  // AI and speech. Every completion and transcription is built from one
+  // client, which refuses; the setup lookups answer "not configured" so the
+  // pages offer no button; and each route refuses before reading anything.
+  { file: 'src/lib/ai.ts', stoppedBy: /assertAiAllowed\(\)/ },
+  { file: 'src/features/integrations/Lib/ai.ts', stoppedBy: /if \(isDemoMode\) return null/ },
+  {
+    file: 'src/features/integrations/Lib/speech.ts',
+    stoppedBy: /if \(isDemoMode\) return null/,
+  },
+  { file: 'src/app/api/protected/ai/chat/route.ts', stoppedBy: /if \(isDemoMode\)/ },
+  { file: 'src/app/api/protected/ai/transcribe/route.ts', stoppedBy: /if \(isDemoMode\)/ },
+  // Live dictation talks to OpenAI with a fetch of its own, not the client.
+  { file: 'src/app/api/protected/ai/transcribe/live/route.ts', stoppedBy: /if \(isDemoMode\)/ },
+  // Inbound WhatsApp media is fetched from the vendor on the workshop's
+  // credentials through the adapter, which the send transport's stop never
+  // sees.
+  {
+    file: 'src/app/api/protected/whatsapp/media/[messageId]/route.ts',
+    stoppedBy: /if \(isDemoMode\)/,
+  },
+  // Billing on torqvoice.com with this deployment's service secret. The
+  // billing routes and actions refuse too, but the daily sync, the account
+  // link ping and account deletion reach the client on their own.
+  { file: 'src/lib/torqvoice-com.ts', stoppedBy: /if \(isDemoMode\) throw/ },
+  {
+    file: 'src/app/api/protected/subscription/upgrade-preview/route.ts',
+    stoppedBy: /if \(isDemoMode\)/,
+  },
 ]
 
 describe('demo mode', () => {

+ 240 - 0
src/__tests__/lib/demo-hardening.test.ts

@@ -0,0 +1,240 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The stops the public demo leans on beyond demoGuard(): upload caps, the
+ * better-auth endpoints it refuses, the shared account's session list, the
+ * AI vendors, and the settings a visitor cannot write. Each module reads
+ * DEMO_MODE once at import, so every test loads a fresh copy under the flag
+ * it wants.
+ */
+
+const findManySessions = vi.hoisted(() => vi.fn())
+const findManyConnections = vi.hoisted(() => vi.fn())
+const findFirstConnection = vi.hoisted(() => vi.fn())
+const findManySettings = vi.hoisted(() => vi.fn())
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    session: { findMany: findManySessions },
+    integrationConnection: { findMany: findManyConnections, findFirst: findFirstConnection },
+    appSetting: { findMany: findManySettings },
+  },
+}))
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: async () => ({
+    user: { id: 'demo-user' },
+    session: { id: 'my-session' },
+  }),
+}))
+vi.mock('@/lib/audit', () => ({ logAudit: async () => undefined }))
+vi.mock('next/cache', () => ({ revalidatePath: () => undefined }))
+
+async function load<T>(module: string, demo: boolean): Promise<T> {
+  vi.resetModules()
+  process.env.DEMO_MODE = demo ? 'true' : 'false'
+  return (await import(module)) as T
+}
+
+beforeEach(() => {
+  findManySessions.mockReset().mockResolvedValue([])
+  findManyConnections.mockReset().mockResolvedValue([])
+  findFirstConnection.mockReset().mockResolvedValue(null)
+  findManySettings.mockReset().mockResolvedValue([])
+})
+
+afterEach(() => {
+  delete process.env.DEMO_MODE
+})
+
+type UploadGuard = typeof import('@/lib/upload-guard')
+const MB = 1024 * 1024
+
+/** A fresh path per request, so one test's pace never limits the next. */
+let counter = 0
+function upload(bytes: number | null) {
+  counter += 1
+  const headers = new Headers({ 'x-real-ip': '203.0.113.7' })
+  if (bytes !== null) headers.set('content-length', String(bytes))
+  return new Request(`http://localhost/api/protected/upload/test-${counter}`, {
+    method: 'POST',
+    headers,
+  })
+}
+
+describe('upload limits', () => {
+  it('lowers a route limit to 25MB on the demo and leaves smaller ones alone', async () => {
+    const { uploadLimit } = await load<UploadGuard>('@/lib/upload-guard', true)
+    expect(uploadLimit(500 * MB)).toBe(25 * MB)
+    expect(uploadLimit(5 * MB)).toBe(5 * MB)
+  })
+
+  it('keeps every route limit as it is off the demo', async () => {
+    const { uploadLimit } = await load<UploadGuard>('@/lib/upload-guard', false)
+    expect(uploadLimit(500 * MB)).toBe(500 * MB)
+  })
+
+  it('refuses a declared body over the effective limit before it is read, naming that limit', async () => {
+    const { guardUpload } = await load<UploadGuard>('@/lib/upload-guard', true)
+    const refused = guardUpload(upload(200 * MB), 500 * MB)
+    expect(refused?.status).toBe(413)
+    expect(await refused?.json()).toEqual({ error: 'File size must be under 25MB' })
+  })
+
+  it('lets through a body within the limit, or one that declares no length', async () => {
+    const { guardUpload } = await load<UploadGuard>('@/lib/upload-guard', true)
+    expect(guardUpload(upload(10 * MB), 500 * MB)).toBeNull()
+    // The route's own check on the file holds this one to the same limit.
+    expect(guardUpload(upload(null), 500 * MB)).toBeNull()
+  })
+
+  it('allows the multipart framing on top of the file', async () => {
+    const { guardUpload } = await load<UploadGuard>('@/lib/upload-guard', false)
+    expect(guardUpload(upload(5 * MB + 1024), 5 * MB)).toBeNull()
+    expect(guardUpload(upload(6 * MB), 5 * MB)?.status).toBe(413)
+  })
+
+  it('caps how often one caller uploads to one route', async () => {
+    const { guardUpload } = await load<UploadGuard>('@/lib/upload-guard', true)
+    const url = `http://localhost/api/protected/upload/paced-${Date.now()}`
+    const request = () =>
+      new Request(url, { method: 'POST', headers: { 'x-real-ip': '203.0.113.8' } })
+    for (let i = 0; i < 20; i++) expect(guardUpload(request(), 5 * MB)).toBeNull()
+    expect(guardUpload(request(), 5 * MB)?.status).toBe(429)
+  })
+})
+
+describe('better-auth endpoints on the demo', () => {
+  it('refuses everything that changes the shared account or reaches other visitors', async () => {
+    const { isDemoBlockedAuthPath } = await import('@/lib/demo-auth-paths')
+    for (const path of [
+      'change-password',
+      'set-password',
+      'request-password-reset',
+      'reset-password',
+      'reset-password/some-token',
+      'two-factor/enable',
+      'passkey/generate-register-options',
+      'update-user',
+      'change-email',
+      'delete-user',
+      'delete-user/callback',
+      'send-verification-email',
+      'link-social',
+      'unlink-account',
+      'list-accounts',
+      'get-access-token',
+      'refresh-token',
+      'list-sessions',
+      'revoke-session',
+      'revoke-sessions',
+      'revoke-other-sessions',
+    ]) {
+      expect(isDemoBlockedAuthPath(`/api/public/auth/${path}`), path).toBe(true)
+    }
+  })
+
+  it('keeps signing in and out working', async () => {
+    const { isDemoBlockedAuthPath } = await import('@/lib/demo-auth-paths')
+    for (const path of ['sign-in/email', 'sign-out', 'get-session', 'callback/google']) {
+      expect(isDemoBlockedAuthPath(`/api/public/auth/${path}`), path).toBe(false)
+    }
+  })
+
+  it('matches whole path segments, not any path that starts the same', async () => {
+    const { isDemoBlockedAuthPath } = await import('@/lib/demo-auth-paths')
+    expect(isDemoBlockedAuthPath('/api/public/auth/update-user-something')).toBe(false)
+  })
+})
+
+type SessionActions = typeof import('@/features/settings/Actions/sessionActions')
+
+describe('signed-in devices', () => {
+  it('lists only the caller’s own session on the demo, where every visitor is the same user', async () => {
+    const { listMyDevices } = await load<SessionActions>(
+      '@/features/settings/Actions/sessionActions',
+      true
+    )
+    await listMyDevices()
+    expect(findManySessions.mock.calls[0][0].where).toMatchObject({
+      userId: 'demo-user',
+      id: 'my-session',
+    })
+  })
+
+  it('lists every session of the account elsewhere', async () => {
+    const { listMyDevices } = await load<SessionActions>(
+      '@/features/settings/Actions/sessionActions',
+      false
+    )
+    await listMyDevices()
+    expect(findManySessions.mock.calls[0][0].where).not.toHaveProperty('id')
+  })
+})
+
+describe('AI on the demo', () => {
+  it('reports no provider and never reads a stored key', async () => {
+    const ai = await load<typeof import('@/features/integrations/Lib/ai')>(
+      '@/features/integrations/Lib/ai',
+      true
+    )
+    expect(await ai.aiSetup('org')).toBeNull()
+    expect(await ai.isAiConfigured('org')).toBe(false)
+    expect(await ai.configuredAiProvider('org')).toBeNull()
+    expect(findManyConnections).not.toHaveBeenCalled()
+    expect(findManySettings).not.toHaveBeenCalled()
+  })
+
+  it('offers no speech model, so the mic stays with the browser', async () => {
+    const speech = await load<typeof import('@/features/integrations/Lib/speech')>(
+      '@/features/integrations/Lib/speech',
+      true
+    )
+    expect(await speech.speechSetup('org')).toBeNull()
+    expect(await speech.configuredDictation('org')).toEqual({ available: false, mode: 'choice' })
+    expect(findFirstConnection).not.toHaveBeenCalled()
+  })
+
+  it('refuses to build a vendor client at all', async () => {
+    const { createClient, getAiConfig } = await load<typeof import('@/lib/ai')>('@/lib/ai', true)
+    expect(() => createClient({ provider: 'openai', apiKey: 'sk-real', model: 'gpt-4o' })).toThrow(
+      /disabled on the demo/
+    )
+    await expect(getAiConfig('org')).rejects.toThrow(/disabled on the demo/)
+  })
+
+  it('builds one as before off the demo', async () => {
+    const { createClient } = await load<typeof import('@/lib/ai')>('@/lib/ai', false)
+    expect(createClient({ provider: 'openai', apiKey: 'sk-real', model: 'gpt-4o' }).baseURL).toBe(
+      'https://api.openai.com/v1'
+    )
+  })
+})
+
+describe('settings a demo visitor cannot write', () => {
+  it('includes the licence, whose key the daily check would send to torqvoice.com', async () => {
+    const { isDemoBlockedSettingKey } = await import('@/lib/demo')
+    for (const key of ['license.key', 'license.token', 'license.valid', 'license.plan']) {
+      expect(isDemoBlockedSettingKey(key), key).toBe(true)
+    }
+    expect(isDemoBlockedSettingKey('workshop.name')).toBe(false)
+  })
+})
+
+describe('billing on torqvoice.com from the demo', () => {
+  it('never makes the request', async () => {
+    const fetchSpy = vi.spyOn(globalThis, 'fetch')
+    process.env.TORQVOICE_SERVICE_SECRET = 'a-secret-long-enough'
+    try {
+      const { billingRequest } = await load<typeof import('@/lib/torqvoice-com')>(
+        '@/lib/torqvoice-com',
+        true
+      )
+      await expect(billingRequest('sync', {})).rejects.toThrow(/disabled on the demo/)
+      expect(fetchSpy).not.toHaveBeenCalled()
+    } finally {
+      delete process.env.TORQVOICE_SERVICE_SECRET
+      fetchSpy.mockRestore()
+    }
+  })
+})

+ 64 - 0
src/__tests__/lib/files/manager.test.ts

@@ -36,6 +36,15 @@ vi.mock('@/lib/upload-root', () => ({
   uploadsRoots: () => [roots.current, roots.legacy],
 }))
 
+// Read on every call rather than once at import, so one test can switch the
+// manager into demo mode without reloading it.
+const demo = vi.hoisted(() => ({ on: false }))
+vi.mock('@/lib/demo', () => ({
+  get isDemoMode() {
+    return demo.on
+  },
+}))
+
 const inUse = vi.hoisted(() => ({
   suffixes: new Set<string>(),
   fail: false,
@@ -113,6 +122,7 @@ beforeEach(async () => {
   inUse.fail = false
   inUse.organizations = new Set([ORG, OTHER])
   inUse.later = new Set()
+  demo.on = false
   referencedSuffixes.mockClear()
   vi.spyOn(console, 'warn').mockImplementation(() => undefined)
   vi.spyOn(console, 'error').mockImplementation(() => undefined)
@@ -296,6 +306,60 @@ describe('releaseFiles', () => {
   })
 })
 
+describe('on the public demo', () => {
+  // The reset restores the database and never empties the trash, so a trash
+  // there is a disk one visitor can fill for everybody.
+  it('deletes a released file outright instead of trashing it', async () => {
+    demo.on = true
+    const current = await put(roots.current, ORG, 'services', PHOTO)
+    const legacy = await put(roots.legacy, ORG, 'services', PHOTO)
+
+    const result = await releaseFiles([url(ORG, 'services', PHOTO)], {
+      organizationId: ORG,
+      reason: 'test',
+      now: NOW,
+    })
+
+    expect(result.removed).toEqual([url(ORG, 'services', PHOTO)])
+    expect(existsSync(current)).toBe(false)
+    expect(existsSync(legacy)).toBe(false)
+    expect(existsSync(path.join(roots.current, '.trash'))).toBe(false)
+    expect(existsSync(path.join(roots.legacy, '.trash'))).toBe(false)
+  })
+
+  it('still keeps a file some row uses', async () => {
+    demo.on = true
+    const file = await put(roots.current, ORG, 'services', PHOTO)
+    inUse.suffixes.add(`/services/${PHOTO}`)
+
+    const result = await releaseFiles([url(ORG, 'services', PHOTO)], {
+      organizationId: ORG,
+      reason: 'test',
+      now: NOW,
+    })
+
+    expect(result.kept).toEqual([url(ORG, 'services', PHOTO)])
+    expect(existsSync(file)).toBe(true)
+  })
+
+  it('never deletes a symlink or a directory in its place', async () => {
+    demo.on = true
+    const outside = path.join(base, 'outside.jpg')
+    await writeFile(outside, 'not an upload')
+    await mkdir(path.join(roots.current, ORG, 'services'), { recursive: true })
+    await symlink(outside, path.join(roots.current, ORG, 'services', PHOTO))
+
+    const result = await releaseFiles([url(ORG, 'services', PHOTO)], {
+      organizationId: ORG,
+      reason: 'test',
+      now: NOW,
+    })
+
+    expect(result.removed).toEqual([])
+    expect(existsSync(outside)).toBe(true)
+  })
+})
+
 describe('discardUnsavedUpload', () => {
   it('removes the file an upload just wrote, and nothing outside its folder', async () => {
     const file = await put(roots.current, ORG, 'services', PHOTO)

+ 4 - 1
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -45,12 +45,15 @@ export function AccountSettings({
   emailVerified: initialEmailVerified,
   emailVerificationRequired,
   devices,
+  demoMode = false,
   signature,
 }: {
   twoFactorEnabled: boolean
   emailVerified: boolean
   emailVerificationRequired: boolean
   devices: SignedInDevice[]
+  /** The shared demo account: other visitors' sessions are hidden. */
+  demoMode?: boolean
   /** The caller's saved signature for this workshop, as a data URI. */
   signature: string | null
 }) {
@@ -454,7 +457,7 @@ export function AccountSettings({
         )}
       </AppCard>
 
-      <SignedInDevices devices={devices} />
+      <SignedInDevices devices={devices} demoMode={demoMode} />
 
       {/* 2FA Setup Dialog */}
       <Dialog

+ 2 - 0
src/app/(authenticated)/settings/account/page.tsx

@@ -1,6 +1,7 @@
 import { headers } from 'next/headers'
 import { auth } from '@/lib/auth'
 import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
 import { redirect } from 'next/navigation'
 import { AccountSettings } from './account-settings'
 import { listMyDevices } from '@/features/settings/Actions/sessionActions'
@@ -29,6 +30,7 @@ export default async function AccountSettingsPage() {
       emailVerified={user?.emailVerified ?? false}
       emailVerificationRequired={verificationSetting?.value === 'true'}
       devices={devices}
+      demoMode={isDemoMode}
       signature={signature.success ? (signature.data ?? null) : null}
     />
   )

+ 20 - 1
src/app/(authenticated)/settings/account/signed-in-devices.tsx

@@ -36,7 +36,18 @@ const ICONS: Record<DeviceKind, LucideIcon> = {
  * a way to end any of them. The browser looking at the page is marked and
  * cannot be ended from here; sign out does that.
  */
-export function SignedInDevices({ devices }: { devices: SignedInDevice[] }) {
+export function SignedInDevices({
+  devices,
+  demoMode = false,
+}: {
+  devices: SignedInDevice[]
+  /**
+   * The demo's one account is shared by every visitor, so the server sends
+   * only this browser's session there, and the card says why the rest are
+   * missing.
+   */
+  demoMode?: boolean
+}) {
   const t = useTranslations('settings')
   const format = useFormatter()
   const router = useRouter()
@@ -164,6 +175,14 @@ export function SignedInDevices({ devices }: { devices: SignedInDevice[] }) {
           )
         })}
       </ul>
+      {demoMode && (
+        <p
+          className="border-t border-border/60 px-6 py-3 text-xs text-muted-foreground"
+          data-testid="signed-in-devices-demo-note"
+        >
+          {t('account.devicesDemoHidden')}
+        </p>
+      )}
     </AppCard>
   )
 }

+ 5 - 0
src/app/api/protected/ai/chat/route.ts

@@ -13,6 +13,7 @@ import { getFeatures } from '@/lib/features'
 import { db } from '@/lib/db'
 import { completionTuning, createClient, getAiConfig } from '@/lib/ai'
 import { describeAiError } from '@/lib/ai-error'
+import { DEMO_AI_DISABLED_MESSAGE, isDemoMode } from '@/lib/demo'
 import { localeNames, type Locale } from '@/i18n/config'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
@@ -48,6 +49,10 @@ function json(status: number, error: string) {
  * the answer has finished: a failed call leaves no half chat behind.
  */
 export async function POST(request: NextRequest) {
+  // Refused before anything is read. The client refuses on the demo too, but
+  // this answer names the reason instead of failing somewhere in the stream.
+  if (isDemoMode) return json(403, DEMO_AI_DISABLED_MESSAGE)
+
   const ctx = await getAuthContext()
   if (!ctx) return json(401, 'Unauthorized')
   const { organizationId, userId } = ctx

+ 7 - 0
src/app/api/protected/ai/transcribe/live/route.ts

@@ -1,6 +1,7 @@
 import { createHash } from 'node:crypto'
 import { type NextRequest, NextResponse } from 'next/server'
 import { db } from '@/lib/db'
+import { DEMO_AI_DISABLED_MESSAGE, isDemoMode } from '@/lib/demo'
 import { rateLimit } from '@/lib/rate-limit'
 import { OPENAI_BASE } from '@/integrations/ai/models'
 import { dictationAccess } from '@/features/ai/Lib/dictationAccess'
@@ -33,6 +34,12 @@ const MAX_SDP_BYTES = 64 * 1024
  * through the ordinary route instead.
  */
 export async function POST(request: NextRequest) {
+  // This route talks to OpenAI with its own fetch rather than the shared
+  // client, so it refuses itself: speechSetup answering null on the demo would
+  // stop it too, but only by the accident of what comes next. The code tells
+  // the page to fall back to the ordinary route, which refuses as well.
+  if (isDemoMode) return json(403, DEMO_AI_DISABLED_MESSAGE, 'live-unavailable')
+
   const limited = rateLimit(request, { limit: 10, windowMs: 60_000 })
   if (limited) return limited
 

+ 4 - 0
src/app/api/protected/ai/transcribe/route.ts

@@ -6,6 +6,7 @@ import { rateLimit } from '@/lib/rate-limit'
 import { createClient } from '@/lib/ai'
 import { speechSetup } from '@/features/integrations/Lib/speech'
 import { describeAiError } from '@/lib/ai-error'
+import { DEMO_AI_DISABLED_MESSAGE, isDemoMode } from '@/lib/demo'
 import {
   MAX_TRANSCRIPTION_BYTES,
   transcriptionLanguage,
@@ -28,6 +29,9 @@ function json(status: number, error: string) {
  * once the person at the desk saves the job it was dictated into.
  */
 export async function POST(request: NextRequest) {
+  // A recording goes to a vendor on somebody's key; the demo sends none.
+  if (isDemoMode) return json(403, DEMO_AI_DISABLED_MESSAGE)
+
   // Live dictation asks every few seconds while somebody is talking, so the
   // budget is a talking person's, not a button's.
   const limited = rateLimit(request, { limit: 60, windowMs: 60_000 })

+ 12 - 1
src/app/api/protected/cleanup/status-reports/route.ts

@@ -2,12 +2,23 @@ import { NextResponse } from 'next/server'
 import { cleanupExpiredReports } from '@/features/status-reports/Actions/cleanupExpiredReports'
 import { getAuthContext } from '@/lib/get-auth-context'
 
+/**
+ * The manual twin of the cron route (api/v1/cron/cleanup-status-reports).
+ *
+ * The cleanup runs across every workshop on the instance, so it is the
+ * platform's to trigger, not any one workshop's: a super admin only. Being
+ * signed in was the whole check once, which let any account, and on the
+ * public demo anybody at all, delete other workshops' expired reports on
+ * demand. Nothing in the app calls this; the cron route is the scheduled path.
+ */
 export async function POST() {
-  // Require authentication - only admins/super admins should call this
   const auth = await getAuthContext()
   if (!auth) {
     return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
   }
+  if (!auth.isSuperAdmin) {
+    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+  }
 
   const result = await cleanupExpiredReports()
   return NextResponse.json(result)

+ 6 - 0
src/app/api/protected/subscription/upgrade-preview/route.ts

@@ -1,6 +1,7 @@
 import { NextResponse } from 'next/server'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
 import { billingErrorResponse, billingRequest } from '@/lib/torqvoice-com'
 
 type Preview = { amountDue: number; currency: string; prorationDate: number }
@@ -8,6 +9,11 @@ type Preview = { amountDue: number; currency: string; prorationDate: number }
 /** What moving from Pro to Enterprise costs today, prorated by Stripe. */
 export async function POST() {
   try {
+    // As its siblings: the demo has no subscription of anyone's to price.
+    if (isDemoMode) {
+      return NextResponse.json({ error: 'This action is disabled on the demo.' }, { status: 403 })
+    }
+
     // The active organisation from the session, and only its owners and
     // admins: this moves money and changes the plan.
     const ctx = await getAuthContext()

+ 7 - 2
src/app/api/protected/upload/email-image/route.ts

@@ -5,6 +5,7 @@ import path from 'path'
 import sharp from 'sharp'
 import { EMAIL_IMAGE_CATEGORY, EMAIL_IMAGE_MAX_WIDTH } from '@/features/email/Lib/emailTemplate'
 import { guardEmailUpload } from '@/features/email/Lib/emailUploadAccess.server'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
 
 /**
  * A picture for an image block.
@@ -31,6 +32,9 @@ export async function POST(request: Request) {
   if ('response' in guard) return guard.response
   const { ctx } = guard
 
+  const refused = guardUpload(request, MAX_UPLOAD_BYTES)
+  if (refused) return refused
+
   const formData = await request.formData()
   const file = formData.get('file')
   if (!(file instanceof File)) {
@@ -39,8 +43,9 @@ export async function POST(request: Request) {
   if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') {
     return NextResponse.json({ error: 'Upload a PNG, JPEG or WebP image' }, { status: 400 })
   }
-  if (file.size > MAX_UPLOAD_BYTES) {
-    return NextResponse.json({ error: 'File size must be under 10MB' }, { status: 400 })
+  const maxBytes = uploadLimit(MAX_UPLOAD_BYTES)
+  if (file.size > maxBytes) {
+    return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
   }
 
   try {

+ 9 - 2
src/app/api/protected/upload/email-logo/route.ts

@@ -6,6 +6,7 @@ import sharp from 'sharp'
 import { EMAIL_LOGO_CATEGORY, EMAIL_LOGO_MAX_WIDTH } from '@/features/email/Lib/emailTemplate'
 import { guardEmailUpload } from '@/features/email/Lib/emailUploadAccess.server'
 import { uploadsRoot } from '@/lib/upload-root'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
 
 /**
  * A logo for the email templates, uploaded on its own rather than borrowed
@@ -21,11 +22,16 @@ import { uploadsRoot } from '@/lib/upload-root'
 /** More pixels than this and the decode alone would eat a gigabyte; no email needs it. */
 const MAX_INPUT_PIXELS = 40_000_000
 
+const MAX_UPLOAD_BYTES = 4 * 1024 * 1024
+
 export async function POST(request: Request) {
   const guard = await guardEmailUpload(request)
   if ('response' in guard) return guard.response
   const { ctx } = guard
 
+  const refused = guardUpload(request, MAX_UPLOAD_BYTES)
+  if (refused) return refused
+
   const formData = await request.formData()
   const file = formData.get('file')
   if (!(file instanceof File)) {
@@ -34,8 +40,9 @@ export async function POST(request: Request) {
   if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') {
     return NextResponse.json({ error: 'Upload a PNG, JPEG or WebP image' }, { status: 400 })
   }
-  if (file.size > 4 * 1024 * 1024) {
-    return NextResponse.json({ error: 'File size must be under 4MB' }, { status: 400 })
+  const maxBytes = uploadLimit(MAX_UPLOAD_BYTES)
+  if (file.size > maxBytes) {
+    return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
   }
 
   try {

+ 9 - 2
src/app/api/protected/upload/inventory/route.ts

@@ -5,6 +5,9 @@ import { writeFile, mkdir } from 'fs/promises'
 import path from 'path'
 import crypto from 'crypto'
 import { uploadsRoot } from '@/lib/upload-root'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
+
+const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
 
 export async function POST(request: NextRequest) {
   try {
@@ -14,6 +17,9 @@ export async function POST(request: NextRequest) {
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
+    const refused = guardUpload(request, MAX_UPLOAD_BYTES)
+    if (refused) return refused
+
     const formData = await request.formData()
     const file = formData.get('file') as File | null
 
@@ -29,8 +35,9 @@ export async function POST(request: NextRequest) {
       )
     }
 
-    if (file.size > 5 * 1024 * 1024) {
-      return NextResponse.json({ error: 'File size must be under 5MB' }, { status: 400 })
+    const maxBytes = uploadLimit(MAX_UPLOAD_BYTES)
+    if (file.size > maxBytes) {
+      return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
     }
 
     const ext = extensionForType(file.type, 'jpg')

+ 9 - 2
src/app/api/protected/upload/logo/route.ts

@@ -5,6 +5,9 @@ import path from 'path'
 import { randomUUID } from 'crypto'
 import { cleanImage } from '@/lib/image-upload.server'
 import { uploadsRoot } from '@/lib/upload-root'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
+
+const MAX_UPLOAD_BYTES = 2 * 1024 * 1024
 
 export async function POST(request: Request) {
   try {
@@ -14,6 +17,9 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
+    const refused = guardUpload(request, MAX_UPLOAD_BYTES)
+    if (refused) return refused
+
     const formData = await request.formData()
     const file = formData.get('file') as File | null
 
@@ -25,8 +31,9 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'Upload a PNG, JPEG or WebP image' }, { status: 400 })
     }
 
-    if (file.size > 2 * 1024 * 1024) {
-      return NextResponse.json({ error: 'File size must be under 2MB' }, { status: 400 })
+    const maxBytes = uploadLimit(MAX_UPLOAD_BYTES)
+    if (file.size > maxBytes) {
+      return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
     }
 
     // Decoded and re-encoded: the stored file holds pixels and nothing else,

+ 10 - 3
src/app/api/protected/upload/portal-background/route.ts

@@ -5,6 +5,10 @@ import path from 'path'
 import { randomUUID } from 'crypto'
 import { cleanImage } from '@/lib/image-upload.server'
 import { uploadsRoot } from '@/lib/upload-root'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
+
+/** Backgrounds are allowed to be larger than logos. */
+const MAX_UPLOAD_BYTES = 8 * 1024 * 1024
 
 export async function POST(request: Request) {
   try {
@@ -14,6 +18,9 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
+    const refused = guardUpload(request, MAX_UPLOAD_BYTES)
+    if (refused) return refused
+
     const formData = await request.formData()
     const file = formData.get('file') as File | null
 
@@ -25,9 +32,9 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'Upload a PNG, JPEG or WebP image' }, { status: 400 })
     }
 
-    // Backgrounds are allowed to be larger than logos.
-    if (file.size > 8 * 1024 * 1024) {
-      return NextResponse.json({ error: 'File size must be under 8MB' }, { status: 400 })
+    const maxBytes = uploadLimit(MAX_UPLOAD_BYTES)
+    if (file.size > maxBytes) {
+      return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
     }
 
     // Decoded and re-encoded: the stored file holds pixels and nothing else,

+ 7 - 2
src/app/api/protected/upload/quote-files/route.ts

@@ -5,6 +5,7 @@ import { writeFile, mkdir } from 'fs/promises'
 import path from 'path'
 import crypto from 'crypto'
 import { uploadsRoot } from '@/lib/upload-root'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
 
 const ALLOWED_TYPES = [
   'image/jpeg',
@@ -28,6 +29,9 @@ export async function POST(request: NextRequest) {
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
+    const refused = guardUpload(request, MAX_SIZE)
+    if (refused) return refused
+
     const formData = await request.formData()
     const file = formData.get('file') as File | null
 
@@ -44,8 +48,9 @@ export async function POST(request: NextRequest) {
       )
     }
 
-    if (file.size > MAX_SIZE) {
-      return NextResponse.json({ error: 'File size must be under 10MB' }, { status: 400 })
+    const maxBytes = uploadLimit(MAX_SIZE)
+    if (file.size > maxBytes) {
+      return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
     }
 
     const ext = extensionForType(file.type)

+ 9 - 2
src/app/api/protected/upload/route.ts

@@ -5,6 +5,9 @@ import { writeFile, mkdir } from 'fs/promises'
 import path from 'path'
 import crypto from 'crypto'
 import { uploadsRoot } from '@/lib/upload-root'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
+
+const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
 
 export async function POST(request: NextRequest) {
   try {
@@ -14,6 +17,9 @@ export async function POST(request: NextRequest) {
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
+    const refused = guardUpload(request, MAX_UPLOAD_BYTES)
+    if (refused) return refused
+
     const formData = await request.formData()
     const file = formData.get('file') as File | null
 
@@ -29,8 +35,9 @@ export async function POST(request: NextRequest) {
       )
     }
 
-    if (file.size > 5 * 1024 * 1024) {
-      return NextResponse.json({ error: 'File size must be under 5MB' }, { status: 400 })
+    const maxBytes = uploadLimit(MAX_UPLOAD_BYTES)
+    if (file.size > maxBytes) {
+      return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
     }
 
     const ext = extensionForType(file.type, 'jpg')

+ 9 - 2
src/app/api/protected/upload/service-files/route.ts

@@ -8,6 +8,7 @@ import crypto from 'crypto'
 import { execFile } from 'child_process'
 import { promisify } from 'util'
 import { uploadsRoot } from '@/lib/upload-root'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
 
 const execFileAsync = promisify(execFile)
 
@@ -65,6 +66,11 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
   }
 
+  // Before the body is read: at 500MB, buffering first and checking after is
+  // the whole cost already paid, and a video is transcoded inline afterwards.
+  const refused = guardUpload(request, MAX_SIZE)
+  if (refused) return refused
+
   const formData = await request.formData()
   const file = formData.get('file') as File | null
 
@@ -79,8 +85,9 @@ export async function POST(request: NextRequest) {
     )
   }
 
-  if (file.size > MAX_SIZE) {
-    return NextResponse.json({ error: 'File size must be under 500MB' }, { status: 400 })
+  const maxBytes = uploadLimit(MAX_SIZE)
+  if (file.size > maxBytes) {
+    return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
   }
 
   const isVideo = VIDEO_TYPES.includes(file.type)

+ 7 - 2
src/app/api/protected/upload/tire-hotel/route.ts

@@ -5,6 +5,7 @@ import { writeFile, mkdir, stat } from 'fs/promises'
 import path from 'path'
 import crypto from 'crypto'
 import { uploadsRoot } from '@/lib/upload-root'
+import { guardUpload, uploadLimit, uploadTooLargeMessage } from '@/lib/upload-guard'
 
 /**
  * Photos and documents held against a stored tire set.
@@ -25,6 +26,9 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
   }
 
+  const refused = guardUpload(request, MAX_SIZE)
+  if (refused) return refused
+
   const formData = await request.formData()
   const file = formData.get('file') as File | null
 
@@ -39,8 +43,9 @@ export async function POST(request: NextRequest) {
     )
   }
 
-  if (file.size > MAX_SIZE) {
-    return NextResponse.json({ error: 'File size must be under 15MB' }, { status: 400 })
+  const maxBytes = uploadLimit(MAX_SIZE)
+  if (file.size > maxBytes) {
+    return NextResponse.json({ error: uploadTooLargeMessage(maxBytes) }, { status: 400 })
   }
 
   // A generated name, never the uploaded one: a file called ../../server.key

+ 7 - 0
src/app/api/protected/whatsapp/media/[messageId]/route.ts

@@ -1,6 +1,7 @@
 import { NextResponse } from 'next/server'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
 import { getWhatsappConfig } from '@/lib/whatsapp'
 
 /**
@@ -19,6 +20,12 @@ export async function GET(
     return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
   }
 
+  // The fetch goes to the vendor on the workshop's credentials, and the demo
+  // sends nothing to a vendor. Its seeded conversations carry no real media.
+  if (isDemoMode) {
+    return NextResponse.json({ error: 'Media is not available on the demo' }, { status: 403 })
+  }
+
   const { messageId } = await params
 
   const message = await db.whatsappMessage.findFirst({

+ 20 - 19
src/app/api/public/auth/[...all]/route.ts

@@ -1,6 +1,7 @@
 import { NextResponse } from 'next/server'
 import { auth } from '@/lib/auth'
 import { isDemoMode } from '@/lib/demo'
+import { isDemoBlockedAuthPath } from '@/lib/demo-auth-paths'
 import { limitAuthRequest } from '@/lib/auth-rate-limit'
 import { toNextJsHandler } from 'better-auth/next-js'
 import { db } from '@/lib/db'
@@ -16,16 +17,19 @@ const authAuditPrefixes = [
   '/api/public/auth/passkey',
 ]
 
-// better-auth's own endpoints bypass server actions, so demoGuard() never sees
-// them. Without this, a demo visitor can change the shared demo user's
-// password or enroll 2FA/passkeys on it, locking the demo for everyone until
-// the next reset.
-const demoBlockedPrefixes = [
-  '/api/public/auth/change-password',
-  '/api/public/auth/set-password',
-  '/api/public/auth/two-factor',
-  '/api/public/auth/passkey',
-]
+/**
+ * better-auth's own endpoints bypass server actions, so demoGuard() never sees
+ * them. Without this, a demo visitor could change the shared demo user's
+ * password, name or address, enroll 2FA or a passkey on it, or list and sign
+ * out every other visitor. The list is in lib/demo-auth-paths.ts.
+ */
+function refuseOnDemo(pathname: string): NextResponse | null {
+  if (!isDemoMode || !isDemoBlockedAuthPath(pathname)) return null
+  const error =
+    'This action is disabled on the demo. Install Torqvoice on your own server to use it.'
+  // `message` as well, which is what the better-auth client shows.
+  return NextResponse.json({ error, message: error }, { status: 403 })
+}
 
 function getRequestIp(request: Request): string | null {
   // Same precedence as lib/rate-limit.ts: Cloudflare's header cannot be forged
@@ -40,6 +44,10 @@ function getRequestIp(request: Request): string | null {
 
 /** OAuth callbacks arrive as GET and create sessions too. */
 async function GET(incoming: Request) {
+  // list-sessions is a GET, and on the demo it is every visitor's address.
+  const refused = refuseOnDemo(new URL(incoming.url).pathname)
+  if (refused) return refused
+
   const { request, issued } = withDeviceCookie(incoming)
   return attachDeviceCookie(await authGET(request), issued)
 }
@@ -50,15 +58,8 @@ async function POST(incoming: Request) {
   const { request, issued } = withDeviceCookie(incoming)
   const { pathname } = new URL(request.url)
 
-  if (isDemoMode && demoBlockedPrefixes.some((p) => pathname.startsWith(p))) {
-    return NextResponse.json(
-      {
-        error:
-          'This action is disabled on the demo. Install Torqvoice on your own server to use it.',
-      },
-      { status: 403 }
-    )
-  }
+  const refused = refuseOnDemo(pathname)
+  if (refused) return refused
 
   // The end-to-end suite signs in on nearly every test and loads the sign-in
   // page more often still, each load a passkey probe on the same prefix; its

+ 5 - 0
src/app/api/public/email-image/[orgId]/[file]/route.ts

@@ -2,6 +2,7 @@ import { readFile, stat } from 'fs/promises'
 import { NextResponse } from 'next/server'
 import path from 'path'
 import { EMAIL_IMAGE_CATEGORY } from '@/features/email/Lib/emailTemplate'
+import { isDemoMode } from '@/lib/demo'
 import { uploadsRoot } from '@/lib/upload-root'
 
 const MIME_TYPES: Record<string, string> = {
@@ -24,6 +25,10 @@ export async function GET(
   _request: Request,
   { params }: { params: Promise<{ orgId: string; file: string }> }
 ) {
+  // No mail leaves the demo, so nothing needs this there, and a public,
+  // immutable URL for whatever a visitor uploads is free image hosting.
+  if (isDemoMode) return NextResponse.json({ error: 'Not found' }, { status: 404 })
+
   const { orgId, file } = await params
   if (!/^[A-Za-z0-9_-]+$/.test(orgId) || !FILE_NAME.test(file)) {
     return NextResponse.json({ error: 'Not found' }, { status: 404 })

+ 5 - 0
src/app/api/public/email-logo/[orgId]/[file]/route.ts

@@ -2,6 +2,7 @@ import { readFile, stat } from 'fs/promises'
 import { NextResponse } from 'next/server'
 import path from 'path'
 import { EMAIL_LOGO_CATEGORY } from '@/features/email/Lib/emailTemplate'
+import { isDemoMode } from '@/lib/demo'
 import { uploadsRoot } from '@/lib/upload-root'
 
 const MIME_TYPES: Record<string, string> = {
@@ -24,6 +25,10 @@ export async function GET(
   _request: Request,
   { params }: { params: Promise<{ orgId: string; file: string }> }
 ) {
+  // No mail leaves the demo, so nothing needs this there, and a public,
+  // immutable URL for whatever a visitor uploads is free image hosting.
+  if (isDemoMode) return NextResponse.json({ error: 'Not found' }, { status: 404 })
+
   const { orgId, file } = await params
   if (!/^[A-Za-z0-9_-]+$/.test(orgId) || !FILE_NAME.test(file)) {
     return NextResponse.json({ error: 'Not found' }, { status: 404 })

+ 5 - 1
src/app/api/public/inspection-handoff/[token]/route.ts

@@ -8,6 +8,7 @@ import { discardUnsavedUpload } from '@/lib/files/manager'
 import { compressPhoto } from '@/lib/image-upload.server'
 import { PHOTO_HANDOFF_TTL_SECONDS, verifyInspectionHandoffToken } from '@/lib/photo-handoff'
 import { rateLimit } from '@/lib/rate-limit'
+import { refuseDeclaredOversize, uploadLimit } from '@/lib/upload-guard'
 import { uploadsRoot } from '@/lib/upload-root'
 import {
   INSPECTION_ATTACHMENT_LIMITS,
@@ -58,6 +59,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
   if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
   const { organizationId, inspectionId, userId, expiresAt } = check.handoff
 
+  // The declared size before the body is buffered, as on the work order's twin.
+  if (refuseDeclaredOversize(request, MAX_BYTES)) return refuse(400, 'tooLarge')
+
   const inspection = await db.inspection.findFirst({
     where: { id: inspectionId, organizationId },
     select: { id: true, status: true, vehicle: { select: { licensePlate: true } } },
@@ -70,7 +74,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
   const isPdf = file.type === PDF_TYPE
   if (!isPdf && !PHOTO_TYPES.has(file.type)) return refuse(400, 'type')
   if (file.size === 0) return refuse(400, 'empty')
-  if (file.size > (isPdf ? MAX_PDF_BYTES : MAX_BYTES)) return refuse(400, 'tooLarge')
+  if (file.size > uploadLimit(isPdf ? MAX_PDF_BYTES : MAX_BYTES)) return refuse(400, 'tooLarge')
 
   const itemField = form?.get('itemId')
   const itemId = typeof itemField === 'string' && itemField ? itemField : null

+ 5 - 0
src/app/api/public/logo/[orgId]/route.ts

@@ -1,6 +1,8 @@
 import { NextResponse } from 'next/server'
 import { svgDownloadHeaders } from '@/lib/upload-url'
 import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
+import { defaultLogoResponse } from '@/lib/default-logo'
 import { readFile, stat } from 'fs/promises'
 import path from 'path'
 import { resolvePortalOrg } from '@/lib/portal-slug'
@@ -15,6 +17,9 @@ const MIME_TYPES: Record<string, string> = {
 }
 
 export async function GET(_request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+  // Never a visitor's upload on the demo: see defaultLogoResponse.
+  if (isDemoMode) return defaultLogoResponse()
+
   const { orgId: orgParam } = await params
 
   // Accept either slug or UUID

+ 5 - 0
src/app/api/public/logo/route.ts

@@ -1,6 +1,8 @@
 import { NextResponse } from 'next/server'
 import { svgDownloadHeaders } from '@/lib/upload-url'
 import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
+import { defaultLogoResponse } from '@/lib/default-logo'
 import { readFile, stat } from 'fs/promises'
 import path from 'path'
 
@@ -13,6 +15,9 @@ const MIME_TYPES: Record<string, string> = {
 }
 
 export async function GET() {
+  // Never a visitor's upload on the demo: see defaultLogoResponse.
+  if (isDemoMode) return defaultLogoResponse()
+
   const logoSetting = await db.appSetting.findFirst({
     where: { key: 'workshop.logo' },
     select: { value: true, organizationId: true },

+ 6 - 1
src/app/api/public/photo-handoff/[token]/route.ts

@@ -10,6 +10,7 @@ import { compressPhoto } from '@/lib/image-upload.server'
 import { type DropoffSlot, isDropoffSlot } from '@/lib/dropoff-slots'
 import { PHOTO_HANDOFF_TTL_SECONDS, verifyPhotoHandoffToken } from '@/lib/photo-handoff'
 import { rateLimit } from '@/lib/rate-limit'
+import { refuseDeclaredOversize, uploadLimit } from '@/lib/upload-guard'
 import { uploadsRoot } from '@/lib/upload-root'
 
 /**
@@ -78,6 +79,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
   const { organizationId, serviceRecordId, concernId, purpose, userId, expiresAt } = check.handoff
   const dropoff = purpose === 'dropoff'
 
+  // The declared size before the body is buffered: the check on the file
+  // below only runs once the memory is already spent.
+  if (refuseDeclaredOversize(request, MAX_BYTES)) return refuse(400, 'tooLarge')
+
   const job = await db.serviceRecord.findFirst({
     where: { id: serviceRecordId, organizationId },
     select: { id: true, invoiceNumber: true },
@@ -101,7 +106,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
   if (dropoff && isPdf) return refuse(400, 'type')
   if (!isPdf && !PHOTO_TYPES.has(file.type)) return refuse(400, 'type')
   if (file.size === 0) return refuse(400, 'empty')
-  if (file.size > (isPdf ? MAX_PDF_BYTES : MAX_BYTES)) return refuse(400, 'tooLarge')
+  if (file.size > uploadLimit(isPdf ? MAX_PDF_BYTES : MAX_BYTES)) return refuse(400, 'tooLarge')
   const category = dropoff ? 'dropoff' : isPdf ? 'document' : 'image'
   // Which shot this is, from the fixed list and nothing else: the value is
   // stored and shown on the work order.

+ 5 - 0
src/app/api/public/portal-bg/[orgId]/route.ts

@@ -1,6 +1,7 @@
 import { NextResponse } from 'next/server'
 import { svgDownloadHeaders } from '@/lib/upload-url'
 import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
 import { readFile, stat } from 'fs/promises'
 import path from 'path'
 import { resolvePortalOrg } from '@/lib/portal-slug'
@@ -16,6 +17,10 @@ const MIME_TYPES: Record<string, string> = {
 }
 
 export async function GET(_request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+  // The same rule as the public logo: on the demo a background is whatever
+  // the last visitor uploaded, shown to strangers and cached past the reset.
+  if (isDemoMode) return NextResponse.json({ error: 'No background' }, { status: 404 })
+
   const { orgId: orgParam } = await params
 
   // Accept either slug or UUID

+ 11 - 2
src/app/api/v1/tech/jobs/[id]/attachments/route.ts

@@ -5,6 +5,7 @@ import { db } from '@/lib/db'
 import { getFeatures, type PlanFeatures } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { refuseDeclaredOversize, uploadLimit } from '@/lib/upload-guard'
 import { uploadsRoot } from '@/lib/upload-root'
 
 /**
@@ -61,6 +62,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       })
       if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
 
+      // Lowered on the demo, where anyone can sign the app in, and checked on
+      // the declared length before the body is buffered.
+      const maxBytes = uploadLimit(MAX_BYTES)
+      const tooLarge = `That file is too large. Keep it under ${Math.round(maxBytes / (1024 * 1024))} MB.`
+      if (refuseDeclaredOversize(request, MAX_BYTES)) {
+        return apiError(400, 'invalid_request', tooLarge)
+      }
+
       const form = await request.formData()
       const file = form.get('file')
       if (!(file instanceof File)) {
@@ -74,8 +83,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       if (file.size === 0) {
         return apiError(400, 'invalid_request', 'That file is empty.')
       }
-      if (file.size > MAX_BYTES) {
-        return apiError(400, 'invalid_request', 'That file is too large. Keep it under 60 MB.')
+      if (file.size > maxBytes) {
+        return apiError(400, 'invalid_request', tooLarge)
       }
 
       const category = file.type.startsWith('video/') ? 'video' : 'image'

+ 14 - 6
src/app/api/v1/tech/jobs/[id]/status-report/route.ts

@@ -6,6 +6,8 @@ import { db } from '@/lib/db'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { sendStatusReport } from '@/features/status-reports/Actions/sendStatusReport'
+import { isDemoMode } from '@/lib/demo'
+import { refuseDeclaredOversize, uploadLimit } from '@/lib/upload-guard'
 import { uploadsRoot } from '@/lib/upload-root'
 
 /**
@@ -58,6 +60,16 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       })
       if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
 
+      // Lowered on the demo, where anyone can sign the app in, and checked on
+      // the declared length before the body is buffered.
+      const maxBytes = uploadLimit(MAX_BYTES)
+      const tooLarge = isDemoMode
+        ? `That video is too large for the demo. Keep it under ${Math.round(maxBytes / (1024 * 1024))} MB.`
+        : 'That video is too long. Keep it under two minutes.'
+      if (refuseDeclaredOversize(request, MAX_BYTES)) {
+        return apiError(400, 'invalid_request', tooLarge)
+      }
+
       const form = await request.formData()
       const title = (form.get('title') as string | null)?.slice(0, 200) || undefined
       const message = (form.get('message') as string | null)?.slice(0, 4000) || undefined
@@ -72,12 +84,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
         if (!ext) {
           return apiError(400, 'invalid_request', 'That video format cannot be uploaded.')
         }
-        if (video.size > MAX_BYTES) {
-          return apiError(
-            400,
-            'invalid_request',
-            'That video is too long. Keep it under two minutes.'
-          )
+        if (video.size > maxBytes) {
+          return apiError(400, 'invalid_request', tooLarge)
         }
 
         // Generated name, never the client's. See the attachments route.

+ 14 - 2
src/features/audit/Actions/auditActions.ts

@@ -1,8 +1,20 @@
 'use server'
 
 import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
 import { withAuth } from '@/lib/with-auth'
 
+/**
+ * Every demo visitor signs in as the same user in the same workshop, so the
+ * audit log there is a list of strangers' addresses and browsers. The rows
+ * stay; who was where does not.
+ */
+function withoutVisitorDetails<T extends { ip: string | null; userAgent: string | null }>(
+  logs: T[]
+): T[] {
+  return isDemoMode ? logs.map((log) => ({ ...log, ip: null, userAgent: null })) : logs
+}
+
 export async function getRecentAuditLogs(limit = 10) {
   return withAuth(async ({ organizationId }) => {
     const logs = await db.auditLog.findMany({
@@ -11,7 +23,7 @@ export async function getRecentAuditLogs(limit = 10) {
       orderBy: { timestamp: 'desc' },
       take: limit,
     })
-    return logs
+    return withoutVisitorDetails(logs)
   })
 }
 
@@ -87,7 +99,7 @@ export async function getAuditLogsPaginated(params: {
     ])
 
     return {
-      logs,
+      logs: withoutVisitorDetails(logs),
       total,
       page,
       pageSize,

+ 7 - 0
src/features/integrations/Lib/ai.ts

@@ -13,6 +13,7 @@
  */
 
 import { db } from '@/lib/db'
+import { isDemoMode } from '@/lib/demo'
 import { isUniqueViolation, writeAdoptionMarker } from './adoption-marker'
 import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
 import { openCredentials, sealCredentials } from './vault'
@@ -212,6 +213,9 @@ async function unsealedFallback(
  * cue to raise its own "not configured" message.
  */
 export async function aiSetup(organizationId: string): Promise<AiSetup | null> {
+  // Never configured on the demo, whatever a reset or a visitor left in the
+  // table: see assertAiAllowed.
+  if (isDemoMode) return null
   const rows = await db.integrationConnection.findMany({
     where: {
       organizationId,
@@ -256,6 +260,8 @@ export async function aiSetup(organizationId: string): Promise<AiSetup | null> {
  * use, or when the catalog is opened.
  */
 export async function isAiConfigured(organizationId: string): Promise<boolean> {
+  // So the pages offer no AI button that could only fail.
+  if (isDemoMode) return false
   const rows = await db.integrationConnection.findMany({
     where: {
       organizationId,
@@ -276,6 +282,7 @@ export async function isAiConfigured(organizationId: string): Promise<boolean> {
  * `isAiConfigured` must not create a connection while rendering.
  */
 export async function configuredAiProvider(organizationId: string): Promise<AiConnectorId | null> {
+  if (isDemoMode) return null
   const rows = await db.integrationConnection.findMany({
     where: {
       organizationId,

+ 6 - 0
src/features/integrations/Lib/speech.ts

@@ -9,6 +9,7 @@
 
 import { db } from '@/lib/db'
 import { isCloudInstance } from '@/lib/cloud-instance'
+import { isDemoMode } from '@/lib/demo'
 import { canTranscribe, transcriptionModel } from '@/features/ai/Lib/transcription'
 import { aiSetup, configuredAiProvider } from './ai'
 import { openCredentials } from './vault'
@@ -54,6 +55,8 @@ async function speechConnection(organizationId: string) {
 export async function configuredDictation(
   organizationId: string
 ): Promise<{ available: boolean; mode: DictationMode }> {
+  // The mic falls back to the browser's own recognition, which never leaves it.
+  if (isDemoMode) return { available: false, mode: 'choice' }
   const connection = await speechConnection(organizationId)
   if (connection) return { available: true, mode: dictationModeOf(connection.settings) }
   const provider = await configuredAiProvider(organizationId)
@@ -62,6 +65,9 @@ export async function configuredDictation(
 
 /** The keys and the model, for the one request that sends a recording. */
 export async function speechSetup(organizationId: string): Promise<SpeechSetup | null> {
+  // A recording is somebody's voice, sent to a vendor on somebody's key; the
+  // demo has neither to offer. See assertAiAllowed.
+  if (isDemoMode) return null
   const connection = await speechConnection(organizationId)
   if (connection) {
     const credentials = openCredentials(connection.credentials)

+ 7 - 1
src/features/portal/Components/CustomerPortalSettings.tsx

@@ -364,9 +364,15 @@ export function CustomerPortalSettings({
                 <div className="flex items-center gap-4">
                   <div className="relative h-24 w-40 shrink-0 overflow-hidden rounded-lg border bg-muted">
                     {backgroundImage ? (
+                      // The uploaded file itself when it is one, read with the
+                      // staff session: the public route refuses on the demo.
                       // eslint-disable-next-line @next/next/no-img-element
                       <img
-                        src={`/api/public/portal-bg/${portalParam}?v=${encodeURIComponent(backgroundImage)}`}
+                        src={
+                          backgroundImage.startsWith('/api/protected/files/')
+                            ? backgroundImage
+                            : `/api/public/portal-bg/${portalParam}?v=${encodeURIComponent(backgroundImage)}`
+                        }
                         alt=""
                         className="h-full w-full object-cover"
                       />

+ 10 - 2
src/features/settings/Actions/sessionActions.ts

@@ -5,7 +5,7 @@ import { getCachedSession } from '@/lib/cached-session'
 import { db } from '@/lib/db'
 import { classifyUserAgent, describeUserAgent, type DeviceKind } from '@/lib/known-devices'
 import { logAudit } from '@/lib/audit'
-import { demoGuard } from '@/lib/demo'
+import { demoGuard, isDemoMode } from '@/lib/demo'
 import { z } from 'zod'
 
 export interface SignedInDevice {
@@ -22,12 +22,20 @@ export interface SignedInDevice {
  * The account's open sessions, newest first, as devices a person can
  * recognise. Self-scoped by construction: the session decides the user, and
  * every query below carries that user id.
+ *
+ * On the demo that is not enough: every visitor signs in as the same user,
+ * so "the account's sessions" is every visitor's address and browser. There
+ * the list is the caller's own session and nothing else.
  */
 export async function listMyDevices(): Promise<SignedInDevice[]> {
   const session = await getCachedSession()
   if (!session?.user?.id) return []
   const rows = await db.session.findMany({
-    where: { userId: session.user.id, expiresAt: { gt: new Date() } },
+    where: {
+      userId: session.user.id,
+      expiresAt: { gt: new Date() },
+      ...(isDemoMode ? { id: session.session.id } : {}),
+    },
     orderBy: { updatedAt: 'desc' },
     select: { id: true, userAgent: true, ipAddress: true, createdAt: true, updatedAt: true },
   })

+ 7 - 0
src/lib/ai.ts

@@ -3,6 +3,7 @@ import { aiSetup } from '@/features/integrations/Lib/ai'
 import { localeNames, type Locale } from '@/i18n/config'
 import OpenAI from 'openai'
 import { describeAiError } from '@/lib/ai-error'
+import { assertAiAllowed } from '@/lib/demo'
 import {
   ANTHROPIC_BASE,
   ANTHROPIC_VERSION,
@@ -24,6 +25,9 @@ interface AiConfig {
  * there, adopted into one on this first call.
  */
 export async function getAiConfig(organizationId: string): Promise<AiConfig> {
+  // Said plainly rather than as "not connected", which would send a demo
+  // visitor off to a settings page that refuses them.
+  assertAiAllowed()
   const setup = await aiSetup(organizationId)
 
   if (!setup) {
@@ -44,6 +48,9 @@ export async function getAiConfig(organizationId: string): Promise<AiConfig> {
  * would quietly go somewhere the connector's key check never looked.
  */
 export function createClient(config: AiConfig): OpenAI {
+  // The one client every completion and transcription is built from, so the
+  // last place a demo request could still reach a vendor.
+  assertAiAllowed()
   if (config.provider === 'anthropic') {
     return new OpenAI({
       apiKey: config.apiKey,

+ 23 - 0
src/lib/default-logo.ts

@@ -0,0 +1,23 @@
+import { readFile } from 'node:fs/promises'
+import path from 'node:path'
+import { NextResponse } from 'next/server'
+
+/**
+ * The app's own mark, as the public logo routes answer on the demo.
+ *
+ * Those routes are unauthenticated and publicly cacheable for an hour, and on
+ * the demo whatever logo a visitor last uploaded would be the first thing the
+ * next stranger sees on the sign-in page, cached at the edge past the reset
+ * that removes it. The demo workshop has no logo of its own (the seed clears
+ * it), so the default mark is also the honest answer.
+ */
+export async function defaultLogoResponse(): Promise<NextResponse> {
+  try {
+    const buffer = await readFile(path.join(process.cwd(), 'public', 'torqvoice_app_logo.png'))
+    return new NextResponse(new Uint8Array(buffer), {
+      headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' },
+    })
+  } catch {
+    return NextResponse.json({ error: 'No logo' }, { status: 404 })
+  }
+}

+ 51 - 0
src/lib/demo-auth-paths.ts

@@ -0,0 +1,51 @@
+/**
+ * better-auth endpoints the public demo refuses.
+ *
+ * They are reached straight from the browser, past every server action, so
+ * `demoGuard()` never sees them. The demo has one account that every visitor
+ * signs in as; each endpoint here either changes that account for everybody,
+ * reaches the other visitors' sessions, or sends mail. Sign-in, sign-out and
+ * get-session are not here: without them there is no demo.
+ *
+ * Paths are better-auth 1.6's, under this app's `/api/public/auth` base. A
+ * prefix covers what hangs under it (`/delete-user/callback`,
+ * `/reset-password/:token`, every `/two-factor/...` and `/passkey/...`).
+ */
+const AUTH_BASE = '/api/public/auth'
+
+const DEMO_BLOCKED_AUTH_PATHS = [
+  // Locking the account: a new password, a second factor or a passkey on it
+  // would keep everybody else out until the next reset.
+  '/change-password',
+  '/set-password',
+  '/request-password-reset',
+  '/reset-password',
+  '/two-factor',
+  '/passkey',
+  // The account itself: its name and address are what every visitor sees,
+  // and deleting it ends the demo.
+  '/update-user',
+  '/change-email',
+  '/delete-user',
+  '/send-verification-email',
+  // Accounts linked to it at another provider, and their tokens.
+  '/link-social',
+  '/unlink-account',
+  '/list-accounts',
+  '/account-info',
+  '/get-access-token',
+  '/refresh-token',
+  // The other visitors' sessions: listing them hands out their addresses and
+  // browsers, and revoking them signs strangers out.
+  '/list-sessions',
+  '/revoke-session',
+  '/revoke-sessions',
+  '/revoke-other-sessions',
+].map((path) => `${AUTH_BASE}${path}`)
+
+/** Whether the demo refuses this better-auth request path, whatever its method. */
+export function isDemoBlockedAuthPath(pathname: string): boolean {
+  return DEMO_BLOCKED_AUTH_PATHS.some(
+    (blocked) => pathname === blocked || pathname.startsWith(`${blocked}/`)
+  )
+}

+ 21 - 0
src/lib/demo.ts

@@ -67,6 +67,11 @@ export function assertConnectorAllowed(): void {
  * shouldn't be able to paste real API keys into a shared demo DB.
  */
 const DEMO_BLOCKED_SETTING_KEY_PATTERNS: RegExp[] = [
+  // The licence page refuses on the demo, but setSettings takes any key: a key
+  // pasted here would be sent to torqvoice.com by the daily check, and the
+  // cached plan and validity rows are what a self-hosted install's plan is
+  // read from. None of them is anything a visitor has a reason to write.
+  /^license\./,
   /^payment\.(stripe|vipps|paypal)\./,
   /^payment\.providersEnabled$/,
   // Each provider's own settings action already refuses in demo mode, but
@@ -103,3 +108,19 @@ export function demoGuardSettingKey(key: string): void {
     throw new Error("This setting can't be changed on the demo.")
   }
 }
+
+/** What a refused AI or dictation request is told on the demo. */
+export const DEMO_AI_DISABLED_MESSAGE =
+  'AI is disabled on the demo. Install Torqvoice on your own server to connect a provider.'
+
+/**
+ * Hard stop for the AI and speech vendors. Connecting one is refused on the
+ * demo already, so no key should exist there; this is for the one that does,
+ * because every prompt carries somebody's workshop data and every call is
+ * billed to whoever owns the key. The setup lookups report "not configured"
+ * so the pages hide their AI buttons, and the client itself refuses to be
+ * built, so nothing reaches a vendor whichever path asks.
+ */
+export function assertAiAllowed(): void {
+  if (isDemoMode) throw new Error(DEMO_AI_DISABLED_MESSAGE)
+}

+ 7 - 1
src/lib/files/manager.ts

@@ -3,6 +3,7 @@ import 'server-only'
 import type { Dirent } from 'node:fs'
 import { constants, copyFile, lstat, mkdir, readdir, rename, rm, unlink } from 'node:fs/promises'
 import path from 'node:path'
+import { isDemoMode } from '@/lib/demo'
 import { UPLOAD_CATEGORIES } from '@/lib/upload-url'
 import { uploadsRoot, uploadsRoots } from '@/lib/upload-root'
 import {
@@ -37,7 +38,8 @@ import {
  *    `TRASH_DAYS` later (purgeTrash), so a mistake, a race or a stale save is
  *    still recoverable. The trash is outside every workshop's folder, so it is
  *    never served and never part of a workshop's own export; the server's
- *    backup of the data folder does include it until it is purged.
+ *    backup of the data folder does include it until it is purged. On the
+ *    public demo a released file is deleted outright instead (see trashFile).
  *
  * Both upload roots are cleaned (see lib/upload-root.ts), because files have
  * been written under each and a file under either would still be served.
@@ -117,6 +119,10 @@ async function taken(target: string): Promise<boolean> {
  * reached by copy and delete, since a rename cannot cross disks.
  */
 async function trashFile(root: string, target: string, file: StoredFile, now: number) {
+  // The demo's reset restores the database but never empties the trash, so
+  // there a trash is only a place for one visitor to fill the disk that every
+  // other visitor shares. Nothing on the demo is anybody's to recover.
+  if (isDemoMode) return deleteFile(target)
   if (!(await isPlainFile(target))) return false
   const day = new Date(now).toISOString().slice(0, 10)
   const dir = path.join(path.resolve(root), TRASH, day, file.organizationId, file.folder)

+ 7 - 1
src/lib/rate-limit.ts

@@ -88,9 +88,15 @@ export function rateLimit(
     limit = 30,
     windowMs = 60_000,
     anonymous = false,
+    bucket,
   }: {
     limit?: number
     windowMs?: number
+    /**
+     * A separate count for a second limit on the same route. Two calls on one
+     * request otherwise share an entry and each request is counted twice.
+     */
+    bucket?: string
     /**
      * Key on the address alone, for endpoints that do not require a session.
      *
@@ -120,7 +126,7 @@ export function rateLimit(
     : `ip:${ip}`
 
   const url = new URL(request.url)
-  const key = `${identity}:${url.pathname}`
+  const key = bucket ? `${identity}:${url.pathname}:${bucket}` : `${identity}:${url.pathname}`
 
   const now = Date.now()
   const entry = store.get(key)

+ 9 - 0
src/lib/torqvoice-com.ts

@@ -1,5 +1,6 @@
 import { createHmac, randomBytes } from 'node:crypto'
 import { NextResponse } from 'next/server'
+import { isDemoMode } from './demo'
 import { isCloudMode } from './features'
 
 /**
@@ -184,6 +185,14 @@ export async function billingRequest<T>(
   body: Record<string, unknown>,
   timeoutMs = 20_000
 ): Promise<T> {
+  // The routes and actions that bill refuse on the demo, but the daily sync,
+  // the account-link ping and account deletion reach this on their own. The
+  // demo's workshop has no subscription on torqvoice.com, and no secret of
+  // this deployment's should be spent asking about one.
+  // No upstream status: the site was never asked, so nothing reads this as the
+  // site refusing the app's secret.
+  if (isDemoMode) throw new TorqvoiceComError('Billing is disabled on the demo.', 403, 0)
+
   const secret = serviceSecret()
   if (!secret) throw new TorqvoiceComError('Billing is not configured', 500)
 

+ 71 - 0
src/lib/upload-guard.ts

@@ -0,0 +1,71 @@
+import { NextResponse } from 'next/server'
+import { isDemoMode } from '@/lib/demo'
+import { rateLimit } from '@/lib/rate-limit'
+
+/**
+ * The checks every upload route makes before it reads a byte of the body.
+ *
+ * `request.formData()` buffers the whole request, so a size check on the file
+ * afterwards comes too late: the memory is already spent. The declared length
+ * is checked first, and the caller's pace is capped. On the public demo, where
+ * anybody can sign in, uploads are also capped far lower, since a video is
+ * transcoded inline and every upload lands on a host other deploys share.
+ */
+
+/** Largest upload the public demo takes, whatever the route allows elsewhere. */
+export const DEMO_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
+
+/** Uploads per minute, per caller and route. A drop-off walk round is a few dozen photos. */
+const UPLOADS_PER_MINUTE = isDemoMode ? 20 : 120
+
+/** Multipart framing around the file itself. */
+const FORM_OVERHEAD_BYTES = 64 * 1024
+
+/** The route's own limit, lowered on the demo. */
+export function uploadLimit(routeMaxBytes: number): number {
+  return isDemoMode ? Math.min(routeMaxBytes, DEMO_MAX_UPLOAD_BYTES) : routeMaxBytes
+}
+
+/**
+ * What a refused upload is told, naming the limit that actually applied. On
+ * the demo that is the lowered one, so a route's own "under 500MB" would be
+ * a lie there.
+ */
+export function uploadTooLargeMessage(maxBytes: number): string {
+  return `File size must be under ${Math.round(maxBytes / (1024 * 1024))}MB`
+}
+
+/**
+ * The size half of `guardUpload` alone, for a route that already limits its
+ * callers its own way (the public handoff routes key on the address only).
+ */
+export function refuseDeclaredOversize(
+  request: Request,
+  routeMaxBytes: number
+): NextResponse | null {
+  const max = uploadLimit(routeMaxBytes)
+  // A missing header reads as 0 here, which passes; the route's own check on
+  // the file after reading still holds it to the same limit.
+  const declared = Number(request.headers.get('content-length'))
+  if (Number.isFinite(declared) && declared > max + FORM_OVERHEAD_BYTES) {
+    return NextResponse.json({ error: uploadTooLargeMessage(max) }, { status: 413 })
+  }
+  return null
+}
+
+/**
+ * Null when the upload may be read, or the response that refuses it. Call it
+ * before `request.formData()`, with the route's own limit.
+ */
+export function guardUpload(request: Request, routeMaxBytes: number): NextResponse | null {
+  // Its own bucket, so a route that also limits its callers (the email
+  // uploads do) does not count every request twice against one entry.
+  const limited = rateLimit(request, {
+    limit: UPLOADS_PER_MINUTE,
+    windowMs: 60_000,
+    bucket: 'upload',
+  })
+  if (limited) return limited
+
+  return refuseDeclaredOversize(request, routeMaxBytes)
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов