Bernt Christian Egeland 2 týždňov pred
rodič
commit
b68fcd288d

+ 12 - 0
.github/workflows/deploy-prod.yml

@@ -46,6 +46,9 @@ jobs:
           # themselves live in this app's database and are edited from the
           # torqvoice.com admin, so they are no longer passed here.
           TORQVOICE_SERVICE_SECRET: ${{ secrets.TORQVOICE_SERVICE_SECRET }}
+          # Signed by torqvoice.com for APP_URL (scripts/mint-cloud-token.mjs there).
+          # Without it the app ignores TORQVOICE_MODE=cloud and runs self-hosted.
+          TORQVOICE_CLOUD_TOKEN: ${{ secrets.CLOUD_TORQVOICE_CLOUD_TOKEN }}
           DATA_PATH: ${{ secrets.DATA_PATH }}
           POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
@@ -94,6 +97,7 @@ jobs:
                 # Without this every visitor shares the edge's rate-limit bucket.
                 TRUST_CF_CONNECTING_IP: "true"
                 TORQVOICE_SERVICE_SECRET: ${TORQVOICE_SERVICE_SECRET}
+                TORQVOICE_CLOUD_TOKEN: ${TORQVOICE_CLOUD_TOKEN}
                 TORQVOICE_COM_URL: https://torqvoice.com
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 POSTHOG_KEY: ${POSTHOG_KEY}
@@ -113,6 +117,14 @@ jobs:
                 - proxy
           COMPOSE
 
+          # Without the cloud token the new container would come up as a
+          # self-hosted install: one workshop for everybody, branding back on.
+          # Stop here, before anything is replaced.
+          if [ -z "$TORQVOICE_CLOUD_TOKEN" ]; then
+            echo "::error title=Cloud token missing::CLOUD_TORQVOICE_CLOUD_TOKEN is empty. Mint one for $APP_URL with scripts/mint-cloud-token.mjs in the torqvoice.com repo."
+            exit 1
+          fi
+
           # The link to torqvoice.com is what the subscription pages hang on. A
           # wrong secret or an origin the site does not list only shows up as a
           # missing page, so the deploy asks the site itself and says so.

+ 12 - 0
.github/workflows/deploy-staging.yml

@@ -30,6 +30,9 @@ jobs:
           # themselves live in this app's database and are edited from the
           # torqvoice.com admin, so they are no longer passed here.
           TORQVOICE_SERVICE_SECRET: ${{ secrets.TORQVOICE_SERVICE_SECRET }}
+          # Signed by torqvoice.com for APP_URL (scripts/mint-cloud-token.mjs there).
+          # Without it the app ignores TORQVOICE_MODE=cloud and runs self-hosted.
+          TORQVOICE_CLOUD_TOKEN: ${{ secrets.STAGING_TORQVOICE_CLOUD_TOKEN }}
           DATA_PATH: ${{ secrets.DATA_PATH }}
           POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
@@ -76,6 +79,7 @@ jobs:
                 # Staging sits behind Cloudflare like production; see deploy-prod.yml.
                 TRUST_CF_CONNECTING_IP: "true"
                 TORQVOICE_SERVICE_SECRET: ${TORQVOICE_SERVICE_SECRET}
+                TORQVOICE_CLOUD_TOKEN: ${TORQVOICE_CLOUD_TOKEN}
                 TORQVOICE_COM_URL: https://torqvoice.com
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
@@ -94,6 +98,14 @@ jobs:
                 - proxy
           COMPOSE
 
+          # Without the cloud token the new container would come up as a
+          # self-hosted install: one workshop for everybody, branding back on.
+          # Stop here, before anything is replaced.
+          if [ -z "$TORQVOICE_CLOUD_TOKEN" ]; then
+            echo "::error title=Cloud token missing::STAGING_TORQVOICE_CLOUD_TOKEN is empty. Mint one for $APP_URL with scripts/mint-cloud-token.mjs in the torqvoice.com repo."
+            exit 1
+          fi
+
           # The link to torqvoice.com is what the subscription pages hang on. A
           # wrong secret or an origin the site does not list only shows up as a
           # missing page, so the deploy asks the site itself and says so.

