Просмотр исходного кода

Hand the e2e cloud run a pre-minted token instead of the licence signing key, and bind Postgres to loopback in the compose files (#503)

* Hand the e2e cloud run a pre-minted token instead of the licence signing key, and bind Postgres to loopback in the compose files

* Say that POSTGRES_PASSWORD goes into DATABASE_URL unescaped, so letters and digits only
Bernt Christian Egeland 3 дней назад
Родитель
Сommit
231d2bbe48
6 измененных файлов с 50 добавлено и 36 удалено
  1. 4 2
      .devcontainer/docker-compose.yml
  2. 6 0
      .env.example
  3. 12 10
      .github/workflows/e2e.yml
  4. 13 3
      docker-compose.yml
  5. 1 1
      e2e/README.md
  6. 14 20
      playwright.config.ts

+ 4 - 2
.devcontainer/docker-compose.yml

@@ -26,7 +26,9 @@ services:
       POSTGRES_PASSWORD: torqvoice
       POSTGRES_DB: torqvoice
     ports:
-      - "5432:5432"
+      # Loopback only: a laptop on a shared network should not offer its
+      # database, or pgAdmin below, to the room.
+      - "127.0.0.1:5432:5432"
     healthcheck:
       test: ["CMD-SHELL", "pg_isready -U torqvoice"]
       interval: 5s
@@ -44,7 +46,7 @@ services:
       PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
       PGPASS_FILE: /pgpass
     ports:
-      - "8080:80"
+      - "127.0.0.1:8080:80"
     depends_on:
       - db
     volumes:

+ 6 - 0
.env.example

@@ -1,5 +1,11 @@
 # Database
 DATABASE_URL="postgresql://torqvoice:torqvoice@db:5432/torqvoice"
+# Docker only (docker-compose.yml): the password of the `db` service, put into
+# the app's DATABASE_URL there as it is, so letters and digits only
+# (`openssl rand -hex 24`); a `/`, `@`, `#` or `%` breaks the URL. Set it
+# before the first start; unset, it is `torqvoice`, and the port is bound to
+# 127.0.0.1 so nothing outside the host can try it.
+# POSTGRES_PASSWORD=""
 
 # Authentication (generate with: openssl rand -hex 32)
 BETTER_AUTH_SECRET="your-secret-here"

+ 12 - 10
.github/workflows/e2e.yml

@@ -62,20 +62,22 @@ 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 || '' }}
+      # The cloud token torqvoice.com minted for http://127.0.0.1:3100, the
+      # origin this suite builds for; without it the app ignores
+      # TORQVOICE_MODE=cloud. Only the cloud job gets it. A token and never the
+      # signing key: this env reaches npm ci and every install script with it.
+      E2E_CLOUD_TOKEN: ${{ matrix.mode == 'cloud' && secrets.E2E_CLOUD_TOKEN || '' }}
 
     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
+      # A pull request from a fork gets no secrets, so the cloud job has no
+      # cloud token 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 token and always run.
+      - name: Check for the cloud token
         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."
+          if [ "${{ matrix.mode }}" = "cloud" ] && [ -z "$E2E_CLOUD_TOKEN" ]; then
+            echo "::notice title=Cloud specs skipped::No E2E_CLOUD_TOKEN here (a fork or Dependabot pull request), so the cloud specs cannot run."
             echo "run=false" >> "$GITHUB_OUTPUT"
           else
             echo "run=true" >> "$GITHUB_OUTPUT"

+ 13 - 3
docker-compose.yml

@@ -6,7 +6,7 @@ services:
     ports:
       - "3000:3000"
     environment:
-      - DATABASE_URL=postgresql://torqvoice:torqvoice@db:5432/torqvoice
+      - DATABASE_URL=postgresql://torqvoice:${POSTGRES_PASSWORD:-torqvoice}@db:5432/torqvoice
       - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
       - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
       - RESEND_API_KEY=${RESEND_API_KEY:-}
@@ -29,10 +29,20 @@ services:
       - postgres-data:/var/lib/postgresql/data
     environment:
       POSTGRES_USER: torqvoice
-      POSTGRES_PASSWORD: torqvoice
+      # Set POSTGRES_PASSWORD in .env before the first start. The same value
+      # goes into the app's DATABASE_URL above as it is, with no escaping, so
+      # keep it to letters and digits (`openssl rand -hex 24`): a `/`, `@`, `#`
+      # or `%` in it breaks the URL. Postgres keeps the password it was
+      # created with, so changing it later means changing it in the database
+      # as well (ALTER USER torqvoice PASSWORD '...').
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-torqvoice}
       POSTGRES_DB: torqvoice
     ports:
-      - "5432:5432"
+      # The app reaches the database as `db` on Docker's own network; this is
+      # for psql or a GUI on the host only. Bound to loopback because Docker
+      # publishes ports past the host firewall, and 0.0.0.0 put the database
+      # on the internet with the password above.
+      - "127.0.0.1:5432:5432"
     healthcheck:
       test: ["CMD-SHELL", "pg_isready -U torqvoice"]
       interval: 5s

+ 1 - 1
e2e/README.md

@@ -187,7 +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 |
+| `E2E_CLOUD_TOKEN` | required for `E2E_MODE=cloud` | a cloud token minted for the suite's origin (`http://127.0.0.1:3100`, or `E2E_BASE_URL`) with `scripts/mint-cloud-token.mjs` on torqvoice.com, since the app ignores `TORQVOICE_MODE=cloud` without one. The token, never the signing key |
 
 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

+ 14 - 20
playwright.config.ts

@@ -1,4 +1,3 @@
-import { createPrivateKey, sign } from 'node:crypto'
 import { resolve } from 'node:path'
 import { defineConfig, devices } from '@playwright/test'
 
@@ -60,28 +59,23 @@ 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.
+ * The run is handed one in E2E_CLOUD_TOKEN (a repository secret in CI), minted
+ * once for http://127.0.0.1:3100 with scripts/mint-cloud-token.mjs on
+ * torqvoice.com. The run used to mint its own from the signing key, which put
+ * the key behind every licence in the environment of npm ci and every
+ * dependency's install script; a token bound to a loopback origin is worth
+ * nothing outside this suite. 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) {
+function cloudToken(): string {
+  const token = process.env.E2E_CLOUD_TOKEN?.trim()
+  if (!token) {
+    const origin = new URL(baseURL).origin
     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.'
+      `E2E_MODE=cloud needs E2E_CLOUD_TOKEN, a cloud token minted for ${origin} (on torqvoice.com: node --env-file=.env scripts/mint-cloud-token.mjs ${origin}).`
     )
   }
-  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')}`
+  return token
 }
 
 /** Where the Google stand-in listens, for the app's server and for the specs. */
@@ -232,7 +226,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(),
+                  TORQVOICE_CLOUD_TOKEN: cloudToken(),
                   // 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.