+ 30 - 2
.github/workflows/e2e.yml

@@ -58,11 +58,30 @@ jobs:
       # before it does; both come from playwright.config.ts.
       E2E_DATABASE_URL: postgresql://torqvoice:torqvoice@127.0.0.1:5432/torqvoice_e2e
       E2E_MODE: ${{ matrix.mode }}
+      # The cloud job mints a one-day cloud token from it; without the token the
+      # app ignores TORQVOICE_MODE=cloud. Only the cloud job gets it.
+      TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY: ${{ matrix.mode == 'cloud' && secrets.TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY || '' }}
 
     steps:
+      # A pull request from a fork gets no secrets, so the cloud job has no key
+      # to mint its cloud token with and the app would refuse cloud mode. That
+      # says nothing about the contribution, so the job skips instead of failing.
+      # The self-hosted shards need no key and always run.
+      - name: Check for the cloud signing key
+        id: gate
+        run: |
+          if [ "${{ matrix.mode }}" = "cloud" ] && [ -z "$TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY" ]; then
+            echo "::notice title=Cloud specs skipped::No TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY here (a fork or Dependabot pull request), so the cloud specs cannot run."
+            echo "run=false" >> "$GITHUB_OUTPUT"
+          else
+            echo "run=true" >> "$GITHUB_OUTPUT"
+          fi
+
       - uses: actions/checkout@v4
+        if: ${{ steps.gate.outputs.run == 'true' }}
 
       - uses: actions/setup-node@v4
+        if: ${{ steps.gate.outputs.run == 'true' }}
         with:
           node-version: 22
           cache: npm
@@ -70,21 +89,26 @@ jobs:
       # A fresh one per run: the sessions it signs live as long as the job, and
       # a short or guessable value makes better-auth warn on every request.
       - name: Make a session secret for this run
+        if: ${{ steps.gate.outputs.run == 'true' }}
         run: echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)" >> "$GITHUB_ENV"
 
       - run: npm ci
+        if: ${{ steps.gate.outputs.run == 'true' }}
 
       - run: npx prisma generate
+        if: ${{ steps.gate.outputs.run == 'true' }}
 
       # Keyed on the pinned version, because the image tag and this download
       # have to be the same build.
       - name: Cache the browser
+        if: ${{ steps.gate.outputs.run == 'true' }}
         uses: actions/cache@v4
         with:
           path: ~/.cache/ms-playwright
           key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
 
       - name: Install the browser
+        if: ${{ steps.gate.outputs.run == 'true' }}
         run: npx playwright install --with-deps chromium
 
       # The seed copies bundled vehicle photos when they are in the tree and
@@ -92,6 +116,7 @@ jobs:
       # another, which is minutes of a cold run and the flakiest thing in it.
       # Cached, a warm run copies them off disk instead.
       - name: Cache the seed's photos
+        if: ${{ steps.gate.outputs.run == 'true' }}
         uses: actions/cache@v4
         with:
           # Where prepare-db.ts points the seed's DATA_ROOT, so a test run
@@ -105,6 +130,7 @@ jobs:
       # Every job reads it; only the first shard writes it back, so five jobs
       # do not race to save the same entry.
       - name: Restore the build cache
+        if: ${{ steps.gate.outputs.run == 'true' }}
         uses: actions/cache/restore@v4
         with:
           path: .next/cache
@@ -117,24 +143,26 @@ jobs:
       # refuses a sign-in from an origin the build was not made for, so it has
       # to match the base URL the suite uses.
       - name: Build
+        if: ${{ steps.gate.outputs.run == 'true' }}
         run: npm run build
         env:
           NEXT_PUBLIC_APP_URL: http://127.0.0.1:3100
 
       - name: Save the build cache
-        if: ${{ matrix.id == 'shard-1' }}
+        if: ${{ steps.gate.outputs.run == 'true' && (matrix.id == 'shard-1') }}
         uses: actions/cache/save@v4
         with:
           path: .next/cache
           key: next-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
 
       - name: Run the specs
+        if: ${{ steps.gate.outputs.run == 'true' }}
         run: npx playwright test ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}
 
       # The blob carries the results with their traces, screenshots and videos;
       # the report job turns every job's blob into one HTML report.
       - name: Upload the blob report
-        if: ${{ !cancelled() }}
+        if: ${{ steps.gate.outputs.run == 'true' && (!cancelled()) }}
         uses: actions/upload-artifact@v4
         with:
           name: blob-report-${{ matrix.id }}

+ 1 - 0
e2e/README.md

@@ -187,6 +187,7 @@ so parity checks compare both.
 | `E2E_SMTP_PORT` | `1025` | Where the mail sink listens for the app |
 | `E2E_MAIL_API_PORT` | `8025` | Where the mail sink answers the specs |
 | `E2E_MAIL_API` | `http://127.0.0.1:8025` | The sink a spec reads from, when it is not the local one |
+| `TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY` | required for `E2E_MODE=cloud` | the torqvoice.com licence signing key (the real one, not a test key); the cloud run mints a one-day cloud token with it, since the app ignores `TORQVOICE_MODE=cloud` without one |
 
 The suite's own server also runs with `TORQVOICE_MODE=self-hosted`, `DEMO_MODE=false` and
 `AUTH_RATE_LIMIT=off`. Pointed at another server, start it the same way or the plan

+ 29 - 0
playwright.config.ts

@@ -1,3 +1,4 @@
+import { createPrivateKey, sign } from 'node:crypto'
 import { resolve } from 'node:path'
 import { defineConfig, devices } from '@playwright/test'
 
@@ -56,6 +57,33 @@ const paymentSink = {
  */
 const cloud = process.env.E2E_MODE === 'cloud'
 
+/**
+ * Cloud mode needs a token torqvoice.com signed for the app's URL, or the app
+ * ignores TORQVOICE_MODE and runs self-hosted (see src/lib/cloud-instance.ts).
+ * The run mints its own from the real signing key, in TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY
+ * (a repository secret in CI), bound to the base URL and dead after a day, so
+ * a copy that leaks out of a run is worth nothing for long. There is no test
+ * key the app would accept instead: that would be a second way in.
+ */
+function mintCloudToken(): string {
+  const raw = process.env.TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY?.trim()
+  if (!raw) {
+    throw new Error(
+      'E2E_MODE=cloud needs TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY (the torqvoice.com licence signing key) to mint a cloud token for the run.'
+    )
+  }
+  const key = createPrivateKey({ key: Buffer.from(raw, 'base64'), format: 'der', type: 'pkcs8' })
+  const now = Date.now()
+  const payload = {
+    v: 1,
+    origin: new URL(baseURL).origin,
+    issuedAt: new Date(now).toISOString(),
+    expiresAt: new Date(now + 24 * 60 * 60 * 1000).toISOString(),
+  }
+  const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
+  return `tvc1.${encoded}.${sign(null, Buffer.from(encoded), key).toString('base64url')}`
+}
+
 /** Where the Google stand-in listens, for the app's server and for the specs. */
 const googlePort = process.env.E2E_GOOGLE_PORT ?? '8027'
 const googleStandinUrl = `http://127.0.0.1:${googlePort}`
@@ -204,6 +232,7 @@ export default defineConfig({
                   GOOGLE_AUTH_CLIENT_SECRET: 'e2e-google-secret',
                   E2E_GOOGLE_STANDIN_URL: googleStandinUrl,
                   NODE_OPTIONS: `--import=${resolve('e2e/google-standin-preload.mjs')}`,
+                  TORQVOICE_CLOUD_TOKEN: mintCloudToken(),
                   // Plans are sold on torqvoice.com; the stand-in plays it.
                   // The link check forgets its answer after a second instead of
                   // an hour or two minutes, so a spec can flip the stand-in's answer.

+ 6 - 0
src/__tests__/api/subscription-account-link.test.ts

@@ -1,5 +1,11 @@
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
 
+// Cloud mode also needs a signed token (see cloud-instance.test.ts); here the
+// environment variable alone stands for a confirmed cloud instance.
+vi.mock('@/lib/cloud-instance', () => ({
+  isCloudInstance: () => process.env.TORQVOICE_MODE === 'cloud',
+}))
+
 vi.mock('@/lib/get-auth-context', () => ({ getAuthContext: vi.fn() }))
 vi.mock('@/lib/db', () => ({ db: { user: { findUnique: vi.fn() } } }))
 

+ 6 - 0
src/__tests__/api/subscription-checkout.test.ts

@@ -1,5 +1,11 @@
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
 
+// Cloud mode also needs a signed token (see cloud-instance.test.ts); here the
+// environment variable alone stands for a confirmed cloud instance.
+vi.mock('@/lib/cloud-instance', () => ({
+  isCloudInstance: () => process.env.TORQVOICE_MODE === 'cloud',
+}))
+
 vi.mock('@/lib/get-auth-context', () => ({
   getAuthContext: vi.fn(),
 }))

+ 6 - 0
src/__tests__/features/integrations/registry.test.ts

@@ -8,6 +8,12 @@ import {
 } from '@/features/integrations/Lib/inspection-sync'
 import { getConnector, getManifest, listManifests } from '@/integrations/registry'
 
+// Cloud mode also needs a signed token (see cloud-instance.test.ts); here the
+// environment variable alone stands for a confirmed cloud instance.
+vi.mock('@/lib/cloud-instance', () => ({
+  isCloudInstance: () => process.env.TORQVOICE_MODE === 'cloud',
+}))
+
 const ROOT = process.cwd()
 const messages = JSON.parse(
   fs.readFileSync(path.join(ROOT, 'messages/en/integrations.json'), 'utf-8')

+ 107 - 0
src/__tests__/lib/cloud-instance.test.ts

@@ -0,0 +1,107 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { generateKeyPairSync, sign } from 'node:crypto'
+import { isCloudInstance, verifyCloudTokenWith } from '@/lib/cloud-instance'
+
+const APP = 'https://app.torqvoice.com'
+
+const { privateKey, publicKey } = generateKeyPairSync('ed25519')
+const other = generateKeyPairSync('ed25519')
+
+function mint(
+  overrides: Partial<Record<string, unknown>> = {},
+  key = privateKey,
+  prefix = 'tvc1'
+): string {
+  const payload = { v: 1, origin: APP, issuedAt: new Date().toISOString(), ...overrides }
+  const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
+  const sig = sign(null, Buffer.from(encoded), key).toString('base64url')
+  return `${prefix}.${encoded}.${sig}`
+}
+
+const verify = (token: string | null | undefined, appUrl: string | undefined = APP) =>
+  verifyCloudTokenWith([publicKey], token, appUrl)
+
+describe('verifyCloudTokenWith', () => {
+  it('accepts a token signed for this origin', () => {
+    expect(verify(mint())).toBe('valid')
+  })
+
+  it('compares origins, not the written URL', () => {
+    expect(verify(mint(), `${APP}/`)).toBe('valid')
+    expect(verify(mint({ origin: `${APP}/` }), APP)).toBe('valid')
+  })
+
+  it('reports a missing token', () => {
+    expect(verify(undefined)).toBe('missing')
+    expect(verify('  ')).toBe('missing')
+  })
+
+  it('refuses a token minted for another URL', () => {
+    expect(verify(mint(), 'https://staging.torqvoice.com')).toBe('wrong-origin')
+    expect(verify(mint(), '')).toBe('wrong-origin')
+  })
+
+  it('honours an expiry when the token has one', () => {
+    const now = Date.now()
+    const token = mint({ expiresAt: new Date(now + 60_000).toISOString() })
+    expect(verifyCloudTokenWith([publicKey], token, APP, new Date(now))).toBe('valid')
+    expect(verifyCloudTokenWith([publicKey], token, APP, new Date(now + 120_000))).toBe('expired')
+    expect(verify(mint({ expiresAt: 'soon' }))).toBe('invalid')
+  })
+
+  it('refuses a token signed with another key', () => {
+    expect(verify(mint({}, other.privateKey))).toBe('invalid')
+  })
+
+  it('refuses a white-label licence token, even one signed with the right key', () => {
+    expect(verify(mint({}, privateKey, 'tvl1'))).toBe('invalid')
+  })
+
+  it('refuses a payload edited after signing', () => {
+    const [prefix, , sig] = mint().split('.')
+    const forged = Buffer.from(
+      JSON.stringify({ v: 1, origin: 'https://evil.example', issuedAt: new Date().toISOString() })
+    ).toString('base64url')
+    expect(verify(`${prefix}.${forged}.${sig}`, 'https://evil.example')).toBe('invalid')
+  })
+
+  it('refuses malformed tokens and payloads', () => {
+    expect(verify('tvc1.abc')).toBe('invalid')
+    expect(verify('nonsense')).toBe('invalid')
+    expect(verify(mint({ v: 2 }))).toBe('invalid')
+    expect(verify(mint({ origin: 'not a url' }))).toBe('invalid')
+    expect(verify(mint({ origin: undefined }))).toBe('invalid')
+  })
+})
+
+describe('isCloudInstance', () => {
+  beforeEach(() => {
+    vi.spyOn(console, 'error').mockReturnValue(undefined)
+  })
+
+  afterEach(() => {
+    vi.unstubAllEnvs()
+    vi.restoreAllMocks()
+  })
+
+  it('is off outside cloud mode', () => {
+    vi.stubEnv('TORQVOICE_MODE', 'self-hosted')
+    expect(isCloudInstance()).toBe(false)
+    expect(console.error).not.toHaveBeenCalled()
+  })
+
+  it('is off when cloud mode is set without a token, and says why', () => {
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
+    vi.stubEnv('TORQVOICE_CLOUD_TOKEN', '')
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://workshop.example')
+    expect(isCloudInstance()).toBe(false)
+    expect(console.error).toHaveBeenCalledWith(expect.stringContaining('is not set'))
+  })
+
+  it('is off with a token nobody at torqvoice.com signed', () => {
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
+    vi.stubEnv('TORQVOICE_CLOUD_TOKEN', mint({ origin: 'https://workshop.example' }))
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://workshop.example')
+    expect(isCloudInstance()).toBe(false)
+  })
+})

+ 6 - 0
src/__tests__/lib/features.test.ts

@@ -1,5 +1,11 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest'
 
+// Cloud mode also needs a signed token (see cloud-instance.test.ts); here the
+// environment variable alone stands for a confirmed cloud instance.
+vi.mock('@/lib/cloud-instance', () => ({
+  isCloudInstance: () => process.env.TORQVOICE_MODE === 'cloud',
+}))
+
 vi.mock('@/lib/db', () => ({
   db: {
     subscription: { findUnique: vi.fn() },

+ 6 - 0
src/__tests__/lib/support.test.ts

@@ -10,6 +10,12 @@
 
 import { describe, it, expect, vi, beforeEach } from 'vitest'
 
+// Cloud mode also needs a signed token (see cloud-instance.test.ts); here the
+// environment variable alone stands for a confirmed cloud instance.
+vi.mock('@/lib/cloud-instance', () => ({
+  isCloudInstance: () => process.env.TORQVOICE_MODE === 'cloud',
+}))
+
 vi.mock('@/lib/db', () => ({
   db: {
     systemSetting: { findUnique: vi.fn() },

+ 6 - 0
src/__tests__/lib/torqvoice-com-link.test.ts

@@ -1,5 +1,11 @@
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
 
+// Cloud mode also needs a signed token (see cloud-instance.test.ts); here the
+// environment variable alone stands for a confirmed cloud instance.
+vi.mock('@/lib/cloud-instance', () => ({
+  isCloudInstance: () => process.env.TORQVOICE_MODE === 'cloud',
+}))
+
 vi.mock('@/lib/db', () => ({ db: {} }))
 
 import { isCloudLinked, resetCloudLinkForTests } from '@/lib/torqvoice-com-link'

+ 6 - 0
src/__tests__/lib/torqvoice-com.test.ts

@@ -1,6 +1,12 @@
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
 import { createHmac } from 'node:crypto'
 
+// Cloud mode also needs a signed token (see cloud-instance.test.ts); here the
+// environment variable alone stands for a confirmed cloud instance.
+vi.mock('@/lib/cloud-instance', () => ({
+  isCloudInstance: () => process.env.TORQVOICE_MODE === 'cloud',
+}))
+
 vi.mock('@/lib/db', () => ({ db: {} }))
 
 import {

+ 4 - 3
src/integrations/registry.ts

@@ -8,6 +8,7 @@
  */
 
 import type { ConnectorManifest, ConnectorServer } from '@/features/integrations/Lib/types'
+import { isCloudInstance } from '@/lib/cloud-instance'
 import { manifest as amazonSes } from './amazon-ses/manifest'
 import { manifest as anthropic } from './anthropic/manifest'
 import { manifest as googleCalendar } from './google-calendar/manifest'
@@ -72,10 +73,10 @@ const ALL_ENTRIES: readonly RegistryEntry[] = [
 ]
 
 /**
- * The same test as isCloudMode() in lib/features, read here directly so the
- * registry stays plain data with no database behind it.
+ * The same test as isCloudMode() in lib/features, imported from lib/cloud-instance
+ * directly so the registry stays plain data with no database behind it.
  */
-const IS_CLOUD = process.env.TORQVOICE_MODE === 'cloud'
+const IS_CLOUD = isCloudInstance()
 
 /**
  * A self-hosted-only connector does not exist on the cloud instance: not in

+ 110 - 0
src/lib/cloud-instance.ts

@@ -0,0 +1,110 @@
+import type { KeyObject } from 'node:crypto'
+import { embeddedKeys, signedPayload } from './license/signature'
+
+/**
+ * Whether this app is the Torqvoice cloud instance.
+ *
+ * `TORQVOICE_MODE=cloud` alone is a line anyone can put in an environment
+ * file, and cloud mode changes what a workshop gets: branding comes off on
+ * every plan, plans follow the `subscription` table (which on a self-hosted
+ * install is the operator's to write), and the one-workshop limit no longer
+ * counts the install. So cloud mode also needs a token torqvoice.com signed
+ * for this app's public URL, in TORQVOICE_CLOUD_TOKEN. Without one the app
+ * runs as a self-hosted install, and says so in the log.
+ *
+ * The token is minted by hand on torqvoice.com (`scripts/mint-cloud-token.mjs`)
+ * with the key that signs licence tokens, under its own prefix so a licence
+ * can never pass for it. The production token has no expiry: nothing refreshes
+ * it, and an expiry would only schedule an outage. Revoking one means rotating
+ * the signing key. Throwaway tokens, like the one the e2e run mints for
+ * 127.0.0.1, carry `expiresAt` so a copy that escapes the run is soon dead.
+ *
+ * Checked offline against the embedded public key, so the answer never waits
+ * on the network and a torqvoice.com outage changes nothing.
+ */
+
+export const CLOUD_TOKEN_PREFIX = 'tvc1'
+
+export type CloudTokenPayload = {
+  v: 1
+  /** the public origin this token was minted for, e.g. https://app.torqvoice.com */
+  origin: string
+  /** ISO 8601, when torqvoice.com signed this token */
+  issuedAt: string
+  /** ISO 8601; only throwaway tokens have one */
+  expiresAt?: string
+}
+
+export type CloudTokenStatus =
+  | 'missing'
+  /** malformed, bad signature, or not a cloud token */
+  | 'invalid'
+  /** signature fine, minted for another URL */
+  | 'wrong-origin'
+  /** signature fine, past its expiresAt */
+  | 'expired'
+  | 'valid'
+
+function originOf(url: string | null | undefined): string | null {
+  if (!url) return null
+  try {
+    return new URL(url).origin
+  } catch {
+    return null
+  }
+}
+
+/** Verifies with an explicit key set. Tests use this with a throwaway pair. */
+export function verifyCloudTokenWith(
+  keys: readonly KeyObject[],
+  token: string | null | undefined,
+  appUrl: string | null | undefined,
+  now: Date = new Date()
+): CloudTokenStatus {
+  if (!token?.trim()) return 'missing'
+
+  const p = signedPayload(keys, token, CLOUD_TOKEN_PREFIX)
+  if (!p || p.v !== 1 || typeof p.origin !== 'string' || typeof p.issuedAt !== 'string') {
+    return 'invalid'
+  }
+
+  if (p.expiresAt !== undefined) {
+    if (typeof p.expiresAt !== 'string' || Number.isNaN(Date.parse(p.expiresAt))) return 'invalid'
+  }
+
+  const minted = originOf(p.origin)
+  if (!minted) return 'invalid'
+  if (minted !== originOf(appUrl)) return 'wrong-origin'
+  if (p.expiresAt && Date.parse(p.expiresAt) <= now.getTime()) return 'expired'
+  return 'valid'
+}
+
+let memo: { key: string; cloud: boolean } | null = null
+
+export function isCloudInstance(): boolean {
+  if (process.env.TORQVOICE_MODE !== 'cloud') return false
+
+  const token = process.env.TORQVOICE_CLOUD_TOKEN
+  const appUrl = process.env.NEXT_PUBLIC_APP_URL
+  // Keyed on the inputs rather than computed once, so a test that changes
+  // the environment gets a fresh answer.
+  const key = `${token}|${appUrl}`
+  if (memo?.key === key) return memo.cloud
+
+  const status = verifyCloudTokenWith(embeddedKeys(), token, appUrl)
+  if (status !== 'valid') {
+    const reason =
+      status === 'missing'
+        ? 'TORQVOICE_CLOUD_TOKEN is not set'
+        : status === 'wrong-origin'
+          ? `TORQVOICE_CLOUD_TOKEN was minted for another URL than ${appUrl}`
+          : status === 'expired'
+            ? 'TORQVOICE_CLOUD_TOKEN has expired'
+            : 'TORQVOICE_CLOUD_TOKEN is not a valid cloud token'
+    console.error(
+      `[cloud] TORQVOICE_MODE=cloud is ignored: ${reason}. Running as a self-hosted install.`
+    )
+  }
+  memo = { key, cloud: status === 'valid' }
+  return memo.cloud
+}

+ 3 - 1
src/lib/features.ts

@@ -1,4 +1,5 @@
 import { cache } from 'react'
+import { isCloudInstance } from './cloud-instance'
 import { db } from './db'
 import { verifyLicenseToken } from './license/token'
 import { scheduleLicenseSelfHeal } from './license/revalidate'
@@ -132,8 +133,9 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
   },
 }
 
+/** TORQVOICE_MODE=cloud with a token torqvoice.com signed for this URL. See lib/cloud-instance. */
 export function isCloudMode(): boolean {
-  return process.env.TORQVOICE_MODE === 'cloud'
+  return isCloudInstance()
 }
 
 /**

+ 56 - 0
src/lib/license/signature.ts

@@ -0,0 +1,56 @@
+import { createPublicKey, verify, type KeyObject } from 'node:crypto'
+import { LICENSE_PUBLIC_KEYS } from './public-keys'
+
+/**
+ * The signature check shared by every token torqvoice.com signs.
+ *
+ * Format: `<prefix>.<base64url payload JSON>.<base64url Ed25519 signature>`.
+ * The prefix names the kind of token, so one kind can never be read as
+ * another: a white-label licence (`tvl1`) is not a cloud instance token
+ * (`tvc1`), even though the same key signs both.
+ */
+
+let cachedKeys: KeyObject[] | null = null
+
+export function embeddedKeys(): KeyObject[] {
+  if (!cachedKeys) {
+    cachedKeys = LICENSE_PUBLIC_KEYS.map((k) =>
+      createPublicKey({ key: Buffer.from(k, 'base64'), format: 'der', type: 'spki' })
+    )
+  }
+  return cachedKeys
+}
+
+/**
+ * The decoded payload object when `token` carries `prefix` and one of `keys`
+ * signed it; null otherwise. The caller still checks the payload's fields.
+ */
+export function signedPayload(
+  keys: readonly KeyObject[],
+  token: string,
+  prefix: string
+): Record<string, unknown> | null {
+  const parts = token.trim().split('.')
+  if (parts.length !== 3 || parts[0] !== prefix) return null
+  const [, encoded, sig] = parts
+
+  const signature = Buffer.from(sig, 'base64url')
+  if (signature.length !== 64) return null
+
+  const data = Buffer.from(encoded, 'utf8')
+  const signed = keys.some((key) => {
+    try {
+      return verify(null, data, key, signature)
+    } catch {
+      return false
+    }
+  })
+  if (!signed) return null
+
+  try {
+    const parsed: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))
+    return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
+  } catch {
+    return null
+  }
+}

+ 5 - 45
src/lib/license/token.ts

@@ -1,5 +1,5 @@
-import { createPublicKey, verify, type KeyObject } from 'node:crypto'
-import { LICENSE_PUBLIC_KEYS } from './public-keys'
+import type { KeyObject } from 'node:crypto'
+import { embeddedKeys, signedPayload } from './signature'
 
 /**
  * Verifies the signed licence token torqvoice.com hands out.
@@ -62,26 +62,7 @@ const NOT_VERIFIED: LicenseTokenVerification = {
   daysUntilExpiry: null,
 }
 
-let cachedKeys: KeyObject[] | null = null
-
-function embeddedKeys(): KeyObject[] {
-  if (!cachedKeys) {
-    cachedKeys = LICENSE_PUBLIC_KEYS.map((k) =>
-      createPublicKey({ key: Buffer.from(k, 'base64'), format: 'der', type: 'spki' })
-    )
-  }
-  return cachedKeys
-}
-
-function parsePayload(encoded: string): LicenseTokenPayload | null {
-  let parsed: unknown
-  try {
-    parsed = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))
-  } catch {
-    return null
-  }
-  if (!parsed || typeof parsed !== 'object') return null
-  const p = parsed as Record<string, unknown>
+function parsePayload(p: Record<string, unknown>): LicenseTokenPayload | null {
   if (p.v !== 1) return null
   for (const field of ['lid', 'org', 'plan', 'expiresAt', 'issuedAt']) {
     if (typeof p[field] !== 'string' || !(p[field] as string)) return null
@@ -104,29 +85,8 @@ export function verifyLicenseTokenWith(
 ): LicenseTokenVerification {
   if (!token) return { ...NOT_VERIFIED, status: 'missing' }
 
-  const parts = token.trim().split('.')
-  if (parts.length !== 3 || parts[0] !== LICENSE_TOKEN_PREFIX) return NOT_VERIFIED
-  const [, encoded, sig] = parts
-
-  let signature: Buffer
-  try {
-    signature = Buffer.from(sig, 'base64url')
-  } catch {
-    return NOT_VERIFIED
-  }
-  if (signature.length !== 64) return NOT_VERIFIED
-
-  const data = Buffer.from(encoded, 'utf8')
-  const signed = keys.some((key) => {
-    try {
-      return verify(null, data, key, signature)
-    } catch {
-      return false
-    }
-  })
-  if (!signed) return NOT_VERIFIED
-
-  const payload = parsePayload(encoded)
+  const signed = signedPayload(keys, token, LICENSE_TOKEN_PREFIX)
+  const payload = signed && parsePayload(signed)
   if (!payload) return NOT_VERIFIED
   // A token is bound to the org that asked for it. One lifted from another
   // install, or minted for a different org on the same install, is rejected.