diff --git a/.env.example b/.env.example index 01bcdcdf..069aa768 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,82 @@ BEACON_COMMERCE_SERVICE_KEY_CURRENT=replace-with-at-least-43-random-characters # Fixed at seven days for the 2026-08-08 event plus support window. SESSION_COOKIE_TTL_SECONDS=604800 +# =================== +# Listener identity, membership and private stream +# =================== +# Identity/access settings below keep their EARLY_BIRDS_ deployment names for +# the existing namespace migration. The application also accepts the stable +# BEACON_LISTENER_ alias for ENABLED, FREE_FOR_ALL, AUTH_BASE_URL, +# TRUSTED_ORIGINS, AUTH_SECRET, Google credentials, the magic-link trio, +# test access and staging team entry. Never mix generations within a credential +# bundle; if both aliases are present they must have the same trimmed value. +# Public entry defaults OFF. Set to exactly 1 only after the isolated stack is +# healthy; switching it back to 0 presents a truthful unavailable page while +# private membership projection and reconciliation continue. +EARLY_BIRDS_ENABLED=0 +# Reversible operator override for public listening moments. Exactly 1 bypasses +# identity/membership only for Listener audio routes; 0 restores normal access. +EARLY_BIRDS_FREE_FOR_ALL=0 + +# Public app origin. Register these exact OAuth callbacks: +# https:///api/early-birds/auth/callback/google +# https:///api/early-birds/auth/callback/apple +EARLY_BIRDS_AUTH_BASE_URL=https://app.example.invalid +EARLY_BIRDS_TRUSTED_ORIGINS=https://app.example.invalid +EARLY_BIRDS_AUTH_SECRET=replace-with-at-least-32-random-characters +EARLY_BIRDS_GOOGLE_CLIENT_ID= +EARLY_BIRDS_GOOGLE_CLIENT_SECRET= +# Apple remains absent from the UI until the complete server-side bundle is +# installed and this explicit gate is exactly 1. +BEACON_LISTENER_APPLE_ENABLED=0 +BEACON_LISTENER_APPLE_CLIENT_ID= +# Generated ES256 Apple client-secret JWT. Never place the private .p8 here. +BEACON_LISTENER_APPLE_CLIENT_SECRET= + +# Optional passwordless fallback. The UI and Better Auth plugin remain absent +# unless all three values are complete. Delivery is a narrow private API owned +# by the existing mail service; never mount its Gmail grant into this app. +EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL= +EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN= +EARLY_BIRDS_MAGIC_LINK_RATE_SECRET= + +# Outbound server-to-server access to PMP Myth Bot, the sole membership authority. +EARLY_BIRDS_AUTHORITY_BASE_URL=http://pmp-myth-bot:3000 +EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID=local-v1 +EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN=replace-with-at-least-43-random-characters + +# Inbound monotonic membership projection authentication; keep current and +# previous simultaneously during a rotation. +EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID=local-v1 +EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT=replace-with-at-least-43-random-characters +# EARLY_BIRDS_BEACON_SERVICE_KEY_PREVIOUS_ID= +# EARLY_BIRDS_BEACON_SERVICE_KEY_PREVIOUS= + +# The browser receives only a stable same-origin manifest URL. These credentials +# remain server-side and sign the approved origin artifact for <=10 minutes. +EARLY_BIRDS_STREAM_ORIGIN=https://stream.example.invalid +EARLY_BIRDS_STREAM_ARTIFACT_ID=approved-v1 +EARLY_BIRDS_STREAM_SIGNING_SECRET=replace-with-at-least-32-random-characters +EARLY_BIRDS_DEVICE_PEPPER=replace-with-at-least-32-random-characters +# Private drop-ins are local files served through the authenticated same-origin route. +# Leave either absolute path empty to fail closed for that language. +EARLY_BIRDS_DROPIN_ES_PATH= +EARLY_BIRDS_DROPIN_EN_PATH= + +# Synthetic auth/access is absent unless BOTH values are explicitly present. +# The harness must call POST /api/early-birds/test-login with +# Authorization: Bearer . Never expose it to browser code or production. +EARLY_BIRDS_TEST_ACCESS_ENABLED=0 +# EARLY_BIRDS_TEST_LOGIN_SECRET=replace-with-at-least-32-random-characters + +# Optional human-operated synthetic entry for HTTPS staging only. It remains +# hidden unless all EarlyBird/test gates above and this dedicated gate are 1, +# NODE_ENV is production, X-Forwarded-Proto is exactly https, and Host exactly +# matches one comma-separated entry below. Values are host[:port], never URLs +# or wildcards. Do not include production/customer hosts. +EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0 +# EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.example.invalid + # Trusted reverse-proxy hops in front of the app, used to find the real client # address for the failed-login limiter. 1 = Nginx only; 2 = Cloudflare + Nginx. # Too high a value keys the limiter on a forgeable header; too low keys every diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e88b8bd9..67cb0394 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - run: npm ci - name: Reject high-severity production dependency regressions run: | - npm audit --omit=dev --audit-level=high + npm run audit:production npm audit --omit=dev --prefix services/tapestry --audit-level=high npm audit --omit=dev --prefix services/playlist-bot --audit-level=high - run: npm run lint diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bbd33082..6b4b4f90 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,7 +46,7 @@ jobs: npm run contract:commerce:verify npm test npm test --prefix services/tapestry - npm audit --omit=dev --audit-level=high + npm run audit:production npm audit --omit=dev --prefix services/tapestry --audit-level=high npm audit --omit=dev --prefix services/playlist-bot --audit-level=high npx tsc --noEmit diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 787582e6..c9e161f2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -146,12 +146,31 @@ jobs: - name: Run Firefox functional and accessibility gates run: npx playwright test --project=firefox + - name: Verify Listener Founder and Free account-switch cache boundary + run: >- + npx playwright test e2e/tests/listener-account-switch.spec.ts + --project=chromium + --project=firefox + env: + E2E_LISTENER_ACCOUNT_SWITCH_GATE: '1' + - name: Install WebKit after Chromium screenshot gates run: npx playwright install --with-deps webkit - name: Run iPhone/WebKit media gate run: npx playwright test e2e/tests/media-continuity.spec.ts --project=iphone-webkit + - name: Verify Listener buffer and recovery under deterministic network loss + run: >- + npx playwright test e2e/tests/listener-network-resilience.spec.ts + --project=chromium + --project=firefox + --project=android-chrome + --project=iphone-webkit + --workers=4 + env: + E2E_LISTENER_NETWORK_GATE: '1' + - name: Verify commerce transaction and durable outbox contract run: npx vitest run --no-file-parallelism src/lib/__tests__/commerce-entitlement.integration.test.ts src/lib/__tests__/promo-invitation.integration.test.ts env: diff --git a/.github/workflows/early-birds-fast-forward.yml b/.github/workflows/early-birds-fast-forward.yml new file mode 100644 index 00000000..d06e1401 --- /dev/null +++ b/.github/workflows/early-birds-fast-forward.yml @@ -0,0 +1,242 @@ +name: EarlyBirds fast-forward checks + +on: + pull_request: + paths: + - Dockerfile + - package.json + - package-lock.json + - prisma/** + - src/app/api/health/** + - src/app/api/early-birds/** + - src/app/api/listener/** + - src/app/early-birds/** + - src/app/listener/** + - src/components/early-birds/** + - src/lib/early-birds/** + - src/lib/listener/** + - e2e/fixtures/listener-account-switch.ts + - e2e/tests/listener-account-switch.spec.ts + - e2e/tests/listener-network-resilience.spec.ts + - playwright.config.ts + - src/app/layout.tsx + - src/app/globals.css + - src/components/brand/ListenerIdentityCacheBoundary.tsx + - src/components/brand/__tests__/ListenerIdentityCacheBoundary.test.tsx + - services/beacon-stream/** + - ops/early-birds/** + - ops/early-birds-preview/** + - ops/beacon-account/** + - ops/listener-identity-staging/** + - ops/listener-account-production/** + - scripts/beacon-account/** + - scripts/early-birds-preview/** + - scripts/listener-identity-staging/** + - scripts/listener-account-production/** + - scripts/listener_container_observer.py + - tools/early-birds-hls-load/** + - docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md + - docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md + - docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md + - docs/operations/LISTENER_LAUNCH_NOW.md + - docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md + - .github/workflows/early-birds-fast-forward.yml + push: + branches: [early-birds, "feat/early-birds-*"] + paths: + - Dockerfile + - package.json + - package-lock.json + - prisma/** + - src/app/api/health/** + - src/app/api/early-birds/** + - src/app/api/listener/** + - src/app/early-birds/** + - src/app/listener/** + - src/components/early-birds/** + - src/lib/early-birds/** + - src/lib/listener/** + - e2e/fixtures/listener-account-switch.ts + - e2e/tests/listener-account-switch.spec.ts + - e2e/tests/listener-network-resilience.spec.ts + - playwright.config.ts + - src/app/layout.tsx + - src/app/globals.css + - src/components/brand/ListenerIdentityCacheBoundary.tsx + - src/components/brand/__tests__/ListenerIdentityCacheBoundary.test.tsx + - services/beacon-stream/** + - ops/early-birds/** + - ops/early-birds-preview/** + - ops/beacon-account/** + - ops/listener-identity-staging/** + - ops/listener-account-production/** + - scripts/beacon-account/** + - scripts/early-birds-preview/** + - scripts/listener-identity-staging/** + - scripts/listener-account-production/** + - scripts/listener_container_observer.py + - tools/early-birds-hls-load/** + - docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md + - docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md + - docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md + - docs/operations/LISTENER_LAUNCH_NOW.md + - docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md + - .github/workflows/early-birds-fast-forward.yml + +permissions: + contents: read + +jobs: + quota-postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: listener_test + POSTGRES_DB: listener_test + ports: + - 55433:5432 + options: >- + --health-cmd "pg_isready -U postgres -d listener_test" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + DATABASE_URL: postgresql://postgres:listener_test@localhost:55433/listener_test + LISTENER_TEST_DATABASE_URL: postgresql://postgres:listener_test@localhost:55433/listener_test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.22' + cache: 'npm' + - run: npm ci + - run: npx prisma migrate deploy + - run: npm test -- --run src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts src/lib/early-birds/__tests__/quota.postgres.test.ts + + stream-origin: + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/beacon-stream + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - run: npm test + - run: npm run check + + observability-config: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ops/early-birds + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - uses: actions/setup-python@v5 + with: { python-version: '3.13' } + - run: npm test + - run: npm run check + - run: npm run validate + - run: npm --prefix ../../tools/early-birds-hls-load test + - run: npm --prefix ../../tools/early-birds-hls-load run check + + staging-preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - run: npm --prefix ops/early-birds-preview run check + - run: npm --prefix ops/early-birds-preview test + - run: npm --prefix ops/early-birds-preview run validate:build + - run: npm --prefix ops/listener-identity-staging run check + - run: npm --prefix ops/listener-identity-staging test + - run: npm --prefix ops/listener-identity-staging run validate:example + - run: npm --prefix ops/listener-account-production run check + - run: npm --prefix ops/listener-account-production test + + identity-cache: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: e2e + POSTGRES_DB: beacon_test + ports: + - 55432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d beacon_test" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + E2E_DATABASE_URL: postgresql://postgres:e2e@localhost:55432/beacon_test + E2E_LISTENER_ACCOUNT_SWITCH_GATE: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.22' + cache: 'npm' + - run: npm ci + - run: DATABASE_URL="$E2E_DATABASE_URL" npx prisma migrate deploy + - run: npx playwright install --with-deps chromium firefox + - run: >- + npx playwright test e2e/tests/listener-account-switch.spec.ts + --project=chromium + --project=firefox + + network-resilience: + name: network-resilience (${{ matrix.project }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - project: chromium + browser: chromium + runner: ubuntu-latest + - project: firefox + browser: firefox + # Playwright documents that media codec availability varies by + # host OS. Its patched Linux Firefox does not expose the approved + # AAC/fMP4 MSE pipeline, so exercise Firefox on macOS rather than + # silently substituting a different stream codec. + runner: macos-15 + - project: android-chrome + browser: chromium + runner: ubuntu-latest + - project: iphone-webkit + browser: webkit + runner: ubuntu-latest + env: + E2E_LISTENER_NETWORK_GATE: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.22' + cache: 'npm' + - run: npm ci + - run: npx playwright install --with-deps ${{ matrix.browser }} + - name: Install deterministic HLS fixture encoder + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ffmpeg + ffmpeg -version | head -n 1 + - name: Install deterministic HLS fixture encoder on macOS + if: runner.os == 'macOS' + run: | + command -v ffmpeg >/dev/null || brew install ffmpeg + ffmpeg -version | head -n 1 + - name: Run network resilience in an isolated browser process + run: >- + npx playwright test e2e/tests/listener-network-resilience.spec.ts + --project=${{ matrix.project }} + --workers=1 diff --git a/.gitignore b/.gitignore index f5713255..6460594b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ /playwright-report/ /e2e/.auth/ /artifacts/load-test/ +/artifacts/early-birds-hls-load/ +__pycache__/ +*.py[cod] # next.js /.next/ diff --git a/Dockerfile b/Dockerfile index db204e70..d83f624e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,8 @@ RUN npm run build FROM base AS runner WORKDIR /app -ENV NODE_ENV=production +ENV NODE_ENV=production \ + BEACON_ACCOUNT_NAV_ASSET=1 ARG BEACON_GIT_SHA=unknown ARG BEACON_BUILD_TIME=unknown @@ -48,10 +49,37 @@ COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json COPY --from=builder --chown=nextjs:nodejs /app/scripts/weekend-stabilize.ts ./scripts/weekend-stabilize.ts COPY --from=builder --chown=nextjs:nodejs /app/scripts/commerce-media-worker.ts ./scripts/commerce-media-worker.ts +COPY --from=builder --chown=nextjs:nodejs /app/scripts/listener-quiesce-for-free-for-all.ts ./scripts/listener-quiesce-for-free-for-all.ts +COPY --from=builder --chown=nextjs:nodejs /app/scripts/listener-withdrawal-operator.ts ./scripts/listener-withdrawal-operator.ts +COPY --from=builder --chown=nextjs:nodejs /app/scripts/beacon-account/check-migrations.mjs ./scripts/beacon-account/check-migrations.mjs +COPY --from=builder --chown=nextjs:nodejs /app/scripts/beacon-account/provision-production-role.mjs ./scripts/beacon-account/provision-production-role.mjs +COPY --from=builder --chown=nextjs:nodejs /app/scripts/beacon-account/social-provider-env.mjs ./scripts/beacon-account/social-provider-env.mjs +COPY --from=builder --chown=nextjs:nodejs /app/scripts/listener-account-production/sync-secret.mjs ./scripts/listener-account-production/sync-secret.mjs +COPY --from=builder --chown=nextjs:nodejs /app/scripts/listener-account-production/preflight.mjs ./scripts/listener-account-production/preflight.mjs +COPY --from=builder --chown=nextjs:nodejs /app/scripts/listener-account-production/activate-env.mjs ./scripts/listener-account-production/activate-env.mjs +COPY --from=builder --chown=nextjs:nodejs /app/ops/beacon-account/validate.mjs ./ops/beacon-account/validate.mjs +COPY --from=builder --chown=nextjs:nodejs /app/ops/beacon-account/account.production.env.example ./ops/beacon-account/account.production.env.example +COPY --from=builder --chown=nextjs:nodejs /app/ops/beacon-account/account.staging.env.example ./ops/beacon-account/account.staging.env.example +COPY --from=builder --chown=nextjs:nodejs /app/ops/beacon-account/database.staging.env.example ./ops/beacon-account/database.staging.env.example +COPY --from=builder --chown=nextjs:nodejs /app/ops/beacon-account/account-mail-worker.production.env.example ./ops/beacon-account/account-mail-worker.production.env.example +COPY --from=builder --chown=nextjs:nodejs /app/ops/beacon-account/account-mail-worker.staging.env.example ./ops/beacon-account/account-mail-worker.staging.env.example +COPY --from=builder --chown=nextjs:nodejs /app/ops/listener-identity-staging/validate.mjs ./ops/listener-identity-staging/validate.mjs +COPY --from=builder --chown=nextjs:nodejs /app/ops/listener-identity-staging/intro-artifacts.sha256 ./ops/listener-identity-staging/intro-artifacts.sha256 +COPY --from=builder --chown=nextjs:nodejs /app/ops/listener-account-production/validate.mjs ./ops/listener-account-production/validate.mjs +COPY --from=builder --chown=nextjs:nodejs /app/scripts/provision-account-authority.ts ./scripts/provision-account-authority.ts +COPY --from=builder --chown=nextjs:nodejs /app/scripts/process-account-mail-outbox.ts ./scripts/process-account-mail-outbox.ts COPY --from=builder --chown=nextjs:nodejs /app/src/lib/event-stabilization.ts ./src/lib/event-stabilization.ts COPY --from=builder --chown=nextjs:nodejs /app/src/lib/redact.ts ./src/lib/redact.ts COPY --from=builder --chown=nextjs:nodejs /app/src/lib/commerce-media-reconciler.ts ./src/lib/commerce-media-reconciler.ts COPY --from=builder --chown=nextjs:nodejs /app/src/lib/db.ts ./src/lib/db.ts +COPY --from=builder --chown=nextjs:nodejs /app/src/lib/session-auth.ts ./src/lib/session-auth.ts +COPY --from=builder --chown=nextjs:nodejs /app/src/lib/account ./src/lib/account +COPY --from=builder --chown=nextjs:nodejs /app/src/lib/listener/consumer-withdrawal.ts ./src/lib/listener/consumer-withdrawal.ts +COPY --from=builder --chown=nextjs:nodejs /app/src/lib/early-birds/account-id.ts ./src/lib/early-birds/account-id.ts +COPY --from=builder --chown=nextjs:nodejs /app/src/lib/early-birds/access.ts ./src/lib/early-birds/access.ts +COPY --from=builder --chown=nextjs:nodejs /app/src/lib/early-birds/membership.ts ./src/lib/early-birds/membership.ts +COPY --from=builder --chown=nextjs:nodejs /app/src/lib/early-birds/quota.ts ./src/lib/early-birds/quota.ts +COPY --from=builder --chown=nextjs:nodejs /app/src/lib/early-birds/stream.ts ./src/lib/early-birds/stream.ts COPY --from=builder --chown=nextjs:nodejs /app/src/lib/livekit-server.ts ./src/lib/livekit-server.ts COPY --from=builder --chown=nextjs:nodejs /app/src/lib/with-timeout.ts ./src/lib/with-timeout.ts COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json diff --git a/README.md b/README.md index a3bdb86e..08d05a0c 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The product makes public commitments in its policy corpus. They are documented, - **[Trust & Safety](./docs/TRUST_AND_SAFETY.md)**: reports will be acknowledged within 24 hours and S1 incidents will get a public postmortem. Neither exists yet — there is no report model and no incidents page. **[Planned — Phase 1]** - **[Research ethics](./docs/RESEARCH_PROTOCOL.md)**: informed consent, revocable participation, preregistered protocols, de-identified public aggregates — the standard the research protocol will be held to once it starts enrolling. No research data is collected today. **[Planned — Phase 3]** - **[Content policy](./docs/CONTENT_POLICY.md)**: no therapeutic claims is a standing rule enforced today through moderation review; appeals of a moderation decision are not yet available. **[Planned — Phase 2]** -- **[Monetization](./docs/MONETIZATION.md)**: patronage-not-paywall, core experience free forever. No payment processing or entitlement model exists yet, so every published meditation is free to everyone today by default rather than by an enforced floor. **[Planned — Phase 2]** +- **[Monetization](./docs/MONETIZATION.md)**: the broader patronage/provider economy remains planned for Phase 2. Separately, the experimental Founding Listener lane now has a server-authoritative weekly Free allowance and USD 5/month PayPal/Mercado Pago membership authority. Sandbox/TEST are accepted; Live credentials, real charges and public checkout remain OFF pending [the release gates](./docs/operations/LISTENER_LAUNCH_NOW.md). What's live today: diff --git a/contracts/early-bird-authority/v1/README.md b/contracts/early-bird-authority/v1/README.md new file mode 100644 index 00000000..6d852f33 --- /dev/null +++ b/contracts/early-bird-authority/v1/README.md @@ -0,0 +1,55 @@ +# EarlyBird authority contract v1 + +Contrato privado para que Beacon use PMP Myth Bot como única autoridad de membresías Free, PayPal +y Mercado Pago. + +## Autenticación y rutas + +Las llamadas son server-to-server por red privada. Exigen `Authorization: Bearer ...` y +`X-HB-Service-Key-Id`; no se invocan desde el navegador. + +- `POST /api/internal/v1/early-bird-invitations/redeem` + - body: `invitation-redeem.schema.json`; + - header obligatorio `Idempotency-Key`, opaco y de hasta 255 caracteres; + - correlaciona el token firmado y one-use con el `account_id` opaco ya autenticado por OAuth; + - una replay idéntica devuelve byte-semánticamente el mismo resultado; reutilizar la key con otro + body devuelve conflicto. +- `POST /api/internal/v1/early-bird-checkouts` + - body: `checkout-create.schema.json`; resultado: `checkout.schema.json`; + - exige el mismo auth interno e `Idempotency-Key`; no existe una variante pública; + - rechaza antes de llamar al proveedor si la cuenta ya tiene un checkout pendiente o una + continuidad paga elegible; una continuidad terminal permite un checkout realmente nuevo; + - persiste la unión cuenta↔suscripción externa antes de devolver una `approval_url` HTTPS, + acotada a 2048 caracteres y sin credenciales embebidas; + - un webhook inicial sin esa unión falla cerrado y cualquier `account_id` del payload se trata + sólo como comprobación defensiva, nunca como autoridad. +- `GET /api/internal/v1/early-bird-memberships/{account_id}` + - devuelve `membership.schema.json` y permite reconciliación pull. + +Toda respuesta lleva `Cache-Control: private, no-store`. El contrato no contiene nombre, email, +tokens OAuth, identidad de menores ni URLs firmadas del stream. + +## Vocabulario exacto + +Estados: `PENDING`, `ACTIVE`, `GRACE`, `CANCELLED_PENDING_END`, `EXPIRED`, `REFUNDED`, `REVOKED`. + +Fuentes: `FREE`, `PAYPAL`, `MERCADO_PAGO` o `null` si todavía no existe grant. + +Sólo `ACTIVE`, `GRACE` y `CANCELLED_PENDING_END` dentro de sus límites temporales producen +`access_allowed=true`. + +## Proyección monotónica a Beacon + +Después de cada cambio material, la autoridad envía el contrato hermano +`contracts/early-bird-membership/v1` mediante: + +`PUT /api/internal/v1/early-bird-memberships/{account_id}` + +Beacon aplica una revisión mayor, reproduce la misma y rechaza como stale una menor. Al primer pago +confirmado la autoridad revoca el grant `FREE`, fija `free_entitlement_consumed=true`, incrementa +`membership_revision` y proyecta `source=PAYPAL` o `source=MERCADO_PAGO`. Cancelar luego el pago no +restaura Free. Beacon no debe inferir ese cambio desde redirects, webhooks propios ni estado local. + +`account_id` usa exclusivamente 1–128 caracteres RFC 3986 unreserved +(`[A-Za-z0-9._~-]`) y comienza con un carácter alfanumérico. Los clientes lo validan y además lo +codifican al construir URLs. diff --git a/contracts/early-bird-authority/v1/SHA256SUMS b/contracts/early-bird-authority/v1/SHA256SUMS new file mode 100644 index 00000000..4c73aa89 --- /dev/null +++ b/contracts/early-bird-authority/v1/SHA256SUMS @@ -0,0 +1,9 @@ +b68d7933d72985709a94b2710d7cb57d8aec0b3581f7bc6aed9f940785fbc54e README.md +46ebfa406c3e17e7913122f5cb6fe16084fccef2baada373017bf411fe1908bf checkout-create.fixture.json +2d464d210c61e98489059a5828c83bbaf31370eb0ebd980edf1271e785d1d97a checkout-create.schema.json +c231e44fe3c141d9f5322a35267b38630529ef323f7236c3c2337acee447839e checkout.fixture.json +d7865fe63b59ddb82285d4df30145a7c3cb59cf8a0923f93eabac8370dba3c9f checkout.schema.json +47c937f3f93ed94b9eff6750bd430131af0c18ace5c2a6684ba36b5f5b3d41f2 invitation-redeem.fixture.json +085d6b6bbf0e88e974ed63e0585d2e52601e859cd283d13dbaf4191a3ae30fd2 invitation-redeem.schema.json +d9c86c455ff006225aaeeaa66787650f690b485185a1f821ed55fdde5e5acbd1 membership.fixture.json +e0008a97d50fb64d4e396b8d03eb5a7ef03808f5dcc3e230ade91b0079a1a9a9 membership.schema.json diff --git a/contracts/early-bird-authority/v1/checkout-create.fixture.json b/contracts/early-bird-authority/v1/checkout-create.fixture.json new file mode 100644 index 00000000..4f7ece58 --- /dev/null +++ b/contracts/early-bird-authority/v1/checkout-create.fixture.json @@ -0,0 +1,7 @@ +{ + "schema_version": "early-bird-authority.checkout-create.v1", + "account_id": "account_synthetic_0001", + "provider": "paypal", + "return_url": "https://example.invalid/membership/complete", + "cancel_url": "https://example.invalid/membership/cancel" +} diff --git a/contracts/early-bird-authority/v1/checkout-create.schema.json b/contracts/early-bird-authority/v1/checkout-create.schema.json new file mode 100644 index 00000000..f4363417 --- /dev/null +++ b/contracts/early-bird-authority/v1/checkout-create.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-authority/v1/checkout-create.schema.json", + "title": "EarlyBird internal checkout create request v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "account_id", "provider", "return_url", "cancel_url"], + "properties": { + "schema_version": {"const": "early-bird-authority.checkout-create.v1"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "provider": {"enum": ["paypal", "mercado_pago"]}, + "return_url": {"type": "string", "minLength": 9, "maxLength": 2048, "pattern": "^https://"}, + "cancel_url": {"type": "string", "minLength": 9, "maxLength": 2048, "pattern": "^https://"} + } +} diff --git a/contracts/early-bird-authority/v1/checkout.fixture.json b/contracts/early-bird-authority/v1/checkout.fixture.json new file mode 100644 index 00000000..91cab642 --- /dev/null +++ b/contracts/early-bird-authority/v1/checkout.fixture.json @@ -0,0 +1,10 @@ +{ + "schema_version": "early-bird-authority.checkout.v1", + "account_id": "account_synthetic_0001", + "provider": "paypal", + "external_subscription_id": "sandbox_paypal_0123456789abcdef01234567", + "approval_url": "https://sandbox.invalid/paypal/sandbox_paypal_0123456789abcdef01234567", + "currency": "USD", + "amount_minor": 500, + "sandbox": true +} diff --git a/contracts/early-bird-authority/v1/checkout.schema.json b/contracts/early-bird-authority/v1/checkout.schema.json new file mode 100644 index 00000000..098c9f3b --- /dev/null +++ b/contracts/early-bird-authority/v1/checkout.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-authority/v1/checkout.schema.json", + "title": "EarlyBird internal checkout result v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "account_id", + "provider", + "external_subscription_id", + "approval_url", + "currency", + "amount_minor", + "sandbox" + ], + "properties": { + "schema_version": {"const": "early-bird-authority.checkout.v1"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "provider": {"enum": ["paypal", "mercado_pago"]}, + "external_subscription_id": {"type": "string", "minLength": 1, "maxLength": 255}, + "approval_url": { + "type": "string", + "format": "uri", + "minLength": 9, + "maxLength": 2048, + "pattern": "^https://[^\\s/@]+(?:[/?#][^\\s]*)?$" + }, + "currency": {"enum": ["USD", "ARS"]}, + "amount_minor": {"type": "integer", "minimum": 1}, + "sandbox": {"type": "boolean"} + } +} diff --git a/contracts/early-bird-authority/v1/invitation-redeem.fixture.json b/contracts/early-bird-authority/v1/invitation-redeem.fixture.json new file mode 100644 index 00000000..ca3e5d6a --- /dev/null +++ b/contracts/early-bird-authority/v1/invitation-redeem.fixture.json @@ -0,0 +1,5 @@ +{ + "schema_version": "early-bird-authority.invitation-redeem.v1", + "account_id": "account_synthetic_0001", + "invitation_token": "ebi_v1.AAAAAAAAAAAAAAAAAAAAAA.synthetic_nonce_00000000000000000000.synthetic_signature_0000000000000000000000000000000" +} diff --git a/contracts/early-bird-authority/v1/invitation-redeem.schema.json b/contracts/early-bird-authority/v1/invitation-redeem.schema.json new file mode 100644 index 00000000..0cdf0545 --- /dev/null +++ b/contracts/early-bird-authority/v1/invitation-redeem.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-authority/v1/invitation-redeem.schema.json", + "title": "EarlyBird invitation redeem request v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "account_id", "invitation_token"], + "properties": { + "schema_version": {"const": "early-bird-authority.invitation-redeem.v1"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "invitation_token": { + "type": "string", + "minLength": 32, + "maxLength": 512, + "pattern": "^ebi_v1\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$" + } + } +} diff --git a/contracts/early-bird-authority/v1/membership.fixture.json b/contracts/early-bird-authority/v1/membership.fixture.json new file mode 100644 index 00000000..7dadcece --- /dev/null +++ b/contracts/early-bird-authority/v1/membership.fixture.json @@ -0,0 +1,19 @@ +{ + "schema_version": "early-bird-authority.membership.v1", + "account_id": "account_synthetic_0001", + "membership_revision": 1, + "state": "ACTIVE", + "source": "FREE", + "access_allowed": true, + "effective_at": "2026-08-06T12:00:00Z", + "paid_through": null, + "grace_until": null, + "offer": { + "code": "EARLY_BIRDS_FOUNDERS_V1", + "revision": 1 + }, + "provider": null, + "current_price": null, + "free_entitlement_consumed": false, + "reason_code": "INVITATION_REDEEMED" +} diff --git a/contracts/early-bird-authority/v1/membership.schema.json b/contracts/early-bird-authority/v1/membership.schema.json new file mode 100644 index 00000000..ff094b30 --- /dev/null +++ b/contracts/early-bird-authority/v1/membership.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-authority/v1/membership.schema.json", + "title": "EarlyBird canonical membership v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "account_id", + "membership_revision", + "state", + "source", + "access_allowed", + "effective_at", + "paid_through", + "grace_until", + "offer", + "provider", + "current_price", + "free_entitlement_consumed", + "reason_code" + ], + "properties": { + "schema_version": {"const": "early-bird-authority.membership.v1"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "membership_revision": {"type": "integer", "minimum": 1}, + "state": { + "enum": [ + "PENDING", + "ACTIVE", + "GRACE", + "CANCELLED_PENDING_END", + "EXPIRED", + "REFUNDED", + "REVOKED" + ] + }, + "source": {"type": ["string", "null"], "enum": ["FREE", "PAYPAL", "MERCADO_PAGO", null]}, + "access_allowed": {"type": "boolean"}, + "effective_at": {"type": "string", "format": "date-time"}, + "paid_through": {"type": ["string", "null"], "format": "date-time"}, + "grace_until": {"type": ["string", "null"], "format": "date-time"}, + "offer": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["code", "revision"], + "properties": { + "code": {"const": "EARLY_BIRDS_FOUNDERS_V1"}, + "revision": {"type": "integer", "minimum": 1} + } + } + ] + }, + "provider": {"type": ["string", "null"], "enum": ["paypal", "mercado_pago", null]}, + "current_price": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["currency", "amount_minor"], + "properties": { + "currency": {"enum": ["USD", "ARS"]}, + "amount_minor": {"type": "integer", "minimum": 1} + } + } + ] + }, + "free_entitlement_consumed": {"type": "boolean"}, + "reason_code": {"type": "string", "minLength": 1, "maxLength": 64} + } +} diff --git a/contracts/early-bird-authority/v2/README.md b/contracts/early-bird-authority/v2/README.md new file mode 100644 index 00000000..4711209f --- /dev/null +++ b/contracts/early-bird-authority/v2/README.md @@ -0,0 +1,35 @@ +# EarlyBird authority membership read contract v2 — retired experiment + +This contract is retained only as historical evidence. No runtime may use +`founder_price_eligibility`; v3 replaces it with continuity and an irreversible ended tombstone. + +This additive private read contract exposes two independent facts to Listener: + +- the current membership and its server-authoritative `access_allowed` decision; +- the account's durable Founder price eligibility, when one has been earned. + +`GET /api/internal/v2/early-bird-memberships/{account_id}` requires the same private +`Authorization: Bearer ...` and `X-HB-Service-Key-Id` credentials as v1. Successful membership +responses and the generic `membership_not_found` response use `Cache-Control: private, no-store`. +Authentication, disabled-service and path-validation failures retain FastAPI's existing generic +error handling. The browser must never call this endpoint. + +`founder_price_eligibility: null` means that the existing account has not earned Founder pricing. +A non-null object records the immutable canonical USD 5/month offer earned by a confirmed paid +activation. It does not mean that a membership is active, that a payment succeeded recently, or +that access is allowed. Only `access_allowed` authorizes listening. Cancellation, expiry, refund or +revocation can therefore coexist with retained Founder price eligibility. + +Free access, welcome access, invitations, Free For All, checkout redirects and incomplete or +terminal provider events without a prior confirmed activation never create eligibility. +`membership_revision` continues to version membership/access state; it is not an eligibility +revision. The response contains no name, email, OAuth material, payment history, provider event or +subscription identifier, stream URL or secret. + +Every membership/access writer must either take the account row lock or update the account revision +before commit. The v2 read takes a shared account lock so membership and eligibility cannot be +observed across different committed writer states, while concurrent reads remain possible. + +The v1 membership endpoint remains available unchanged for rollback. Invitation redemption and +checkout creation remain on the v1 authority contract; this directory versions only the read +shape added by v2. diff --git a/contracts/early-bird-authority/v2/SHA256SUMS b/contracts/early-bird-authority/v2/SHA256SUMS new file mode 100644 index 00000000..cc6cb1a8 --- /dev/null +++ b/contracts/early-bird-authority/v2/SHA256SUMS @@ -0,0 +1,3 @@ +62834291a980d19864e39604b31cb3451b030d836702c7e4a49ab2b4064f30a4 README.md +361768014bc0b46570c257c9cdec4db2ed0749a953c0994a288fd6f31ff8642f membership.fixture.json +f9c6928ffd79f46a82ddd2ec81d28e79391d09053a1251d518bc41dbb644f0bf membership.schema.json diff --git a/contracts/early-bird-authority/v2/membership.fixture.json b/contracts/early-bird-authority/v2/membership.fixture.json new file mode 100644 index 00000000..ad77fb0a --- /dev/null +++ b/contracts/early-bird-authority/v2/membership.fixture.json @@ -0,0 +1,34 @@ +{ + "schema_version": "early-bird-authority.membership.v2", + "account_id": "account_synthetic_founder_0001", + "membership_revision": 2, + "state": "EXPIRED", + "source": "PAYPAL", + "access_allowed": false, + "effective_at": "2026-08-06T12:00:00Z", + "paid_through": "2026-09-06T12:00:00Z", + "grace_until": null, + "offer": { + "code": "EARLY_BIRDS_FOUNDERS_V1", + "revision": 1 + }, + "provider": "paypal", + "current_price": { + "currency": "USD", + "amount_minor": 500 + }, + "free_entitlement_consumed": true, + "reason_code": "SUBSCRIPTION_CANCELLED", + "founder_price_eligibility": { + "offer": { + "code": "EARLY_BIRDS_FOUNDERS_V1", + "revision": 1 + }, + "canonical_price": { + "currency": "USD", + "amount_minor": 500 + }, + "billing_period": "MONTHLY", + "granted_at": "2026-08-06T12:00:00Z" + } +} diff --git a/contracts/early-bird-authority/v2/membership.schema.json b/contracts/early-bird-authority/v2/membership.schema.json new file mode 100644 index 00000000..349bcf59 --- /dev/null +++ b/contracts/early-bird-authority/v2/membership.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-authority/v2/membership.schema.json", + "title": "EarlyBird canonical membership and Founder price eligibility v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "account_id", + "membership_revision", + "state", + "source", + "access_allowed", + "effective_at", + "paid_through", + "grace_until", + "offer", + "provider", + "current_price", + "free_entitlement_consumed", + "reason_code", + "founder_price_eligibility" + ], + "properties": { + "schema_version": {"const": "early-bird-authority.membership.v2"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "membership_revision": {"type": "integer", "minimum": 1}, + "state": { + "enum": [ + "PENDING", + "ACTIVE", + "GRACE", + "CANCELLED_PENDING_END", + "EXPIRED", + "REFUNDED", + "REVOKED" + ] + }, + "source": {"type": ["string", "null"], "enum": ["FREE", "PAYPAL", "MERCADO_PAGO", null]}, + "access_allowed": {"type": "boolean"}, + "effective_at": {"type": "string", "format": "date-time"}, + "paid_through": {"type": ["string", "null"], "format": "date-time"}, + "grace_until": {"type": ["string", "null"], "format": "date-time"}, + "offer": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["code", "revision"], + "properties": { + "code": {"const": "EARLY_BIRDS_FOUNDERS_V1"}, + "revision": {"type": "integer", "minimum": 1} + } + } + ] + }, + "provider": {"type": ["string", "null"], "enum": ["paypal", "mercado_pago", null]}, + "current_price": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["currency", "amount_minor"], + "properties": { + "currency": {"enum": ["USD", "ARS"]}, + "amount_minor": {"type": "integer", "minimum": 1} + } + } + ] + }, + "free_entitlement_consumed": {"type": "boolean"}, + "reason_code": {"type": "string", "minLength": 1, "maxLength": 64}, + "founder_price_eligibility": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["offer", "canonical_price", "billing_period", "granted_at"], + "properties": { + "offer": { + "type": "object", + "additionalProperties": false, + "required": ["code", "revision"], + "properties": { + "code": {"const": "EARLY_BIRDS_FOUNDERS_V1"}, + "revision": {"type": "integer", "minimum": 1} + } + }, + "canonical_price": { + "type": "object", + "additionalProperties": false, + "required": ["currency", "amount_minor"], + "properties": { + "currency": {"const": "USD"}, + "amount_minor": {"const": 500} + } + }, + "billing_period": {"const": "MONTHLY"}, + "granted_at": {"type": "string", "format": "date-time"} + } + } + ] + } + } +} diff --git a/contracts/early-bird-authority/v3/README.md b/contracts/early-bird-authority/v3/README.md new file mode 100644 index 00000000..69a7e48a --- /dev/null +++ b/contracts/early-bird-authority/v3/README.md @@ -0,0 +1,19 @@ +# EarlyBird authority membership read contract v3 + +`GET /api/internal/v3/early-bird-memberships/{account_id}` returns membership access and one atomic +`founder_continuity` snapshot under the account lock. The object is `null` before a paid Founder +activation. `ACTIVE`, `CANCELLED_PENDING_END`, and `GRACE` preserve the USD 5 monthly category only +inside `service_through`. `ENDED` is the irreversible tombstone for this offer and never authorizes +access, price, or a badge. A later checkout requires a different public offer; absent one, authority +fails closed with `PUBLIC_OFFER_UNAVAILABLE`. + +The same object is embedded byte-exactly in `early-bird-membership.command.v2`. Neither contract +contains PII, provider subscription IDs, redirects, OAuth material, or payment history. Browser +redirects, Free, invitations, promotions, and Free For All never create continuity. + +Cross-field invariants are enforced by the parser because JSON Schema cannot compare sibling +values. `source` and `provider` must pair as `PAYPAL`/`paypal`, +`MERCADO_PAGO`/`mercado_pago`, or `FREE`/`null`; an unset source also requires a null provider. A PayPal continuity snapshot requires +`current_price` to equal its canonical USD 5 price exactly. Mercado Pago continuity keeps the +canonical Founder price in USD while requiring a positive ARS `current_price` derived from the +approved exchange-rate provenance. diff --git a/contracts/early-bird-authority/v3/SHA256SUMS b/contracts/early-bird-authority/v3/SHA256SUMS new file mode 100644 index 00000000..a7e1d1c9 --- /dev/null +++ b/contracts/early-bird-authority/v3/SHA256SUMS @@ -0,0 +1,3 @@ +eb02e9516f52a7e18740b45716abb9f08ac059a61aa3b6b16ac513b58f549816 README.md +c762752472c4604f9363cb329f66f7064b6b07590d447b26968877115589d7c3 membership.fixture.json +f0acbb2662130e477a0809ecc6011a49d3d3ae14e165b02b6e78a5ff662516b9 membership.schema.json diff --git a/contracts/early-bird-authority/v3/membership.fixture.json b/contracts/early-bird-authority/v3/membership.fixture.json new file mode 100644 index 00000000..91417136 --- /dev/null +++ b/contracts/early-bird-authority/v3/membership.fixture.json @@ -0,0 +1,28 @@ +{ + "schema_version": "early-bird-authority.membership.v3", + "account_id": "account_synthetic_founder_0001", + "membership_revision": 3, + "state": "EXPIRED", + "source": "PAYPAL", + "access_allowed": false, + "effective_at": "2026-08-06T12:00:00Z", + "paid_through": "2026-09-06T12:00:00Z", + "grace_until": null, + "offer": {"code": "EARLY_BIRDS_FOUNDERS_V1", "revision": 1}, + "provider": "paypal", + "current_price": {"currency": "USD", "amount_minor": 500}, + "free_entitlement_consumed": true, + "reason_code": "PERIOD_ENDED", + "founder_continuity": { + "episode_id": "eb300000-0000-4000-8000-000000000001", + "revision": 4, + "state": "ENDED", + "offer": {"code": "EARLY_BIRDS_FOUNDERS_V1", "revision": 1}, + "canonical_price": {"currency": "USD", "amount_minor": 500}, + "billing_period": "MONTHLY", + "activated_at": "2026-08-06T12:00:00Z", + "service_through": "2026-09-06T12:00:00Z", + "ended_at": "2026-09-06T12:00:01Z", + "terminal_reason": "PERIOD_ENDED" + } +} diff --git a/contracts/early-bird-authority/v3/membership.schema.json b/contracts/early-bird-authority/v3/membership.schema.json new file mode 100644 index 00000000..79acecb3 --- /dev/null +++ b/contracts/early-bird-authority/v3/membership.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-authority/v3/membership.schema.json", + "title": "EarlyBird canonical membership and Founder continuity v3", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "account_id", "membership_revision", "state", "source", "access_allowed", "effective_at", "paid_through", "grace_until", "offer", "provider", "current_price", "free_entitlement_consumed", "reason_code", "founder_continuity"], + "properties": { + "schema_version": {"const": "early-bird-authority.membership.v3"}, + "account_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$"}, + "membership_revision": {"type": "integer", "minimum": 1}, + "state": {"enum": ["PENDING", "ACTIVE", "GRACE", "CANCELLED_PENDING_END", "EXPIRED", "REFUNDED", "REVOKED"]}, + "source": {"type": ["string", "null"], "enum": ["FREE", "PAYPAL", "MERCADO_PAGO", null]}, + "access_allowed": {"type": "boolean"}, + "effective_at": {"type": "string", "format": "date-time"}, + "paid_through": {"type": ["string", "null"], "format": "date-time"}, + "grace_until": {"type": ["string", "null"], "format": "date-time"}, + "offer": {"oneOf": [{"type": "null"}, {"type": "object", "additionalProperties": false, "required": ["code", "revision"], "properties": {"code": {"type": "string", "minLength": 1, "maxLength": 128}, "revision": {"type": "integer", "minimum": 1}}}]}, + "provider": {"type": ["string", "null"], "enum": ["paypal", "mercado_pago", null]}, + "current_price": {"oneOf": [{"type": "null"}, {"type": "object", "additionalProperties": false, "required": ["currency", "amount_minor"], "properties": {"currency": {"enum": ["USD", "ARS"]}, "amount_minor": {"type": "integer", "minimum": 1}}}]}, + "free_entitlement_consumed": {"type": "boolean"}, + "reason_code": {"type": "string", "minLength": 1, "maxLength": 64}, + "founder_continuity": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["episode_id", "revision", "state", "offer", "canonical_price", "billing_period", "activated_at", "service_through", "ended_at", "terminal_reason"], + "properties": { + "episode_id": {"type": "string", "format": "uuid"}, + "revision": {"type": "integer", "minimum": 1}, + "state": {"enum": ["ACTIVE", "CANCELLED_PENDING_END", "GRACE", "ENDED"]}, + "offer": {"type": "object", "additionalProperties": false, "required": ["code", "revision"], "properties": {"code": {"const": "EARLY_BIRDS_FOUNDERS_V1"}, "revision": {"type": "integer", "minimum": 1}}}, + "canonical_price": {"type": "object", "additionalProperties": false, "required": ["currency", "amount_minor"], "properties": {"currency": {"const": "USD"}, "amount_minor": {"const": 500}}}, + "billing_period": {"const": "MONTHLY"}, + "activated_at": {"type": "string", "format": "date-time"}, + "service_through": {"type": ["string", "null"], "format": "date-time"}, + "ended_at": {"type": ["string", "null"], "format": "date-time"}, + "terminal_reason": {"type": ["string", "null"], "minLength": 1, "maxLength": 64} + }, + "allOf": [ + {"if": {"properties": {"state": {"const": "ENDED"}}}, "then": {"properties": {"ended_at": {"type": "string", "format": "date-time"}, "terminal_reason": {"type": "string", "minLength": 1, "maxLength": 64}}}, "else": {"properties": {"service_through": {"type": "string", "format": "date-time"}, "ended_at": {"type": "null"}, "terminal_reason": {"type": "null"}}}} + ] + } + ] + } + } +} diff --git a/contracts/early-bird-checkout/v2/README.md b/contracts/early-bird-checkout/v2/README.md new file mode 100644 index 00000000..e59720ca --- /dev/null +++ b/contracts/early-bird-checkout/v2/README.md @@ -0,0 +1,18 @@ +# EarlyBird checkout command v2 + +This private contract is separate from the byte-vendored `early-bird-authority` membership-read +family. It adds the Mercado Pago checkout input required by +`POST /api/internal/v2/early-bird-checkouts` without changing authority v1 or v2. + +Mercado Pago requires a normalized `payer_email`. Its plaintext is transient: the backend sends +it only in the provider request and excludes it from authority responses, checkout bindings, +provider events, jobs and logs. The durable intent hash includes only keyed HMAC evidence, so a +retry with another payer fails closed without making the address recoverable. Keep the previous +signing key until pending checkout intents have completed or expired before rotating it. +The command is restricted to +`provider=mercado_pago`; PayPal continues to use the authority v1 checkout command. + +The route remains protected by the private EarlyBird authority credentials and the independent +paid-checkout gate. Provider configuration is TEST-only and disabled by default. +The authority v1 checkout route fails closed for Mercado Pago because it cannot carry the required +transient payer email; PayPal remains on v1 without semantic changes. diff --git a/contracts/early-bird-checkout/v2/SHA256SUMS b/contracts/early-bird-checkout/v2/SHA256SUMS new file mode 100644 index 00000000..7da2d4c8 --- /dev/null +++ b/contracts/early-bird-checkout/v2/SHA256SUMS @@ -0,0 +1,3 @@ +a0008e822c60c4d8b7804da90c411c7f07a1b0457b74192260a5d8a14617a191 README.md +128a8b6e1e91604db276ba4b9e4bc8f592ddacadffec19b2d2f037f1dc8d9c87 checkout-create.fixture.json +c3e274f4cdc94ffece082382fbe2d063bb6f55e21aeba7df94a396a52871c67a checkout-create.schema.json diff --git a/contracts/early-bird-checkout/v2/checkout-create.fixture.json b/contracts/early-bird-checkout/v2/checkout-create.fixture.json new file mode 100644 index 00000000..ab63c6ac --- /dev/null +++ b/contracts/early-bird-checkout/v2/checkout-create.fixture.json @@ -0,0 +1,8 @@ +{ + "schema_version": "early-bird-checkout.checkout-create.v2", + "account_id": "acct_listener_synthetic_0001", + "provider": "mercado_pago", + "payer_email": "listener@example.test", + "return_url": "https://listen.harmonicbeacon.com/membership/return", + "cancel_url": "https://listen.harmonicbeacon.com/membership/cancel" +} diff --git a/contracts/early-bird-checkout/v2/checkout-create.schema.json b/contracts/early-bird-checkout/v2/checkout-create.schema.json new file mode 100644 index 00000000..b121ae38 --- /dev/null +++ b/contracts/early-bird-checkout/v2/checkout-create.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-checkout/v2/checkout-create.schema.json", + "title": "EarlyBird Mercado Pago checkout creation command v2", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "account_id", "provider", "payer_email", "return_url", "cancel_url"], + "properties": { + "schema_version": {"const": "early-bird-checkout.checkout-create.v2"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "provider": {"const": "mercado_pago"}, + "payer_email": {"type": "string", "minLength": 3, "maxLength": 320, "pattern": "^[^@\\sA-Z]+@[^@\\sA-Z]+$"}, + "return_url": {"type": "string", "format": "uri", "pattern": "^https://", "maxLength": 2048}, + "cancel_url": {"type": "string", "format": "uri", "pattern": "^https://", "maxLength": 2048} + } +} diff --git a/contracts/early-bird-membership/v1/README.md b/contracts/early-bird-membership/v1/README.md new file mode 100644 index 00000000..510ad852 --- /dev/null +++ b/contracts/early-bird-membership/v1/README.md @@ -0,0 +1,29 @@ +# EarlyBird membership contract v1 + +Contrato privado entre el módulo canónico de membresías de PMP Myth Bot y la proyección revocable +de Beacon. + +## Transporte + +- `PUT /api/internal/v1/early-bird-memberships/{account_id}` aplica `command.schema.json`. +- `GET /api/internal/v1/early-bird-memberships/{account_id}` devuelve `result.schema.json`. +- Ambos endpoints viven sólo en la red privada y exigen `Authorization: Bearer ...` más + `X-HB-Service-Key-Id`. +- PUT exige `Idempotency-Key: early-bird-membership:{account_id}:{membership_revision}`. + +## Semántica + +`membership_revision` aumenta exclusivamente ante una transición material. Beacon aplica una +revisión nueva, reproduce una idéntica y responde `STALE` a una anterior. El navegador, un redirect +de checkout y el proveedor de pagos nunca son fuente de acceso. + +`ACTIVE`, `GRACE` y `CANCELLED_PENDING_END` permiten acceso sólo dentro de sus límites temporales. +Los restantes estados fallan cerrados. `current_price` informa el importe vigente y no autoriza un +cobro. El comando no contiene email, nombre, tokens OAuth, URLs firmadas ni datos de menores. + +El hash de comando usa JCS/RFC 8785 y SHA-256 sobre exactamente los doce campos requeridos. Los +archivos cubiertos por `SHA256SUMS` deben copiarse byte-equivalentes al repositorio Beacon. + +La autoridad sólo considera aplicada una proyección cuando Beacon confirma revisión suficiente y +un outcome, estado, acceso y `reconciliation_required=false` coherentes con una revisión local +conocida. Una respuesta atrasada o contradictoria permanece reintentable y emite alerta. diff --git a/contracts/early-bird-membership/v1/SHA256SUMS b/contracts/early-bird-membership/v1/SHA256SUMS new file mode 100644 index 00000000..a5be16f3 --- /dev/null +++ b/contracts/early-bird-membership/v1/SHA256SUMS @@ -0,0 +1,5 @@ +d3cd62086dc661acb4532fd725e592e70ddac565a490a4732d7d32321edc8d17 README.md +5683d997fdb8b0e7c2a44c8a2dbd9161571296f22e356de232606d71b99999a2 command.fixture.json +32aa0ee5222ba85d56e6f0baca822e0072d8db1059cdc3d73193a9ab4a9b89c0 command.schema.json +a870cb5590a582ae5408c41cc4414c7bd3804b7efbd11f4e82a3fe769707f682 result.fixture.json +7e00175cb5a48391f1c4823e161a30a7cbab902b3c63c99748f839263f428454 result.schema.json diff --git a/contracts/early-bird-membership/v1/command.fixture.json b/contracts/early-bird-membership/v1/command.fixture.json new file mode 100644 index 00000000..ca767528 --- /dev/null +++ b/contracts/early-bird-membership/v1/command.fixture.json @@ -0,0 +1,20 @@ +{ + "schema_version": "early-bird-membership.command.v1", + "account_id": "account_synthetic_0001", + "membership_revision": 3, + "state": "ACTIVE", + "source": "PAYPAL", + "offer": { + "code": "EARLY_BIRDS_FOUNDERS_V1", + "revision": 1 + }, + "effective_at": "2026-08-06T12:00:00Z", + "paid_through": "2026-09-06T12:00:00Z", + "grace_until": null, + "provider": "paypal", + "current_price": { + "currency": "USD", + "amount_minor": 500 + }, + "reason_code": "PAYMENT_SUCCEEDED" +} diff --git a/contracts/early-bird-membership/v1/command.schema.json b/contracts/early-bird-membership/v1/command.schema.json new file mode 100644 index 00000000..e7597e27 --- /dev/null +++ b/contracts/early-bird-membership/v1/command.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-membership/v1/command.schema.json", + "title": "EarlyBird membership command v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "account_id", + "membership_revision", + "state", + "source", + "offer", + "effective_at", + "paid_through", + "grace_until", + "provider", + "current_price", + "reason_code" + ], + "properties": { + "schema_version": {"const": "early-bird-membership.command.v1"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "membership_revision": {"type": "integer", "minimum": 1}, + "state": { + "enum": [ + "PENDING", + "ACTIVE", + "GRACE", + "CANCELLED_PENDING_END", + "EXPIRED", + "REFUNDED", + "REVOKED" + ] + }, + "source": {"type": ["string", "null"], "enum": ["FREE", "PAYPAL", "MERCADO_PAGO", null]}, + "offer": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["code", "revision"], + "properties": { + "code": {"const": "EARLY_BIRDS_FOUNDERS_V1"}, + "revision": {"type": "integer", "minimum": 1} + } + } + ] + }, + "effective_at": {"type": "string", "format": "date-time"}, + "paid_through": {"type": ["string", "null"], "format": "date-time"}, + "grace_until": {"type": ["string", "null"], "format": "date-time"}, + "provider": {"type": ["string", "null"], "enum": ["paypal", "mercado_pago", null]}, + "current_price": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["currency", "amount_minor"], + "properties": { + "currency": {"enum": ["USD", "ARS"]}, + "amount_minor": {"type": "integer", "minimum": 1} + } + } + ] + }, + "reason_code": {"type": "string", "minLength": 1, "maxLength": 64} + } +} diff --git a/contracts/early-bird-membership/v1/result.fixture.json b/contracts/early-bird-membership/v1/result.fixture.json new file mode 100644 index 00000000..89cbd5ed --- /dev/null +++ b/contracts/early-bird-membership/v1/result.fixture.json @@ -0,0 +1,10 @@ +{ + "schema_version": "early-bird-membership.result.v1", + "membership_id": "eb100000-0000-4000-8000-000000000001", + "account_id": "account_synthetic_0001", + "outcome": "APPLIED", + "applied_revision": 3, + "effective_state": "ACTIVE", + "access_allowed": true, + "reconciliation_required": false +} diff --git a/contracts/early-bird-membership/v1/result.schema.json b/contracts/early-bird-membership/v1/result.schema.json new file mode 100644 index 00000000..66c81068 --- /dev/null +++ b/contracts/early-bird-membership/v1/result.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-membership/v1/result.schema.json", + "title": "EarlyBird membership result v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "membership_id", + "account_id", + "outcome", + "applied_revision", + "effective_state", + "access_allowed", + "reconciliation_required" + ], + "properties": { + "schema_version": {"const": "early-bird-membership.result.v1"}, + "membership_id": {"type": "string", "format": "uuid"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "outcome": {"enum": ["APPLIED", "REPLAYED", "STALE"]}, + "applied_revision": {"type": "integer", "minimum": 1}, + "effective_state": { + "enum": [ + "PENDING", + "ACTIVE", + "GRACE", + "CANCELLED_PENDING_END", + "EXPIRED", + "REFUNDED", + "REVOKED" + ] + }, + "access_allowed": {"type": "boolean"}, + "reconciliation_required": {"type": "boolean"} + } +} diff --git a/contracts/early-bird-membership/v2/README.md b/contracts/early-bird-membership/v2/README.md new file mode 100644 index 00000000..4dd16e86 --- /dev/null +++ b/contracts/early-bird-membership/v2/README.md @@ -0,0 +1,17 @@ +# EarlyBird membership projection contract v2 + +`PUT /api/internal/v2/early-bird-memberships/{account_id}` atomically projects membership and the +Founder continuity snapshot for one `membership_revision`. The command uses RFC 8785/JCS and +SHA-256 exactly like v1. Listener applies both facts in one transaction; it must never infer +Founder from redirects, provider IDs, cookies, email, Free, invitations, or FFA. + +An `ENDED` episode is an irreversible audit tombstone and removes the Founder badge and price. +The result contract remains `early-bird-membership.result.v1` because acknowledgement semantics do +not change. + +Cross-field invariants are enforced by the parser because JSON Schema cannot compare sibling +values. `source` and `provider` must pair as `PAYPAL`/`paypal`, +`MERCADO_PAGO`/`mercado_pago`, or `FREE`/`null`; an unset source also requires a null provider. A PayPal continuity snapshot requires +`current_price` to equal its canonical USD 5 price exactly. Mercado Pago continuity keeps the +canonical Founder price in USD while requiring a positive ARS `current_price` derived from the +approved exchange-rate provenance. diff --git a/contracts/early-bird-membership/v2/SHA256SUMS b/contracts/early-bird-membership/v2/SHA256SUMS new file mode 100644 index 00000000..a5244434 --- /dev/null +++ b/contracts/early-bird-membership/v2/SHA256SUMS @@ -0,0 +1,5 @@ +61855b5e731159d8c6943262e6bf6a87ca84d403031ffb2fa8abce8256ba18b4 README.md +03efd74e0e9384a170618c19c3f7f2253032d8eaad1a29cec21c858253eebca9 command.fixture.json +6ad90479301a060ce282449e54642ddc5492c09cacc778eda2c961169e5c3f58 command.schema.json +a870cb5590a582ae5408c41cc4414c7bd3804b7efbd11f4e82a3fe769707f682 result.fixture.json +06e6e3616c41391103fae70f6649dbf5a0b21fab24d9e8d3d332a6b2abebab50 result.schema.json diff --git a/contracts/early-bird-membership/v2/command.fixture.json b/contracts/early-bird-membership/v2/command.fixture.json new file mode 100644 index 00000000..0e21b742 --- /dev/null +++ b/contracts/early-bird-membership/v2/command.fixture.json @@ -0,0 +1,26 @@ +{ + "schema_version": "early-bird-membership.command.v2", + "account_id": "account_synthetic_0001", + "membership_revision": 3, + "state": "ACTIVE", + "source": "PAYPAL", + "offer": {"code": "EARLY_BIRDS_FOUNDERS_V1", "revision": 1}, + "effective_at": "2026-08-06T12:00:00Z", + "paid_through": "2026-09-06T12:00:00Z", + "grace_until": null, + "provider": "paypal", + "current_price": {"currency": "USD", "amount_minor": 500}, + "reason_code": "PAYMENT_SUCCEEDED", + "founder_continuity": { + "episode_id": "eb300000-0000-4000-8000-000000000001", + "revision": 2, + "state": "ACTIVE", + "offer": {"code": "EARLY_BIRDS_FOUNDERS_V1", "revision": 1}, + "canonical_price": {"currency": "USD", "amount_minor": 500}, + "billing_period": "MONTHLY", + "activated_at": "2026-08-06T12:00:00Z", + "service_through": "2026-09-06T12:00:00Z", + "ended_at": null, + "terminal_reason": null + } +} diff --git a/contracts/early-bird-membership/v2/command.schema.json b/contracts/early-bird-membership/v2/command.schema.json new file mode 100644 index 00000000..7d495264 --- /dev/null +++ b/contracts/early-bird-membership/v2/command.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-membership/v2/command.schema.json", + "title": "EarlyBird membership and Founder continuity command v2", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "account_id", "membership_revision", "state", "source", "offer", "effective_at", "paid_through", "grace_until", "provider", "current_price", "reason_code", "founder_continuity"], + "properties": { + "schema_version": {"const": "early-bird-membership.command.v2"}, + "account_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$"}, + "membership_revision": {"type": "integer", "minimum": 1}, + "state": {"enum": ["PENDING", "ACTIVE", "GRACE", "CANCELLED_PENDING_END", "EXPIRED", "REFUNDED", "REVOKED"]}, + "source": {"type": ["string", "null"], "enum": ["FREE", "PAYPAL", "MERCADO_PAGO", null]}, + "offer": {"oneOf": [{"type": "null"}, {"type": "object", "additionalProperties": false, "required": ["code", "revision"], "properties": {"code": {"type": "string", "minLength": 1, "maxLength": 128}, "revision": {"type": "integer", "minimum": 1}}}]}, + "effective_at": {"type": "string", "format": "date-time"}, + "paid_through": {"type": ["string", "null"], "format": "date-time"}, + "grace_until": {"type": ["string", "null"], "format": "date-time"}, + "provider": {"type": ["string", "null"], "enum": ["paypal", "mercado_pago", null]}, + "current_price": {"oneOf": [{"type": "null"}, {"type": "object", "additionalProperties": false, "required": ["currency", "amount_minor"], "properties": {"currency": {"enum": ["USD", "ARS"]}, "amount_minor": {"type": "integer", "minimum": 1}}}]}, + "reason_code": {"type": "string", "minLength": 1, "maxLength": 64}, + "founder_continuity": { + "oneOf": [ + {"type": "null"}, + {"type": "object", "additionalProperties": false, "required": ["episode_id", "revision", "state", "offer", "canonical_price", "billing_period", "activated_at", "service_through", "ended_at", "terminal_reason"], "properties": { + "episode_id": {"type": "string", "format": "uuid"}, + "revision": {"type": "integer", "minimum": 1}, + "state": {"enum": ["ACTIVE", "CANCELLED_PENDING_END", "GRACE", "ENDED"]}, + "offer": {"type": "object", "additionalProperties": false, "required": ["code", "revision"], "properties": {"code": {"const": "EARLY_BIRDS_FOUNDERS_V1"}, "revision": {"type": "integer", "minimum": 1}}}, + "canonical_price": {"type": "object", "additionalProperties": false, "required": ["currency", "amount_minor"], "properties": {"currency": {"const": "USD"}, "amount_minor": {"const": 500}}}, + "billing_period": {"const": "MONTHLY"}, + "activated_at": {"type": "string", "format": "date-time"}, + "service_through": {"type": ["string", "null"], "format": "date-time"}, + "ended_at": {"type": ["string", "null"], "format": "date-time"}, + "terminal_reason": {"type": ["string", "null"], "minLength": 1, "maxLength": 64} + }, "allOf": [ + {"if": {"properties": {"state": {"const": "ENDED"}}}, "then": {"properties": {"ended_at": {"type": "string", "format": "date-time"}, "terminal_reason": {"type": "string", "minLength": 1, "maxLength": 64}}}, "else": {"properties": {"service_through": {"type": "string", "format": "date-time"}, "ended_at": {"type": "null"}, "terminal_reason": {"type": "null"}}}} + ]} + ] + } + } +} diff --git a/contracts/early-bird-membership/v2/result.fixture.json b/contracts/early-bird-membership/v2/result.fixture.json new file mode 100644 index 00000000..89cbd5ed --- /dev/null +++ b/contracts/early-bird-membership/v2/result.fixture.json @@ -0,0 +1,10 @@ +{ + "schema_version": "early-bird-membership.result.v1", + "membership_id": "eb100000-0000-4000-8000-000000000001", + "account_id": "account_synthetic_0001", + "outcome": "APPLIED", + "applied_revision": 3, + "effective_state": "ACTIVE", + "access_allowed": true, + "reconciliation_required": false +} diff --git a/contracts/early-bird-membership/v2/result.schema.json b/contracts/early-bird-membership/v2/result.schema.json new file mode 100644 index 00000000..5a877bc1 --- /dev/null +++ b/contracts/early-bird-membership/v2/result.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/early-bird-membership/v2/result.schema.json", + "title": "EarlyBird membership result v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "membership_id", "account_id", "outcome", "applied_revision", "effective_state", "access_allowed", "reconciliation_required"], + "properties": { + "schema_version": {"const": "early-bird-membership.result.v1"}, + "membership_id": {"type": "string", "format": "uuid"}, + "account_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$"}, + "outcome": {"enum": ["APPLIED", "REPLAYED", "STALE"]}, + "applied_revision": {"type": "integer", "minimum": 1}, + "effective_state": {"enum": ["PENDING", "ACTIVE", "GRACE", "CANCELLED_PENDING_END", "EXPIRED", "REFUNDED", "REVOKED"]}, + "access_allowed": {"type": "boolean"}, + "reconciliation_required": {"type": "boolean"} + } +} diff --git a/contracts/listener-account-mail/v1/README.md b/contracts/listener-account-mail/v1/README.md new file mode 100644 index 00000000..52c4d442 --- /dev/null +++ b/contracts/listener-account-mail/v1/README.md @@ -0,0 +1,35 @@ +# Listener Account Mail private contract v1 + +This contract lets the Account service enqueue email verification, authenticated email-change verification, and password-reset messages in the isolated Listener mail sidecar. Account remains the sole token and reauthentication authority. The sidecar validates and delivers an exact action URL; it never creates, hashes, consumes, or introspects a token. + +## Endpoint + +`POST http://listener-mail-api:8765/api/internal/v1/listener-account-mail/deliver` + +The endpoint exists only on the private Compose network. It requires: + +- `Host: listener-mail-api:8765` and no forwarding headers; +- `Authorization: Bearer `; +- `Idempotency-Key: <64 lowercase hexadecimal characters>`; +- `Content-Type: application/json`. + +An accepted request returns HTTP 202 and the body in `accepted.schema.json`. An exact replay is accepted without creating another delivery. Reusing a key with different request content returns HTTP 409. + +## Request variants + +- `email-verification.schema.json`: `listener-email-verification.v1` / `verify_email` / `/verify-email`. +- `email-change.schema.json`: `listener-email-change.v1` / `change_email` / `/verify-email`. +- `password-reset.schema.json`: `listener-password-reset.v1` / `reset_password` / `/reset-password`. + +All three variants accept only HTTPS URLs on `account.harmonicbeacon.com` or `account-staging.harmonicbeacon.com`, the exact purpose path, and a single `token` query parameter. Userinfo, fragments, explicit ports, extra parameters, and expiries beyond 15 minutes are rejected. Recipient addresses must be normalized lowercase ASCII-control-free values. Locale is exactly `es` or `en`. + +The sidecar requires two distinct secrets: + +- `PMP_MYTH_LISTENER_ACCOUNT_MAIL_PRODUCTION_DELIVERY_TOKEN` authorizes only `account.harmonicbeacon.com` action URLs; +- `PMP_MYTH_LISTENER_ACCOUNT_MAIL_STAGING_DELIVERY_TOKEN` authorizes only `account-staging.harmonicbeacon.com` action URLs. + +Both must be present, at least 32 characters, and different. Missing/reused secrets disable the endpoint. Unknown bearer values return 401 and cross-issuer action URLs return 403. The derived issuer is part of the request digest and durable idempotency namespace. There is no legacy shared token. + +The worker sends from the already authorized sender address with display name `Harmonic Beacon`. Provider timeouts with an ambiguous outcome are terminal and observable; they are never automatically replayed. On success, permanent failure, ambiguity, crash recovery, or exhausted retries, the durable Job removes the action URL and retains only delivery ID, purpose, contract version, request digest, and a scrub marker. + +`SHA256SUMS` covers the schemas and fixtures byte-for-byte. diff --git a/contracts/listener-account-mail/v1/SHA256SUMS b/contracts/listener-account-mail/v1/SHA256SUMS new file mode 100644 index 00000000..cb1494e8 --- /dev/null +++ b/contracts/listener-account-mail/v1/SHA256SUMS @@ -0,0 +1,9 @@ +bd6541afc6bb18e6652afc55359735dccc9ca2b9a9a568bb69be8d2a021b3079 README.md +f4377fa1f8af4ff4040e965407c7cfdd7572dd15f3f9b48845c270d0928c9b47 accepted.schema.json +2e92bb1edc526fa2cd2656b69b6bbf55c7be95f55d65c380c7e288f7efb69aee email-change.schema.json +aa5c171e8ef5af58b9b049ac51114093f4761cd49bcd61ea7be2c424101fc517 email-verification.schema.json +eab7cee30109b61198a9ab9b5bf396b842d68ca5396fa6161ada7ba0795511a2 fixtures/accepted.json +3ab94cfb16e3c17ea54d695c445570455341a618fe2741a22cbd1d482c315448 fixtures/email-change.json +bb403872bb2f79b0bae60c29582bf2cdc0effca955ef28dced76c2a95e810bee fixtures/email-verification.json +774f911c5ad1ada5aafba74c86b3d826d400e82e4e7d211e9e809f35f6123b0f fixtures/password-reset.json +3893e90a6d07bd081de023a0e0ff7c47cb2c4bd892e24f1bb9f9f999c7a21079 password-reset.schema.json diff --git a/contracts/listener-account-mail/v1/accepted.schema.json b/contracts/listener-account-mail/v1/accepted.schema.json new file mode 100644 index 00000000..725858e1 --- /dev/null +++ b/contracts/listener-account-mail/v1/accepted.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.harmonicbeacon.com/listener-account-mail/v1/accepted.schema.json", + "title": "Listener Account mail accepted response v1", + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { + "status": { "const": "accepted" } + } +} diff --git a/contracts/listener-account-mail/v1/email-change.schema.json b/contracts/listener-account-mail/v1/email-change.schema.json new file mode 100644 index 00000000..090263ec --- /dev/null +++ b/contracts/listener-account-mail/v1/email-change.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.harmonicbeacon.com/listener-account-mail/v1/email-change.schema.json", + "title": "Listener Account email change delivery v1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "purpose", + "recipient", + "locale", + "action_url", + "expires_at" + ], + "properties": { + "contract_version": { "const": "listener-email-change.v1" }, + "purpose": { "const": "change_email" }, + "recipient": { + "type": "string", + "format": "email", + "minLength": 3, + "maxLength": 320, + "pattern": "^[^\\s@]+@[^\\s@]+$" + }, + "locale": { "enum": ["es", "en"] }, + "action_url": { + "type": "string", + "format": "uri", + "pattern": "^https://account(?:-staging)?\\.harmonicbeacon\\.com/verify-email\\?token=[A-Za-z0-9._~-]{20,2048}$" + }, + "expires_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/listener-account-mail/v1/email-verification.schema.json b/contracts/listener-account-mail/v1/email-verification.schema.json new file mode 100644 index 00000000..c7d74e15 --- /dev/null +++ b/contracts/listener-account-mail/v1/email-verification.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.harmonicbeacon.com/listener-account-mail/v1/email-verification.schema.json", + "title": "Listener Account email verification delivery v1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "purpose", + "recipient", + "locale", + "action_url", + "expires_at" + ], + "properties": { + "contract_version": { "const": "listener-email-verification.v1" }, + "purpose": { "const": "verify_email" }, + "recipient": { + "type": "string", + "format": "email", + "minLength": 3, + "maxLength": 320, + "pattern": "^[^\\s@]+@[^\\s@]+$" + }, + "locale": { "enum": ["es", "en"] }, + "action_url": { + "type": "string", + "format": "uri", + "pattern": "^https://account(?:-staging)?\\.harmonicbeacon\\.com/verify-email\\?token=[A-Za-z0-9._~-]{20,2048}$" + }, + "expires_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/listener-account-mail/v1/fixtures/accepted.json b/contracts/listener-account-mail/v1/fixtures/accepted.json new file mode 100644 index 00000000..1fb871d2 --- /dev/null +++ b/contracts/listener-account-mail/v1/fixtures/accepted.json @@ -0,0 +1,3 @@ +{ + "status": "accepted" +} diff --git a/contracts/listener-account-mail/v1/fixtures/email-change.json b/contracts/listener-account-mail/v1/fixtures/email-change.json new file mode 100644 index 00000000..28f0f176 --- /dev/null +++ b/contracts/listener-account-mail/v1/fixtures/email-change.json @@ -0,0 +1,8 @@ +{ + "contract_version": "listener-email-change.v1", + "purpose": "change_email", + "recipient": "new-address@example.test", + "locale": "en", + "action_url": "https://account-staging.harmonicbeacon.com/verify-email?token=opaque_example_token_123456", + "expires_at": "2026-08-17T16:15:00Z" +} diff --git a/contracts/listener-account-mail/v1/fixtures/email-verification.json b/contracts/listener-account-mail/v1/fixtures/email-verification.json new file mode 100644 index 00000000..5a250a54 --- /dev/null +++ b/contracts/listener-account-mail/v1/fixtures/email-verification.json @@ -0,0 +1,8 @@ +{ + "contract_version": "listener-email-verification.v1", + "purpose": "verify_email", + "recipient": "listener@example.test", + "locale": "en", + "action_url": "https://account-staging.harmonicbeacon.com/verify-email?token=opaque_example_token_123456", + "expires_at": "2026-08-17T16:15:00Z" +} diff --git a/contracts/listener-account-mail/v1/fixtures/password-reset.json b/contracts/listener-account-mail/v1/fixtures/password-reset.json new file mode 100644 index 00000000..e610ecb2 --- /dev/null +++ b/contracts/listener-account-mail/v1/fixtures/password-reset.json @@ -0,0 +1,8 @@ +{ + "contract_version": "listener-password-reset.v1", + "purpose": "reset_password", + "recipient": "listener@example.test", + "locale": "es", + "action_url": "https://account-staging.harmonicbeacon.com/reset-password?token=opaque_example_token_123456", + "expires_at": "2026-08-17T16:15:00Z" +} diff --git a/contracts/listener-account-mail/v1/password-reset.schema.json b/contracts/listener-account-mail/v1/password-reset.schema.json new file mode 100644 index 00000000..333975c7 --- /dev/null +++ b/contracts/listener-account-mail/v1/password-reset.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.harmonicbeacon.com/listener-account-mail/v1/password-reset.schema.json", + "title": "Listener Account password reset delivery v1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "purpose", + "recipient", + "locale", + "action_url", + "expires_at" + ], + "properties": { + "contract_version": { "const": "listener-password-reset.v1" }, + "purpose": { "const": "reset_password" }, + "recipient": { + "type": "string", + "format": "email", + "minLength": 3, + "maxLength": 320, + "pattern": "^[^\\s@]+@[^\\s@]+$" + }, + "locale": { "enum": ["es", "en"] }, + "action_url": { + "type": "string", + "format": "uri", + "pattern": "^https://account(?:-staging)?\\.harmonicbeacon\\.com/reset-password\\?token=[A-Za-z0-9._~-]{20,2048}$" + }, + "expires_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/listener-checkout/v1/README.md b/contracts/listener-checkout/v1/README.md new file mode 100644 index 00000000..251ab278 --- /dev/null +++ b/contracts/listener-checkout/v1/README.md @@ -0,0 +1,14 @@ +# Listener checkout v1 + +Private server-to-server contract for creating a production Founding Listener checkout. It is +available only when the Listener authority, the selected Live provider lifecycle and the separate +new-sales gate are ready. The account and payer identity come from the authenticated Listener +session; provider IDs and secrets never cross to the browser. + +`payer_email` is required only for Mercado Pago and forbidden for PayPal. It is transient and must +not be logged or persisted in plaintext. The response contains a provider-approved HTTPS URL but +no subscription ID. `environment` is fixed to `live`; Sandbox/TEST use their isolated experimental +contracts and routes. + +Redirects never grant membership. Only a signed, correlated provider event and subsequent +canonical projection can authorize Listener access. diff --git a/contracts/listener-checkout/v1/SHA256SUMS b/contracts/listener-checkout/v1/SHA256SUMS new file mode 100644 index 00000000..eaed318f --- /dev/null +++ b/contracts/listener-checkout/v1/SHA256SUMS @@ -0,0 +1,5 @@ +624ae2032f5d7131c6db30e2fe4b52985a1797e3211b4644f5385022d28dfe2d README.md +1ab66d0d15762164a2275fca8fdd202a36b44996dc6815752125c3371d7f293a checkout-create.fixture.json +0cbcd581b42a3a833321a6a3565244e66443c031cb813ab4c9d6aeecf3585fd9 checkout-create.schema.json +f616942eef4ff831eea6329b30256d99996e327e8ab2653c629ed36fd0c8cc3d checkout.fixture.json +d7082abfd9c374eab3fdd84c93b64237067e6bff3fdb088550e746b656658b36 checkout.schema.json diff --git a/contracts/listener-checkout/v1/checkout-create.fixture.json b/contracts/listener-checkout/v1/checkout-create.fixture.json new file mode 100644 index 00000000..acb59b3a --- /dev/null +++ b/contracts/listener-checkout/v1/checkout-create.fixture.json @@ -0,0 +1,8 @@ +{ + "schema_version": "listener-checkout.checkout-create.v1", + "account_id": "acct_listener_synthetic_0001", + "provider": "mercado_pago", + "payer_email": "listener@example.test", + "return_url": "https://listen.harmonicbeacon.com/membership/return", + "cancel_url": "https://listen.harmonicbeacon.com/membership/cancel" +} diff --git a/contracts/listener-checkout/v1/checkout-create.schema.json b/contracts/listener-checkout/v1/checkout-create.schema.json new file mode 100644 index 00000000..cb6d91d9 --- /dev/null +++ b/contracts/listener-checkout/v1/checkout-create.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/listener-checkout/v1/checkout-create.schema.json", + "title": "Listener Live checkout creation command v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "account_id", "provider", "return_url", "cancel_url"], + "properties": { + "schema_version": {"const": "listener-checkout.checkout-create.v1"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "provider": {"enum": ["paypal", "mercado_pago"]}, + "payer_email": { + "type": ["string", "null"], + "minLength": 3, + "maxLength": 320, + "pattern": "^[^@\\sA-Z]+@[^@\\sA-Z]+$" + }, + "return_url": {"type": "string", "format": "uri", "pattern": "^https://", "maxLength": 2048}, + "cancel_url": {"type": "string", "format": "uri", "pattern": "^https://", "maxLength": 2048} + }, + "allOf": [ + { + "if": {"properties": {"provider": {"const": "mercado_pago"}}}, + "then": {"required": ["payer_email"], "properties": {"payer_email": {"type": "string"}}}, + "else": {"properties": {"payer_email": {"type": "null"}}} + } + ] +} diff --git a/contracts/listener-checkout/v1/checkout.fixture.json b/contracts/listener-checkout/v1/checkout.fixture.json new file mode 100644 index 00000000..b77d37d2 --- /dev/null +++ b/contracts/listener-checkout/v1/checkout.fixture.json @@ -0,0 +1,9 @@ +{ + "schema_version": "listener-checkout.checkout.v1", + "account_id": "acct_listener_synthetic_0001", + "provider": "paypal", + "approval_url": "https://www.paypal.com/approve", + "currency": "USD", + "amount_minor": 500, + "environment": "live" +} diff --git a/contracts/listener-checkout/v1/checkout.schema.json b/contracts/listener-checkout/v1/checkout.schema.json new file mode 100644 index 00000000..116bbdfe --- /dev/null +++ b/contracts/listener-checkout/v1/checkout.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://harmonicbeacon.com/contracts/listener-checkout/v1/checkout.schema.json", + "title": "Listener Live checkout result v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "account_id", + "provider", + "approval_url", + "currency", + "amount_minor", + "environment" + ], + "properties": { + "schema_version": {"const": "listener-checkout.checkout.v1"}, + "account_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$" + }, + "provider": {"enum": ["paypal", "mercado_pago"]}, + "approval_url": { + "type": "string", + "format": "uri", + "minLength": 9, + "maxLength": 2048, + "pattern": "^https://[^\\s/@]+(?:[/?#][^\\s]*)?$" + }, + "currency": {"enum": ["USD", "ARS"]}, + "amount_minor": {"type": "integer", "minimum": 1}, + "environment": {"const": "live"} + } +} diff --git a/deploy/nginx-harmonic-beacon.conf b/deploy/nginx-harmonic-beacon.conf index 921a353b..17566ff3 100644 --- a/deploy/nginx-harmonic-beacon.conf +++ b/deploy/nginx-harmonic-beacon.conf @@ -33,6 +33,22 @@ server { root /var/www/html; } + # Legacy EarlyBird invitation URLs carry a bearer query. Never persist the + # first request, and do not permanently cache the redirect containing it. + location = /early-birds { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://$host$request_uri; + } + + location = /early-birds/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://$host$request_uri; + } + location / { return 301 https://$host$request_uri; } @@ -134,6 +150,45 @@ server { default_type text/html; } + # Middleware immediately exchanges a valid legacy bearer query for a short + # HttpOnly cookie and redirects to the clean URL. These exact entry requests + # stay out of the edge access log in both their legacy and clean forms. + location = /early-birds { + access_log off; + proxy_pass http://harmonic_beacon_app; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_buffering off; + proxy_request_buffering off; + } + + location = /early-birds/redeem { + access_log off; + proxy_pass http://harmonic_beacon_app; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_buffering off; + proxy_request_buffering off; + } + # --- Next.js app (everything else) --- location / { proxy_pass http://harmonic_beacon_app; diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md index 3447e103..b7e54268 100644 --- a/docs/MONETIZATION.md +++ b/docs/MONETIZATION.md @@ -7,16 +7,15 @@ > statement in this document is a claim about code that exists today; if you find > one that is not, that is a bug in this document. > -> **Nothing in this document is live.** There is no payment processing, no -> entitlement model, no patron/free distinction and no payout mechanism in the -> codebase: no Stripe integration of any kind, and no patronage, subscription, -> tier or payout model in `prisma/schema.prisma`. No price below has ever been -> published or charged, and nobody has been billed anything. Every published -> meditation is available to every signed-in Listener right now — which exceeds -> the Commons commitment below, and also means the floor it describes is enforced -> nowhere. Read the whole document as the model patronage will implement when it -> ships: **[Planned — Phase 2]** throughout, except where a rule below is tagged -> to a different phase. +> **Founding Listener pre-release is implemented but real sales remain OFF.** +> The isolated Listener has a server-authoritative three-hour weekly Free quota +> and a recurring **USD 5/month Founding Listener** offer. PayPal Sandbox and +> Mercado Pago TEST have completed supervised checkout and lifecycle acceptance; +> production provider credentials, Live flags and public checkout stay disabled +> until the commercial release gates are approved. The broader patronage tiers, +> donations, provider payouts and institutional mechanics below remain draft +> Phase 2 proposals; they must not be confused with the Founding Listener launch +> candidate. *Draft · 2026-04-12 · author: product design, pending validation* @@ -24,7 +23,7 @@ Authoritative rules live in [BUSINESS_RULES.md §5](../BUSINESS_RULES.md). This ## Stance -Harmonic Beacon will be monetized through **patronage and institutional licensing**, not through a paywall. The core listening experience is free forever; money flows into the product because people and organizations want the instrument to exist, not because they have been fenced out of it. +Harmonic Beacon is being monetized first through **Founding Listener membership**, then potentially through the broader patronage and institutional model described below. Registered Free listeners receive a recurring weekly allowance; Founding Listeners receive unrestricted access while their USD 5 monthly service remains uninterrupted. Free For All can still open access temporarily without creating membership or payment state. This is a deliberate choice, not a fallback. A Calm-style paywall would corrode the brand. A donation-only model would starve the infrastructure. The middle path — named patronage tiers with meaningful but non-essential benefits, optional pure donations, and institutional deals on the side — is what we will build. @@ -38,7 +37,7 @@ We expect revenue to come from up to five stacked surfaces. Only the first two m 4. **Grants and foundation support** (continuous). Not transactional; grant-writing is meant to be a standing function of the org rather than a side task, and it needs an owner before it is one. 5. **Harmonic Seal certification** (Phase 4+ speculative). A future certification mark for Harmonically Aware Technology applied to third-party devices, environments, or systems. -We do **not** monetize through: advertising, data resale, affiliate deals that compromise the brand, or NFTs/tokens. That refusal is a standing rule and holds today, trivially — we do not monetize at all. +We do **not** monetize through advertising, data resale, affiliate deals that compromise the brand, or NFTs/tokens. That refusal remains a standing rule as payment capability moves from sandbox acceptance toward an explicitly approved launch. ## Patronage tiers @@ -138,14 +137,15 @@ When a card fails: ### Refunds -- Within 14 days of a new patronage or annual renewal: no-questions-refund via a self-serve flow. -- Beyond 14 days: pro-rated by request. No automated pro-ration for monthly patrons beyond that window. +- Cancellation stops future renewals and preserves the period already paid; it never initiates a refund. +- Refunds, if exceptionally required by law, provider process or an individually reviewed support case, + are performed manually. There is no automatic or self-service refund actuator. ### Gifts - Annual patronage will be giftable at any tier. Gifts are a separate flow; the recipient can opt to continue as a patron or let the gift elapse without billing. -The cancellation, dunning, refund and gift rules above are the contract each flow will be built to. None of the flows exists — there is nothing to cancel, no card to fail, and no charge to refund. **[Planned — Phase 2]** +The cancellation, dunning, exceptional-refund and gift rules above are the contract each flow will be built to. None of the patronage flows exists yet. **[Planned — Phase 2]** ## Provider revenue share @@ -247,10 +247,15 @@ This is documented here only to note that monetization for the Seal is an open q ## Compliance scaffolding -Whatever monetization we ship will run on these scaffolds. None of them is in place — Stripe is not integrated, no tax advisor has been engaged, and no ledger separates Harmonic Beacon within the parent org's accounts. **[Planned — Phase 2]** +The broader patronage/provider economy below remains planned for Phase 2. It is not the current +Founding Listener implementation. That pre-release uses a provider-neutral authority with PayPal +and Mercado Pago, USD 5/month Founder continuity, weekly Free access and default-off Live gates; +see `operations/LISTENER_LAUNCH_NOW.md`. No real charge or public checkout is enabled yet. A tax +advisor and a dedicated Harmonic Beacon accounting ledger remain launch/operations work. -- **Billing provider**: Stripe at launch (Stripe Billing for subscriptions, Stripe Connect for Provider payouts, Stripe Tax for VAT/sales tax, Stripe Checkout for one-time donations). -- **Tax**: Stripe Tax computes and collects. We file where required. A tax advisor is engaged before the first payout-bearing month — it is an open thread in [README.md](./README.md#open-threads) and it gates the phase, because the currencies quoted above decide which registrations we need. +- **Founding Listener billing**: PayPal and Mercado Pago through the canonical membership authority; Live remains OFF until supervised cutover. +- **Future patron/provider economy**: provider, payout and tax tooling are a separate Phase 2 decision; Stripe is a candidate, not deployed fact. +- **Tax**: the receiving merchant/entity and jurisdictional obligations must be accepted before public sales. A processor does not replace the tax/accounting decision. - **Receipts**: every charge generates a compliant receipt. Annual patrons receive a year-end summary of what they contributed. - **Legal entity**: payments flow through the designated AlterMundi entity; separate ledger for Harmonic Beacon within the parent org's accounts. - **Currency risk**: unhedged at launch; visible in the monthly financial review. diff --git a/docs/PRODUCT_PRINCIPLES.md b/docs/PRODUCT_PRINCIPLES.md index 70a4ed41..5b894245 100644 --- a/docs/PRODUCT_PRINCIPLES.md +++ b/docs/PRODUCT_PRINCIPLES.md @@ -36,12 +36,12 @@ No streaks, no badges, no gamified retention. The product should feel like a qui Any UX pattern that relies on manufactured scarcity, FOMO, manipulative defaults, guilt, or sunk-cost pressure is banned. -- Cancellation will be one click, same number of screens as signup. **[Planned — Phase 2]** -- Price will always be visible before commitment. **[Planned — Phase 2]** +- Cancellation is available from the Listener profile with an explicit confirmation; a pending cancellation can be reversed before service ends. +- The exact recurring price and provider are visible before checkout. - We do not use confirm-shaming copy ("No, I don't want to feel better"). - Push notifications will be rare, informative, and never emotional. **[Planned — Phase 3]** -The first two and the last describe surfaces that do not exist — there is no payment flow, no price, and no notification channel. They are written down now because the cheapest time to bind a dark-pattern rule is before the surface that would tempt it. The third holds today, being a rule about copy we already write. +The first two are implemented in the pre-release Founding Listener surface and remain release gates for every provider. The notification channel is still future work. The copy rule holds today. If a proposed feature would be embarrassing to explain at a press interview, we don't ship it. @@ -75,7 +75,7 @@ Every time we touch security, privacy, moderation, billing, or research consent, - No shipping with known moderate-or-higher vulnerabilities. - No logging PII to anywhere we can't purge. *This one is enforced, not just stated:* `src/lib/redact.ts` strips credentials and presigned-URL signatures before anything reaches a log, and `src/lib/__tests__/no-pii-in-logs.test.ts` scans every `console.*` call in `src/` for personal-data accessors and fails the build on a match. The motivating regression was real — the app logged a user's email on every JWT sync — and the test exists so it cannot come back. A principle with a test behind it is a different kind of object from a principle without one, and the rest of this list is the second kind. -- No shipping a payment feature without the cancel/refund path in the same PR. No payment feature exists yet, so this rule has not been tested against anything. +- No shipping a payment feature without cancellation, canonical terminal/refund handling and reconciliation in the same release. Founding Listener now exercises this rule in sandbox/test; real sales remain disabled pending supervised Live acceptance. - No collecting a new field on a user without updating Privacy and the consent copy. ## 7. Default to public @@ -137,7 +137,7 @@ This is the first test case for the linter in §5. You cannot keep a 24/7 promise you can't see. You cannot run research you can't audit. Before any new surface goes live, it will have logs, metrics, and alerts proportional to its blast radius. Observability investment is not deferred past launch; it is launch. **[Planned — Phase 1]** -Today there is none of it. No error tracking, no metrics, no traces, no external uptime monitor, no alerting — the codebase has container healthchecks, a liveness probe, and ad-hoc `console` calls. This is the principle with the widest gap between statement and practice, and it is load-bearing for two others: §7 cannot publish numbers nobody measures, and §1's promise that the beacon never goes dark is currently a promise we would learn we had broken from a listener rather than from a page. +The Listener launch lane now has private Prometheus metrics, Alertmanager/Telegram warning-critical-recovery rules, health/readiness, provider and queue gauges, backups and a rehearsed restore. Public status and broader product observability remain incomplete, so this principle is partially implemented rather than satisfied. ## 12. Innovate cautiously, document generously diff --git a/docs/RESEARCH_PROTOCOL.md b/docs/RESEARCH_PROTOCOL.md index 9d517053..7e20e526 100644 --- a/docs/RESEARCH_PROTOCOL.md +++ b/docs/RESEARCH_PROTOCOL.md @@ -180,19 +180,24 @@ Two things in that diagram are commitments rather than descriptions and should b ### 4.3 Processors -An earlier draft of this document asserted that "no third-party processor touches identifiable data except Stripe (billing) and our email provider (transactional)". That sentence was wrong in both directions — neither Stripe nor an email provider is integrated, and the roadmap adds several processors the sentence excluded. An absolute claim about processors is falsified the day a dependency is added, so this section is a dated list instead. Maintaining it is also what GDPR Art. 28 and Art. 30 record-keeping will require. +An earlier draft of this document asserted that "no third-party processor touches identifiable data except Stripe (billing) and our email provider (transactional)". That sentence was wrong in both directions: Stripe is not the current Listener billing authority, and an absolute list becomes false as soon as a dependency is added. This dated inventory separates services currently exercised in the Founding Listener pre-release from later research processors. Maintaining it is also what GDPR Art. 28 and Art. 30 record-keeping will require. -**Processors with access to identifiable data, as of 2026-06-09:** +**Processors with access to identifiable data, as of 2026-08-12:** | Processor | Data | Role | |---|---|---| -| Zitadel (`auth.altermundi.net`, AlterMundi-operated) | Email, name, OIDC subject | Identity provider — see `src/lib/auth-config.ts` | +| Google OAuth | Email and provider subject during sign-in | Configured Listener identity provider | +| Gmail API / Google Workspace | Recipient and one-use sign-in URL | Transactional Listener magic-link delivery | +| PayPal Sandbox and Mercado Pago TEST | Test buyer identity and synthetic payment instrument data | Accepted pre-release subscription testing only; no Live charges | | PostgreSQL and object storage on AlterMundi-operated hosts | All application data | First-party infrastructure | | LiveKit, self-hosted on AlterMundi infrastructure | Live audio, participant identities | Real-time transport | -No payment processor, email provider, error-tracking, crash-reporting, analytics or push-notification service is integrated today. +PayPal Live and Mercado Pago productive credentials are not installed, their provider and public +checkout flags are OFF, and no real Founding Listener charge has occurred. The Listener stores +opaque provider evidence and membership state; providers retain financial instrument data. No +error-tracking, crash-reporting or push-notification service is integrated in this lane. -**We update this list before adding a processor, not after.** The roadmap already names candidates that will belong here, each of which is a processor decision and not merely a dependency choice: Sentry with release tracking (Phase 1), a transactional email provider such as Resend or Postmark (Phase 2), Stripe including Connect KYC for provider payouts (Phase 2), Firebase Crashlytics (Phase 3 — Google as processor, with its own data-sharing posture, which is the one on this list most worth a second look before it is adopted), and a push-notification service (Phase 3). None is integrated; none may be added while this table still says it is not. +**We update this list before adding a processor, not after.** The broader roadmap still names candidates such as Sentry, Stripe Connect for a future provider economy, Firebase Crashlytics and push notifications. None is implied by the Listener pre-release and each requires its own processor decision and inventory update before activation. ### 4.4 Retention diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9e4f5566..b2321250 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,6 +6,13 @@ This is the multi-horizon roadmap for Harmonic Beacon. It sits above the individ Four phases, each roughly 8 weeks, plus a long-horizon section (Phase 4+) that is open-ended and reactive to what Phases 1–3 produce. +> **Active pre-release lane (2026-08-12):** Founding Listeners is the current thin +> monetization path: registered weekly Free plus a USD 5/month uninterrupted +> Founder subscription using PayPal and Mercado Pago. Sandbox/TEST lifecycles are +> accepted; Live credentials, real charges and public checkout remain OFF pending +> the release gates in `docs/operations/LISTENER_LAUNCH_NOW.md`. The Stripe items +> below are longer-horizon roadmap ideas, not the authority for this pre-release. + --- ## Compass (restated) diff --git a/docs/VISION.md b/docs/VISION.md index 44d14472..6da6b0d1 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -55,7 +55,7 @@ When we write in the voice of the brand we aim for three qualities: Because the positioning is unusual, our promises must be explicit so we can be held to them and so internal decisions can be checked against them. 1. **The beacon never goes dark.** Whatever it takes — redundant upstream sources, a playlist fallback — the stream remains audible. Continuity is a brand promise, not a nice-to-have. The playlist fallback exists; the redundant upstream does not yet, so today the hierarchy has two levels rather than three. See [SLO.md](./SLO.md) and the Covenant of Continuity, which set out what "dark" means and what the uptime target actually allows. **[Planned — Phase 1]** -2. **We do not sell access to presence.** Core listening (live beacon + a rotating set of overlays) stays free forever. Patronage supports the instrument; it does not gate the experience. +2. **Free access stays meaningful.** Every registered Listener receives a recurring weekly Beacon allowance, and operators may open Free For All periods without creating payment state. Founding Listener membership removes the weekly limit while uninterrupted. Pricing and limits are stated before commitment; Free access is never represented as paid membership. 3. **We make no therapeutic claims.** The Analysis pillar frames research as exploration, never as treatment. Copy will be audited against this before publication; there is no audit step in the publishing path yet, and the 2026-06-09 review found a claim of this exact kind inside our own principles document. See [PRODUCT_PRINCIPLES.md §10](./PRODUCT_PRINCIPLES.md). **[Planned — unscheduled]** 4. **Participants own their data.** Research participation will be opt-in, consented per protocol, revocable at any time, and exportable in a structured format. De-identified aggregates may be published; identifiable data never leaves under any condition we choose alone. Ownership is only as real as the mechanics that deliver it, and two of those are still to be built: there is no export endpoint and no deletion endpoint yet. **[Planned — Phase 1]** 5. **Providers are vetted, not gate-kept.** The threshold is alignment with the frame, not credentials. Vetting is transparent. Appeals are possible. diff --git a/docs/architecture/BEACON_ACCOUNT_AUTHORITY.md b/docs/architecture/BEACON_ACCOUNT_AUTHORITY.md new file mode 100644 index 00000000..8d212c66 --- /dev/null +++ b/docs/architecture/BEACON_ACCOUNT_AUTHORITY.md @@ -0,0 +1,165 @@ +# Beacon Account authority v1 + +Status: frozen pre-public implementation contract. + +## Boundary + +Account is the only interactive credential authority: + +- production issuer: `https://account.harmonicbeacon.com` +- staging issuer: `https://account-staging.harmonicbeacon.com` + +Production and staging use separate databases/schemas, secrets, cookies, +providers and issuer records. Account cookies are `__Host-`, Secure, HttpOnly, +Path `/`, have no Domain, and never cross into Listener, Live or the public +site. A staging credential cannot resolve or mutate a production account. + +The canonical production account ID remains the existing opaque +`early_bird_users.id`; product, membership and payment foreign keys survive the +cutover. Staging materializes a deterministic issuer-bound local ID instead. +Email/provider identity is never staff, event, membership or payment authority. + +The reviewed sibling-origin and dangling-record inventory is maintained in +[`docs/security/BEACON_SUBDOMAIN_INVENTORY.md`](../security/BEACON_SUBDOMAIN_INVENTORY.md). +It is a production Account gate. DNS changes remain a human/operator action; +repository automation must never modify DNSExit. + +## Fixed access method and Beacon profile + +Every account has exactly one access method: `email`, `google` or `apple`. +Linking, implicit email joins, merging and public magic-link login are absent. +The one-identity-per-account invariant is also enforced in PostgreSQL. + +`beacon_profiles` is provider-independent and keyed one-to-one by account ID. +Its `display_name` is 1–60 trimmed characters and rejects Cc/Cf, bidi and +zero-width controls in application and database layers. Updates use optimistic +`revision`; the DB trigger guarantees every inserted account gets a sane +profile even if an application hook fails. + +`early_bird_users.security_revision` is the account-wide revocation epoch; +sessions snapshot it and are valid only while both match. Password/email +changes, password reset and all-device logout advance the epoch and atomically +revoke sessions plus OAuth access/refresh tokens. + +## Browser and mail flows + +Public Account pages are `/account`, `/verify-email` and `/reset-password`. +Root `/` leads to Account. The byte-pinned local navigation receives at most a +server-derived boolean session hint; it uses no iframe and exposes no PII or +tokens. `return_to` is an exact product-root allowlist. + +Credential signup is two-step. Verification, reset and email-change tokens are +hashed, one-use, at most 15 minutes, and consumed in the same Serializable +transaction as their mutation. Pages capture the query token client-side, +immediately scrub history, and send no referrer. Passwords are 8–128 characters +with no composition or complexity rules and use the single Better Auth scrypt +implementation. Verification-before-access, reauthentication, durable HMAC rate +buckets and session revocation remain independent security boundaries. + +Mail uses the byte-pinned `contracts/listener-account-mail/v1` contract and a +durable AES-GCM outbox. Retry reuses the same sealed token and exact 64-lowercase +hex idempotency key. Worker command: `npm run account:mail-worker`. Its least +privilege env is DB/base URL, `BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN`, exact +32-byte unpadded-base64url `BEACON_ACCOUNT_MAIL_OUTBOX_KEY`, git SHA and optional +heartbeat path. The mode-0600 heartbeat is: + +```json +{"status":"ok|degraded|error","at":"RFC3339","delivered":0,"gitSha":"sha","pendingCount":0,"oldestPendingSeconds":0,"consecutiveErrors":0,"maintenanceStatus":"ok|error","lastSuccessAt":"RFC3339|null"} +``` + +## OIDC relying-party contract + +Better Auth and `@better-auth/oauth-provider` are pinned to `1.6.30`. Dynamic +registration is disabled. Static clients are confidential server-side clients, +`client_secret_basic` only, authorization code only, PKCE S256 required, +scopes exactly `openid profile`, public=false, subject type public, consent +skipped and end-session enabled: + +| client | redirect | signed front-channel | +| --- | --- | --- | +| `hb-listener` | `https://listen.harmonicbeacon.com/api/account/callback` | `https://listen.harmonicbeacon.com/api/account/frontchannel-logout` | +| `hb-listener-staging` | `https://earlybirds-staging.harmonicbeacon.com/api/account/callback` | `https://earlybirds-staging.harmonicbeacon.com/api/account/frontchannel-logout` | +| `hb-live` | `https://live.harmonicbeacon.com/api/account/callback` | `https://live.harmonicbeacon.com/api/account/frontchannel-logout` | +| `hb-live-staging` | `https://live-staging.harmonicbeacon.com/api/account/callback` | `https://live-staging.harmonicbeacon.com/api/account/frontchannel-logout` | + +Discovery is `/.well-known/openid-configuration`; JWKS is +`/.well-known/jwks.json`. Allowed provider endpoints are exact GET authorize, +GET UserInfo, GET end-session and POST token/introspect/revoke. The auth +catch-all also permits only email/social starts and Google/Apple callbacks; +Better Auth profile/link/session/password/email alternatives are 404. + +ID tokens contain `iss/sub/aud/exp/iat/nonce/sid`; RPs verify JWKS, claims, +nonce/state/PKCE and exact redirect, introspect once, discard Account/provider +tokens and retain only issuer/sub/sid in a host-only local session. Private +`POST /api/account/session-status` uses client-secret Basic and exact +form-encoding; active responses contain only `active,iss,sub,sid`. + +Current-device logout revokes its central session and OAuth tokens, then emits +signed two-minute front-channel URLs to every RP for that environment. +All-device logout revokes all sids. RP-initiated end-session requires a signed +ID-token hint, exact client/state/registered return and is wrapped in the same +signed front-channel contract. A central outage does not interrupt media +already issued, but new identity/authorization and lease renewal fail closed. + +## Runtime and provider configuration + +### Production database boundary + +Production shares the canonical `earlybirds_preview` database so existing +opaque account and product foreign keys survive, but it does not share the +database owner's credential. The lifecycle derives a short-lived migration +connection from the already-running PostgreSQL container, confines that +connection to the internal DB network, and removes it after migration, backup +or verification. The long-running application and mail worker use the +non-owner `account_prod` role. Deployment creates or rotates that role only +after the reviewed migration and grants it CRUD on the explicit Account/auth +table inventory; it has no role membership, DDL, superuser, membership, +commerce or event-table access. + +The first production authority migration intentionally invalidates legacy +browser sessions and one-use auth artifacts. It must therefore be deployed as +the coordinated Account → Listener identity cutover, with the encrypted +pre-migration backup already verified. Starting an internal Account container +early is not a harmless preview and is forbidden. A retry may accept the exact +target migration as already applied, but never an unknown pending/applied +migration or schema downgrade. + +The dedicated Account container requires `BEACON_ACCOUNT_RUNTIME=1` and the +exact issuer Host; all non-Account routes and direct Docker Host access are 404. +Readiness uses `BEACON_DATABASE_SCHEMA_VERSION` and returns +`{status,gitSha,schemaVersion,checks:{database,mail,issuer,jwks,clients,providers}}`. +It checks the exact enabled client inventory and provider configuration. + +Listener central Account is a separate default-off cutover: + +- `BEACON_LISTENER_ACCOUNT_ENABLED` +- `BEACON_LISTENER_ACCOUNT_ENVIRONMENT=production|staging` +- `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET` +- `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING` +- `BEACON_LISTENER_ACCOUNT_STATE_SECRET` +- `BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING` + +The root-owned deployment policy validates all four secrets are at least 32 +characters and each production/staging pair differs. A runtime receives only +the two values selected by its marker: port 13000 gets production, while the +disposable 13001 launcher loads staging values from the exact root:root 0600 +`/etc/harmonic-beacon/listener-account-staging.env` and strips production +values. Host/marker mismatch fails closed. Listener readiness validates only local configuration; +deployment smoke checks Account discovery/session-status egress separately so +an Account outage never creates a playback restart loop. + +Google and Apple are independent. Apple is default-off. +`BEACON_ACCOUNT_APPLE_CLIENT_SECRET` is the short-lived client-secret JWT, +never the `.p8`; generate/rotate it outside Git from Team ID, Key ID, Services +ID and `.p8`. Exact callbacks are +`https://account.harmonicbeacon.com/api/account/auth/callback/apple` and +`https://account-staging.harmonicbeacon.com/api/account/auth/callback/apple`. +The exact Google/Apple registration, staging activation, human acceptance, +rollback, and rotation procedure is +`docs/operations/BEACON_ACCOUNT_SOCIAL_PROVIDERS.md`. The older direct-Listener +provider runbook is not the central Account contract. +The raw one-use Account action token exists only in its exact HTTPS mail action +URL; that surface is `no-referrer` and scrubs the query client-side before any +submission. Secrets, provider tokens and raw action tokens never enter Git, +application logs, analytics or metrics, and PII is excluded from operational +logs and telemetry. diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md new file mode 100644 index 00000000..6a6dd5ea --- /dev/null +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -0,0 +1,227 @@ +# EarlyBird Listener + +EarlyBird Listener is an isolated identity, membership and listening surface at `/early-birds`. +It does not authorize weekend-event tickets, staff tools, LiveKit rooms, chat, or Annie. The webapp +holds a fail-closed read projection; PMP Myth Bot (`proyecciones-mito`) remains the sole authority +for Free, PayPal and Mercado Pago membership state. + +## Identity boundary + +Better Auth uses dedicated `early_bird_*` tables and the `hb_earlybird_session` cookie. Public login +offers configured social providers plus an optional passwordless email fallback. Account linking, +implicit linking, unlinking and the account cookie are disabled. The adapter requires nullable OAuth token columns, but Better Auth database hooks scrub +access, refresh and ID tokens, token expiries and scope to `null` before create/update reaches Prisma. +Listener session hooks likewise discard IP address and user-agent values before +Prisma writes them. The test suite locks both pre-adapter invariants. + +Required OAuth callbacks are: + +- `https://listen.harmonicbeacon.com/api/early-birds/auth/callback/google` +- `https://listen.harmonicbeacon.com/api/early-birds/auth/callback/apple` + +The staging callbacks with the same suffixes may be registered for isolated QA, +but the shared preview runtime uses `listen.harmonicbeacon.com` as its canonical +OAuth base URL. Provider credentials may remain unset during local testing; the +corresponding provider is absent from the public UI and auth runtime. Public nginx exposes this dedicated +auth namespace plus only the exact invitation entry/redeem routes. It continues +to block synthetic login and internal membership routes. + +Browser-initiated auth mutations require an exact configured Listener +`Origin`. OAuth provider callbacks are the sole exception because Apple uses a +cross-site `form_post`; those callbacks are bound instead by Better Auth's +short-lived, one-use state cookie/database verifier and PKCE code verifier. +Unknown, expired or cookie-mismatched state fails before account or session +creation. + +### Passwordless email fallback + +The email fallback is an exact Better Auth `1.6.26` magic-link plugin and is +absent unless its private delivery URL, service token and independent HMAC rate +secret are all configured. Tokens are random, stored only as SHA-256 +verifiers, expire after ten minutes and are atomically consumed on the first +verification attempt. Success, replay, expiry and alteration keep the same +isolated session boundary and fixed `/early-birds` callback allowlist. + +Requests use a generic response regardless of account existence, throttling or +mail-provider uncertainty. Durable 15-minute buckets allow three requests per +normalized address and ten per Origin/network-address pair; only HMAC keys are +stored, never raw network addresses, and stale buckets are discarded after 24 +hours. Better Auth additionally bounds the route +to three requests per minute per process/network source. A magic link may +create an email-only Listener, but both delivery and session creation reject an +address already owned by a Google, Apple or supervised credential identity. +Email equality therefore never silently adds a new way to authenticate an +existing account. + +Mail crosses one versioned private boundary: +`POST /api/internal/v1/listener-magic-links/deliver`. The Listener sends the +recipient, locale, expiring URL and an opaque idempotency key under a dedicated +Bearer credential. The existing mail authority renders and sends the message; +its Gmail OAuth grant is never copied or mounted into the Listener. Until that +endpoint exists and the three Listener values are installed, the control and +auth plugin stay hidden and fail closed. + +## Canonical membership boundary + +The current byte-exact snapshots live in `contracts/early-bird-authority/v3` +and `contracts/early-bird-membership/v2`. Verify them with +`npm run contract:early-birds:verify`. Older versions remain historical +artifacts only and are not accepted by runtime parsers or projection routes. + +- Free redemption authenticates the EarlyBird session first and sends the opaque invitation only to + `POST /api/internal/v1/early-bird-invitations/redeem` on the authority. Beacon never consumes or + stores the invitation. +- The authority can push membership plus Founder-continuity revisions to + `PUT /api/internal/v2/early-bird-memberships/{account_id}`. Beacon requires rotating Bearer/key-id + credentials and `Idempotency-Key: early-bird-membership:{account_id}:{membership_revision}`. +- Commands are hashed with SHA-256 over RFC 8785/JCS canonical JSON, including + the complete `founder_continuity` snapshot. Higher revisions are `APPLIED`, byte-semantic repeats are `REPLAYED`, lower revisions are + `STALE`, and equal revisions with different payloads conflict. +- Paid `ACTIVE`, time-valid `GRACE`, and time-valid + `CANCELLED_PENDING_END` allow access only with a matching current continuity + episode and boundary. `ENDED`, missing, expired, revoked, refunded, + contradictory or unavailable state fails closed. + +### Public invitation handoff + +An invitation link is accepted only on `listen.harmonicbeacon.com` or the +isolated staging host. Staging carries the bearer in one unlogged, +no-store/no-referrer redirect to the canonical +`https://listen.harmonicbeacon.com/listener/redeem` page; it never mints an +invitation cookie. Middleware on `listen` immediately removes the signed bearer +query, dual-writes the canonical `__Host-hb_listener_invitation` and legacy +`__Host-hb_early_bird_invitation` cookies with the same 30-minute value, and +redirects to the clean URL. Both are host-only, Secure, HttpOnly, SameSite=Lax +and Path=/; neither the event host nor a forwarded-host header can mint them. +Readers require unambiguous same-name cookies, prefer the canonical generation +and accept legacy-only state only when canonical state is absent. Conflicting or +malformed overlap fails closed. Success and terminal rejection clear both; +transient 503 and pre-redemption authentication retain both for a safe retry. + +Google and configured magic-link callbacks return to the exact +`/listener/redeem` allowlist. The cookie therefore survives an identity round +trip in the same browser without entering JavaScript, OAuth state or email. +Opening a magic link in another browser or device intentionally does not carry +the invitation; a future cross-device flow needs an authority-mediated claim +contract and must not place the invitation bearer in mail. + +The browser redeem POST is exposed only at the canonical and compatibility +aliases on `listen`. It requires the exact Listener Host and same Origin, and +nginx bounds each address to 30 requests per minute with a 20-request burst so +a shared household/NAT cannot lock out independent one-use redemptions. Both +POST aliases fail closed with an unlogged 404 on staging. All +responses and exact edge locations are no-store and no-referrer. The exact +magic-link verification URL is excluded from HTTP and HTTPS access logs and +staging redirects it once to the canonical host, because its query carries the +one-use authentication token. + +## Registered Free weekly allowance + +Registration does not fabricate a commerce membership, but it makes a signed-in +account eligible for the base Free allowance: **three hours in a personal fixed +seven-day cycle**. The cycle is created only by the first real, server-authorized +Free playback. Registration, OAuth callback, page view, lease preparation and a +second device never start it. The cycle does not follow a timezone or wall-clock +schedule. + +The server is the sole clock and meter. A cycle records its start/end, the base +allowance and metered use; it begins at first playback and ends exactly seven +days later. Unused base time never rolls into the next cycle. While at least one +account lease is genuinely listening, the account consumes one shared timeline, +not one allowance per device: two simultaneous devices consume the union once. +A selected private intro and the Beacon both count, because both are part of +listening. Stop and explicit idle presence stop metering; an unreported +disconnect can consume only through the bounded active-lease horizon. + +Authorization resolves in this order: + +1. Free for All is anonymous, unlimited and non-metered while its route-level + override is enabled; +2. a time-valid canonical membership or invitation is unlimited and non-metered; +3. otherwise a registered account may start or resume its current Free cycle + while server-calculated time remains; +4. an exhausted cycle fails closed until its exact seven-day end, when a new + first real playback may start the next cycle. + +Lease issuance, heartbeat and manifest authorization calculate/cap the same +server-side remaining time. A browser receives a server timestamp and remaining +allowance only for presentation; it may tick a display between revalidations but +cannot authorize itself. Its active countdown is reconciled from server state on +the bounded heartbeat/revalidation path and at exhaustion. + +Discretionary Free credits use distinct, auditable and idempotent grants. Each +grant has immutable account/source/reason/idempotency/amount/expiry facts and a +server-owned monotonic consumed total; it is never a mutable replacement for +the base cycle or membership. The server applies only unexpired credit and the +browser cannot create or replenish a grant. + +`early_bird_free_schedules` and `early_bird_welcome_accesses` remain retained +legacy tables for migration/audit history only. The weekly-cutover readers do +not authorize them, do not create new rows in them, and do not expose their +schedule, timezone or welcome concepts in the Listener UI. The cutover is +forward-only: after its additive migration, rollback is stop/kill-switch and a +roll-forward repair, never re-enabling those retired authorization paths. + +The optional synthetic-login API creates a clearly marked, source-null local projection only when +both `EARLY_BIRDS_TEST_ACCESS_ENABLED=1` and a separate 32+ character secret are configured. Every +POST must present that secret as a Bearer token; absent/wrong credentials receive the same hidden +404. The route is not exposed by the UI or client bundle, cannot replace a canonical projection, +and must never be enabled on the customer-production hostname. + +### Human-operated staging entry + +An optional bilingual team form can expose that API on a dedicated staging hostname without putting +the Bearer code into HTML, JavaScript, `NEXT_PUBLIC_*`, storage, cookies or logs. A tester types a +name, an `@e2e.invalid` account and the separately shared temporary code. The component keeps the +code only in memory, sends it once as `Authorization: Bearer ...`, clears the field immediately and +sends only name/email in the JSON body. + +The form and API fail closed unless every condition below is true: + +- the runtime is a production build (`NODE_ENV=production`) served through HTTPS; +- `EARLY_BIRDS_ENABLED=1` and `EARLY_BIRDS_TEST_ACCESS_ENABLED=1`; +- `EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=1`; +- the request's exact `Host` is listed in `EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS`. + +The host list accepts comma-separated `host` or `host:port` values only—no schemes, paths or +wildcards. The trusted staging reverse proxy must replace `X-Forwarded-Proto` with exactly `https`. +Missing, malformed, HTTP or non-allowlisted requests receive a non-descriptive 404 from the +synthetic-login endpoint and never reach Better Auth. Direct public Better Auth email sign-up/sign-in +routes are also hidden; only the authenticated staging endpoint can invoke that adapter internally. + +This creates only an isolated EarlyBird account, Better Auth session and synthetic EarlyBird +membership projection. It grants no weekend-event principal, ticket, staff role, LiveKit capability, +chat capability or other event authorization. Keep both staging gates at `0` outside a supervised +test window and rotate the temporary code after the window. + +## Stream and device leases + +An entitled account may hold two active device leases. A third device evicts the oldest lease. After +the account, quota and lease decision, Listener registers one opaque media grant over a private +container network. The browser then fetches the manifest and segments directly from +`stream.harmonicbeacon.com`; playback no longer polls Listener, Better Auth or PostgreSQL. + +The grant ID and bearer are deterministic per opaque lease generation, so a heartbeat extends its +expiry without replacing the media URL. The origin retains only a token hash and expiry—never the +account, device, lease ID or PII—and rejects grants beyond the three-minute lease horizon. A Listener +or database outage therefore cannot interrupt already-buffered audio immediately: origin requests +continue until the last registered lease expiry, then fail closed. Media-query credentials are not +written to nginx or application logs. Legacy `exp`/`sig` HMAC URLs remain an operator canary and +origin-first rollback protocol, not an alternate public authorization model. + +Reviewed intro artifacts are configured as immutable, server-selected private files. Listener UI does not +encode or alter them and their progress is local to the browser. Before either intro can be selected, the +HLS source and lease are prepared without autoplay. One click then starts the intro and the already-attached +Beacon element together, keeping the shared timeline muted underneath. Pausing the intro produces silence; +its natural end reveals the still-running Beacon with a three-second element-volume fade where the browser +supports writable volume. iOS does not, so it receives a non-overlapping native unmute rather than a false +fade claim. Pause and Seek exist only for an active introduction; the Beacon is a live-edge source with +Stop, and a later Listen obtains the current edge rather than resuming stale media. No AudioContext, +LiveKit, chat or session-event behavior is changed, and the initial gain remains native 1.0. + +## Dependency note + +Better Auth is pinned to `1.6.26` and HLS.js to `1.6.17`. Better Auth's optional SvelteKit peer can +otherwise make npm select the Vite-8 Svelte plugin, which conflicts with this repository's Vite 7 +test toolchain. The narrow `@sveltejs/vite-plugin-svelte: 6.2.4` override keeps that optional peer on +the Vite-7-compatible line; an ordinary clean `npm ci` succeeds without legacy-peer flags. diff --git a/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md b/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md new file mode 100644 index 00000000..ae693b32 --- /dev/null +++ b/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md @@ -0,0 +1,106 @@ +# Founding Listener email magic-link boundary + +Status: Listener and private delivery are deployed. The dedicated mail sidecar +runs exact backend SHA `456ece2b38e203a2d12c54864115e03ebaa1a89c` independently from the event +runtime. A controlled message reached Gmail `SENT`; only human callback, Free +entry and logout acceptance remain. + +This fallback reuses the deployed Google Workspace/Gmail delivery capability +without copying its OAuth grant into the Listener container. It is additive to +Google sign-in and does not touch event identity, commerce, membership or +audio. + +## Listener behavior + +- Public request: `POST /api/early-birds/auth/sign-in/magic-link` from an exact + trusted Listener Origin. +- Fixed callbacks: `/early-birds` or `/early-birds/redeem`; the error callback + is exactly `/early-birds?authError=1`. +- The response and visible message are generic for unknown addresses, + throttling and delivery uncertainty. +- The Better Auth token is stored only as a SHA-256 verifier, expires after ten + minutes and is consumed atomically once. +- Durable HMAC-only buckets limit each address to three attempts and each + Origin/network-address pair to ten attempts per 15 minutes. Better Auth also + applies a three-per-minute route limit. Stale buckets are deleted after 24 + hours. +- An address already attached to a social or supervised identity receives no + magic link and cannot mint a magic-link session. Explicit future account + linking requires a separate reviewed product flow. +- Verification creates the same `hb_earlybird_session` used by Google and no + event, staff, LiveKit, payment or Founder capability. + +## Required private mail contract + +`POST /api/internal/v1/listener-magic-links/deliver` + +Headers: + +```text +Authorization: Bearer +Content-Type: application/json +Idempotency-Key: +``` + +Body (`listener-magic-link.v1`): + +```json +{ + "contract_version": "listener-magic-link.v1", + "purpose": "listener_sign_in", + "recipient": "listener@example.test", + "locale": "es", + "magic_link_url": "https://listen.example.test/api/early-birds/auth/magic-link/verify?token=opaque", + "expires_at": "2026-08-07T12:10:00.000Z" +} +``` + +The endpoint must: + +1. exist only on the private `earlybirds_authority_private` network under the + dedicated `listener-mail-api` alias; +2. authenticate the dedicated Bearer token in constant time; +3. accept only the schema above, ES/EN locale, an HTTPS + `listen.harmonicbeacon.com` or staging verification URL and a future expiry + no more than ten minutes away; +4. persist the idempotency key before queueing exactly one durable email; +5. render the bilingual subject/body inside the mail authority and never log + the recipient, full URL, token, Bearer value or body; +6. queue through the existing Gmail/Resend `EmailGateway` and worker, retaining + its current ambiguous-outcome semantics; +7. return a minimal `202 {"status":"accepted"}` for accepted or replayed work. + +The event PMP runtime owns the Gmail API OAuth grant for the Google Workspace +sender. A dedicated Listener-only API, worker and PostgreSQL queue reuse that +root-owned grant through a read-only worker mount. Listener and the sidecar API +never receive it. The sidecar has no host ports and neither deploys nor restarts +the event API or workers. + +## Configuration and rollout + +The Listener feature remains absent unless all are set: + +```dotenv +BEACON_LISTENER_MAGIC_LINK_DELIVERY_URL=http://listener-mail-api:8765/api/internal/v1/listener-magic-links/deliver +EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN=<32-plus random characters> +EARLY_BIRDS_MAGIC_LINK_RATE_SECRET=<32-plus independent random characters> +``` + +Apply migration `20260807090000_early_bird_magic_link_throttles`, deploy the +isolated mail sidecar first, then install the three protected Listener values and +recreate only the isolated Listener. Rollback clears the three values and +recreates only that container; existing Google sessions and email-only sessions +remain valid until normal expiry, while no new email request route is exposed. + +Browser acceptance uses a fresh address and proves request, receipt, callback, +Free quota and Listener. Negative checks cover unknown addresses, social +address collision, expiry, alteration, replay, callback injection, throttling, +logout and Google sign-in regression. No test email should contain real +participant data. + +Runtime evidence on 2026-08-12: sidecar API and worker were healthy with zero +restarts; the API had no host ports and only the private authority/database +networks; the worker alone had egress and a read-only Gmail grant mount. Public +Listener health/readiness stayed green at exact SHA +`4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`, while event API, workers, app and +origin container identities/restart counts remained unchanged. diff --git a/docs/architecture/LISTENER_FOUNDER_CONTINUITY.md b/docs/architecture/LISTENER_FOUNDER_CONTINUITY.md new file mode 100644 index 00000000..1826719e --- /dev/null +++ b/docs/architecture/LISTENER_FOUNDER_CONTINUITY.md @@ -0,0 +1,65 @@ +# Listener Founder service-continuity projection + +## Boundary + +`proyecciones-mito` is the sole authority for Founder service continuity. The +Listener accepts only the private authority membership v3 read and membership +command v2 projection. Both carry the same `founder_continuity` snapshot under +the same monotonic `membership_revision`. + +The browser never creates or repairs continuity. Checkout return parameters, +cookies, email, OAuth provider and provider subscription identifiers are not +commercial evidence. + +## Local read model + +Listener stores the current continuity snapshot in normalized columns on +`early_bird_membership_projections`. Membership and continuity are updated in +one PostgreSQL transaction and protected by one canonical command hash. A retry +of the same revision must be byte-equivalent; older revisions are stale and a +different payload at the same revision conflicts. + +The snapshot contains only: + +- an opaque continuity episode UUID and revision; +- ACTIVE, CANCELLED_PENDING_END, GRACE or ENDED state; +- the immutable USD 5/month Founder offer revision; +- activation and current service boundary; +- terminal timestamp and reason for an ENDED tombstone. + +It contains no PII or provider subscription identifier. `ENDED` is retained +only as an audit/reacquisition tombstone and can never authorize access, price +or a Founder badge. + +## Presentation and access + +The account menu shows “Founding Listener” only when all of these are true: + +1. the canonical membership access decision is currently allowed; +2. its source is PayPal or Mercado Pago; +3. its offer and the continuity offer are the Founder offer; +4. continuity is ACTIVE, CANCELLED_PENDING_END or GRACE. + +Once paid-through or grace ends, or a terminal event ends continuity, the badge +disappears. Free, invitation, synthetic preview and Free For All do not create +continuity. A later subscription cannot reuse an ENDED Founder episode; until a +new public offer exists, re-entry fails closed in the authority. + +## Experimental migration and rollback + +No public subscribers exist. The forward-only migration adds continuity fields +to the membership projection, copies every command.v1 projection into an +audit-only table and clears the runtime projection before accepting command.v2. +This prevents an old command hash at the same membership revision from blocking +the first canonical v2 delivery. It also retires the old positive-only table +under a second audit-only name. Neither archive has a Prisma model or runtime +reader/writer, and neither grandfathers its synthetic rows. Authority v1/v2 +contract artifacts remain only as repository history; the runtime has no +dual-read or dual-write compatibility. + +Operational rollback first disables Listener/provider writers. A binary +rollback across this migration requires both the prior image and a pre-migration +database snapshot, because the older runtime cannot read the retired tables. +Without that matched pair, recover by rolling forward. An older binary that +understands permanent account eligibility is not a valid standalone rollback +target. diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md new file mode 100644 index 00000000..49300d8b --- /dev/null +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -0,0 +1,325 @@ +# EarlyBird to Listener namespace migration + +Status: phases 1, 2A and the invitation-cookie phase 2B are integrated and +deployed on the isolated Listener at `20406da`. The Listener session-cookie +bridge (PR #249, head `b6fbac3`) is integrated via `f665f58` but NOT deployed; +its deploy, the session-cookie observation window and the dual-write support +window have not started. This migration is deliberately additive. +`EarlyBird` is an offer and cohort name; `Listener` is the durable product and +technical namespace. + +## Invariants + +- Existing `/early-birds` bookmarks, invitation links, sessions and clients keep + working throughout the migration. +- A rollout never requires clearing cookies or local storage. +- New and legacy endpoints execute the same authorization and state-transition + code. Aliases must not become a second implementation. +- Database and cross-repository contract changes are forward-only and are + coordinated with `proyecciones-mito` before either side deploys them. +- No namespace phase changes codecs, media files, playback, gain, stream leases, + payments, live-event routes or operator behavior. +- A compatibility alias is removed only after its usage has remained zero for a + full support window and the removal has its own rollback plan. + +## Inventory at `16a15d1` + +The initial scan found 168 files containing EarlyBird naming: 82 under `src`, 18 +under preview operations, 14 documentation files, 14 contract files, 10 scripts, +8 tools, 6 Prisma files, 5 services and 4 end-to-end files. + +| Surface | Current identifiers | Migration constraint | +| --- | --- | --- | +| Public pages | `/early-birds`, `/early-birds/redeem` | Preserve both URLs while `/listener` becomes canonical. | +| Browser APIs | `/api/early-birds/*` | Add aliases first; move clients only after aliases ship. Stream and drop-in paths are audio-sensitive and stay unchanged in phase 1. | +| Authentication | `/api/early-birds/auth`, `hb_earlybird`, `hb_earlybird_session` | BetterAuth base paths and cookie prefixes cannot be renamed with a simple redirect. Requires a tested dual-session bridge. | +| Invitation cookie | `__Host-hb_early_bird_invitation` | Phase 2 emits canonical `__Host-hb_listener_invitation` first, reads canonical then legacy, and dual-writes/dual-clears during the rollback window so existing invitations and rollback images survive. | +| Browser storage | `hb_earlybird_device_id`, `hb_earlybird_drop_progress_*` | Dual-read legacy/canonical and canonical-write later. This is inside the player boundary and is not touched in phase 1. `hb_listener_playback_mode` is already canonical. | +| Environment | 34 explicit `EARLY_BIRDS_*` names plus language-specific drop-in keys | Add `BEACON_LISTENER_*`-first/legacy-fallback readers in bounded groups; never rename deployment configuration before the binary accepts both. | +| PostgreSQL | `early_bird_users`, identities, sessions, verifications, magic-link throttles, memberships, free schedules, welcome accesses and stream leases | Treat physical names as private persistence details during application cutover. Do not perform table renames with a web namespace rollout. | +| Cross-repo contracts | `early-bird-authority.v1`, `early-bird-membership.command.v1`, internal EarlyBird membership/invitation paths | Versioned public wire identifiers. Preserve byte-for-byte until both repositories agree on a new contract version. | +| Metrics/ops | Preview container, network, volume, nginx and script names use `earlybirds`; Listener presence is already canonical | Operational resource renames require side-by-side resources or a maintenance window. Labels should keep a stable legacy alias until dashboards and alerts move. | + +## Phase 1: additive public routing + +Implemented in this branch: + +- `/listener` and `/listener/redeem` render the same server components and locale + layout as their legacy counterparts. +- Canonical non-media API aliases exist for access state, free-window selection, + free invitation redemption and welcome access. +- Invitation query tokens are scrubbed on both URL families. Only the canonical + `listen.harmonicbeacon.com` host places one in the existing HttpOnly cookie; + staging first redirects the bearer once to that host and cannot mint a + staging-scoped cookie that would be lost during OAuth. +- `/early-birds` and every legacy API remain unchanged. Current clients continue + using them, making rollback equivalent to removing the new aliases. + +Phase 1 intentionally does not redirect `/early-birds`. Preview nginx currently +rewrites `/` internally to that route, and redirecting it before nginx and client +callbacks move would create unnecessary hops and could expose deployment +internals. + +## Phase 2A: non-media browser and edge cutover + +1. Deploy phase 1 and record requests by route family without user identifiers. +2. Change non-media fetches and navigation to `LISTENER_NAMESPACE.canonical`. +3. Make redemption and login callback responses return `/listener` while still + accepting `/early-birds` callback URLs. +4. Update nginx to serve `/listener` at `/`; keep exact legacy locations proxied. +5. Add browser tests that start with a legacy invitation cookie, enter on the + canonical URL, refresh and finish redemption without signing in again. + +The phase 2A candidate changes only account-local access-state, Free-window, +welcome-access and invitation redemption. Better Auth continues to use its +legacy base path and cookies. The public `listen` edge exposes only the exact +canonical and compatibility invitation pages and POSTs; synthetic entry remains +staging only. Staging invitation pages and magic verification redirect through +exact unlogged locations to `listen`, while both staging redeem POST aliases +fail closed. Every non-Listener application host scrubs an invitation query but +never mints its cookie. Stream, heartbeat, manifest, drop-in and player storage +paths remain on their accepted legacy URLs. + +Roll out the edge and application as a compatibility handoff, never as one +blind replacement: + +1. install the additive exact nginx locations while `/` still rewrites to + `/early-birds`, run `nginx -t`, reload and confirm legacy smoke; +2. deploy the application image containing both route families; +3. smoke `/listener` and every canonical non-media API directly; +4. change the internal `/` rewrite to `/listener`, run `nginx -t`, reload and + verify that the browser-visible URL remains `/`; +5. retain legacy routes for the full measured support window. + +Rollback reverses that order: restore the `/early-birds` root rewrite first, +then restore the previous image. The additive exact locations may remain dark; +no database, cookie, environment or media rollback is required. + +Stream, heartbeat, manifest, drop-in and player storage paths are a separate +audio-reviewed slice. Their aliasing must not modify response bytes, timing, +cache headers, lease semantics or the playback controller. + +## Phase 3: authentication and cookies + +First introduce canonical invitation-cookie helpers that read canonical then +legacy, write both during the overlap, and clear both on redemption. After at +least one deployed support window, stop writing the legacy cookie but continue +reading it for another window. + +The overlap is not retired by date alone. Keep dual-write for at least seven +consecutive days after every Listener instance runs the compatibility image, +one real Google invitation completes, rollback passes and no eligible rollback +image depends on legacy-only state. Then keep canonical-write/dual-read for a +second seven-day observation window with zero legacy-only/conflict observations +(record presence only, never cookie values). A legacy-only or conflict +observation resets that window. Remove the legacy read only afterward, and keep +dual-clear for one additional release. The invitation TTL remains 30 minutes; +the longer windows protect rollback and in-flight identity rather than extend +the bearer lifetime. + +BetterAuth requires a separate design checkpoint. The canonical auth base path +must accept sessions issued with the legacy cookie prefix. The migration must be +proved with Google, Apple and magic-link callbacks, CSRF/origin checks, refresh, +logout and two concurrent devices before clients change their callback URL. Do +not run two independent auth stores or silently create a second account for the +same identity. + +### Listener session-cookie bridge + +The first step of that checkpoint ships as a strict wrapper around the single +Better Auth instance (`src/lib/listener/session-cookie-bridge.ts`). Better Auth +stays the sole session authority on the legacy base path; its signed cookie +value is opaque and its HMAC does not cover the cookie name, so the value is +portable verbatim under a second name. The bridge never parses, decodes, +re-signs or logs the value, and it never touches OAuth state, PKCE or any other +non-session cookie. + +Outbound, every legacy session `Set-Cookie` Better Auth emits (mint, rotation, +clear) is mirrored onto the canonical name byte-identically, so sign-in, +refresh and sign-out always move both cookies together with one scope. +Ambiguous output (repeated same-name mutations, mismatched pairs, canonical +mutations without a legacy counterpart) is an internal failure: the response +is replaced by a generic 500 carrying no `Set-Cookie` at all. + +Inbound, exactly three states may reach Better Auth: + +1. no session cookie; +2. exactly one legacy-only session cookie (the rollback window); +3. exactly one canonical plus one legacy cookie with byte-identical values. + +Everything else terminates with a generic 400/401 BEFORE Better Auth can mint, +rotate or clear anything: canonical-only (401), duplicate same-name cookies, +conflicting pairs, malformed percent encoding or control characters, oversized +values, and oversized Cookie headers (all 400). The generic body carries no +token or cookie detail and rejected values are never echoed. + +Every rejection also expires BOTH exact session cookie names with `Max-Age=0` +and the scope Better Auth actually resolved (`Path=/`, `HttpOnly`, +`SameSite=Lax`, `Secure` when the resolved names carry the `__Secure-` prefix; +the scope is derived from `getCookies(auth.options)` and no `Domain` is ever +invented). This dual-clear is what keeps a deploy → rollback to `20406` → +redeploy sequence recoverable: the rollback image's sign-out clears only the +legacy name, so a stale canonical cookie would otherwise 401 forever, and an +old re-login can leave a stale canonical A plus a fresh legacy B that conflicts +with 400 — while every auth mutation that could repair the jar stops before +Better Auth. Expiring both names logs the client out but lets the next clean +sign-in mint a fresh dual pair. Direct `getSession` paths apply the same +inbound policy, fail closed to `null`, and cannot set response cookies. + +Canonical-only acceptance is deliberately deferred until every rollback image +in the support window emits and accepts the canonical name; accepting it now +would let a rollback image silently strand the session it cannot read. The +401-plus-dual-clear state flips to accepted only after the dual-write bridge +has been the oldest supported rollback image for a full support window. + +Browser-state matrix for the bridge image: + +| Browser jar on request | Bridge response | Client outcome | +| --- | --- | --- | +| No session cookie | Forwarded | Sign-in/OAuth mints the exact dual pair. | +| Legacy only | Forwarded | Session valid; rotation and sign-out stay dual. Rollback-safe. | +| Canonical + legacy, identical | Forwarded | Session valid; both cookies move together. | +| Canonical only | 401 + dual expiry | Logged out; next clean sign-in recovers with a fresh dual pair. | +| Canonical A + legacy B (conflict) | 400 + dual expiry | Logged out; next clean sign-in recovers. | +| Duplicate of either name | 400 + dual expiry | Logged out; never silently selected first-wins. | +| Malformed, oversized value or header | 400 + dual expiry | Logged out; no downgrade to an adjacent valid cookie. | + +Rollback of the bridge itself removes only the wrapper: the legacy-only path +is byte-identical to `20406`, no database, environment or cookie migration is +required, and canonical cookies left behind are expired by the dual-clear on +the next rejected request or sit harmlessly unread. + +### Session-cookie compatibility observability + +The bridge ships with an aggregate-only observation slice +(`src/lib/listener/session-cookie-observability.ts`) that sizes the +rollback-compatible support window. Both session resolvers — the auth-handler +bridge wrapper and `currentEarlyBirdSession` — inspect every inbound Cookie +header through the same pure `inspectListenerSessionCookie(header, names)`, +which returns `{ state, resolution }`; the enforced resolution is +byte-identical to the pre-observability bridge, and recording is fail-soft +inside try/catch so an observer failure can never change an auth outcome. + +Metric contract (fixed, no external labels ever accepted): + +- `beacon_listener_session_cookie_observations_total{state="..."}` — counter + with exactly one label, `state`, over a closed allowlist of nine states; +- `beacon_listener_session_cookie_observer_process_start_time_seconds` — + unlabeled gauge with the Unix epoch seconds at which this observer process + created its registry. + +Categories and classification precedence (first match wins): + +1. `none` — no relevant session cookie (or no Cookie header at all); +2. `oversized_header` — the whole Cookie header exceeds 8192 characters; +3. `duplicate_name` — either relevant name appears more than once; +4. `oversized_value` — a relevant value exceeds 512 characters; +5. `malformed_value` — a relevant value is empty, off the wire charset or + carries a bad percent escape; +6. `canonical_only` — a well-formed canonical cookie without its legacy + counterpart (rejected 401 during this phase); +7. `conflicting_pair` — canonical and legacy values differ (rejected 400); +8. `legacy_only` — exactly one legacy cookie (forwarded; the rollback window); +9. `dual_identical` — a byte-identical canonical/legacy pair (forwarded). + +Counters measure resolver INVOCATIONS, not unique users, browsers or +sessions: one navigation may invoke a resolver several times and one session +is observed on every request, so multiple observations per navigation are +expected. Recording is limited to the exact canonical Listener Host so +staging and synthetic rehearsals cannot contaminate the support-window +series. Even on that host these are raw cookie-shape observations before +cryptographic session verification: a public client can inflate them, so +they are conservative migration safety signals and must never automatically +permit or block a cutover without the correlated provider and rollback +evidence required above. The registry is per process/replica, resets on process restart and +saturates at `Number.MAX_SAFE_INTEGER`; the start-time gauge separates +epochs. A current zero therefore cannot prove seven quiet days: snapshots +must be archived externally per epoch, and any restart or gap without an +archived snapshot invalidates window continuity. + +Privacy: only aggregate counts and the process-start epoch are stored or +rendered. No cookie, header, user, session, account, IP or user-agent value +ever reaches the registry or the exposition. + +Loopback runbook: the exposition is served GET-only by +`/api/internal/v1/listener/session-cookie-observations`, which answers 404 on +any request Host other than the canonical Listener host (the request Host +header, never a forwarded one) and `Cache-Control: private, no-store`. The +public Listener nginx templates deliberately do not expose or proxy this +path; read it from the host with: + +```sh +curl -fsS -H 'Host: listen.harmonicbeacon.com' \ + http://127.0.0.1:13000/api/internal/v1/listener/session-cookie-observations +``` + +This source slice neither connects Prometheus nor starts or certifies the +support window; scraping, alerting and window bookkeeping are private ops +wiring reserved for a later, separately authorized slice. + +The accepted #210 policy remains in force: physical `early_bird_*` tables, +applied migrations and v1 cross-repository wire identifiers are historical +compatibility surfaces and must not be renamed, and the +canonical-only/basePath/prefix cutover stays gated by a deployed support +window, real Google and rollback acceptance, and the remaining callbacks. + +## Phase 4: environment and operations + +Introduce a typed resolver for each bounded environment group: + +1. `BEACON_LISTENER_*` preferred, `EARLY_BIRDS_*` fallback; +2. fail readiness when both are set to different values for security-sensitive + keys or origins; +3. emit only the selected key name, never its value, in validation output; +4. update staging configuration and validate; then update production separately; +5. remove fallback only after all rollback images use canonical names. + +The first bounded slice covers identity and non-media access controls only: +public enablement, Free For All, auth base/trusted origins/secret, Google and +Apple credential pairs, the magic-link delivery trio, and synthetic staging +entry. Credential bundles must be complete within one generation and a dual +configuration must agree after normalization. Conflicts fail closed and error +messages contain variable names only. The deployed preview compose continues +to emit the legacy keys for the first support window, so its existing rollback +image remains valid. Authority, service credentials, stream, drop-ins and +device identifiers are explicitly deferred to separate reviewed slices. +The auth singleton reads configuration once; every env transition therefore +requires a Listener process restart and cannot be treated as a hot switch. The +readiness endpoint validates this bounded configuration before reporting green; +it reports only a generic public failure while the server diagnostic contains +variable names and never their values. Processes without Listener configuration +remain unaffected. + +Operational resource names can remain legacy until replacements are created +side-by-side. Docker volumes and PostgreSQL identities must never be renamed as a +cosmetic cleanup. Dashboards should query canonical and legacy metric labels +during the overlap. + +## Phase 5: persistence and wire contracts + +Rename Prisma model symbols first while retaining `@@map("early_bird_...")` so +application terminology becomes canonical without moving data. Physical table +names are not a public product surface and should remain stable unless there is +a measured operational benefit. + +If physical renames are eventually approved, use a dedicated forward-only +migration after every deployed binary targets the mapped Listener models. The +rollback is a roll-forward compatibility migration, not a down migration. Take a +verified backup, rehearse on a restored database, check locks and query plans, +and never combine the operation with an application or contract release. + +Wire schema versions, idempotency-key prefixes and internal endpoint paths remain +unchanged in this project until `proyecciones-mito` and Beacon publish matching +v2 fixtures and validators. The safe sequence is accept v1+v2, emit v1, switch +emission to v2, observe, then retire v1 in a later release. + +## Verification and rollback + +Each phase must prove both namespaces, legacy session continuity, invalid-token +scrubbing, same-origin mutation behavior and absence of authorization drift. +Route-family counters must contain no account, email, invitation or session +material. + +Phase 1 rollback removes only canonical pages/APIs and the two canonical +middleware matchers. It performs no database, cookie, environment, media or +contract rollback. diff --git a/docs/decisions/0002-earlybird-identity-boundary.md b/docs/decisions/0002-earlybird-identity-boundary.md new file mode 100644 index 00000000..e1ee9604 --- /dev/null +++ b/docs/decisions/0002-earlybird-identity-boundary.md @@ -0,0 +1,38 @@ +# EarlyBird identity is separate from event and staff identity + +*Accepted 2026-08-06 for the EarlyBirds milestone.* + +## Decision + +EarlyBirds uses an exact stable Better Auth release with configured Google and +Apple providers plus an optional one-use email magic-link fallback through the +existing private mail authority. +It owns additive `EarlyBirdAccount`, provider identity and session data, a +separate `hb_earlybird_session` cookie and namespaced routes. Cross-provider +account linking and Facebook are disabled. + +The email fallback was added on 2026-08-07. Its random token is stored only as +a verifier, expires after ten minutes and is consumed once. It never silently +authenticates a Listener whose address is already owned by a social or +supervised identity; explicit account linking remains outside this milestone. + +An EarlyBird session can request a current membership projection and signed +media lease. It can never create a staff principal, an event ticket principal, +a LiveKit token or an event capability. Provider subject is the external key; +verified email is contact evidence and never an authorization key. + +## Security invariants + +- Authorization Code, PKCE, state and nonce are mandatory. +- Provider tokens are not retained without a new reviewed requirement. +- Two active device leases are allowed; a third evicts the oldest lease. +- Logout, account disable and membership revoke invalidate future media leases. +- The product stores no minor profile or minor-specific data; an adult owns the + account and payment. +- A dependency install must pass the repository's ordinary clean CI install; + no hidden local package-manager flag is an accepted runtime dependency. + +## Rollback + +Disable the EarlyBird feature flag and its OAuth callbacks. Additive identity +rows remain inert. Event and staff sessions continue unchanged. diff --git a/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md b/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md new file mode 100644 index 00000000..a01df35a --- /dev/null +++ b/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md @@ -0,0 +1,43 @@ +# Deterministic HLS delivery with an explicit audio guardrail + +*Accepted 2026-08-06 for the EarlyBirds milestone.* + +## Decision + +The 24/7 Listener source is delivered as deterministic HTTP HLS: an approved, +immutable artifact has a fixed UTC epoch and immutable six-second segments; a +small origin derives the current media sequence from wall-clock time. Restarting +the origin does not restart or duplicate the Beacon timeline. + +Safari uses native HLS and other supported browsers use `hls.js`. Membership +authorizes an opaque, three-minute media grant registered on the origin over a +private network. The browser receives one stable direct-origin URL; heartbeats +extend the same grant without replacing `audio.src`. Manifest and segment +fetches therefore do not depend on the Listener process, auth service or +database. The origin stores only a token hash and expiry, compares credentials +in constant time and fails closed at the lease horizon. Bearer query strings +are never logged. Public health is minimal; metrics bind privately. + +The current immutable loop is a temporary source adapter for development. The +delivery and grant boundary is source-agnostic: a future continuously played +Beacon is packaged into the same advancing HLS timeline without changing +browser authorization or the audio controls. + +## Audio boundary + +The source master is +`/home/nicolas/Music/beacon/luz_de_manana_20260624-155633.wav`. This decision does +not select a codec, bitrate, sample rate, channel layout, loudness treatment or +delivery artifact. Those choices require reproducible provenance, the complete +file-to-player test ladder and Nico's explicit listening approval. + +Drop-ins use the exact Amara Sol ES/EN voice masters and an offline candidate +render with a chosen Beacon excerpt ducked by 9 dB. They retain private standard +playback controls and never join the shared Beacon timeline. Their content and +audio artifacts also require Nico's approval. + +## Event boundary and rollback + +The milestone does not edit event `AudioContext`, LiveKit, playlist-bot or +crossfader paths. Stop the independent origin and disable EarlyBird routes to +roll back. Reusing this stream in events is a separate post-milestone decision. diff --git a/docs/decisions/0004-provider-neutral-earlybird-membership.md b/docs/decisions/0004-provider-neutral-earlybird-membership.md new file mode 100644 index 00000000..d3aa0198 --- /dev/null +++ b/docs/decisions/0004-provider-neutral-earlybird-membership.md @@ -0,0 +1,43 @@ +# Provider-neutral EarlyBird membership + +*Accepted 2026-08-06 and amended 2026-08-10 for the EarlyBirds milestone.* + +## Decision + +`proyecciones-mito` is the canonical authority. Free invitations, PayPal, +MercadoPago and future app-store providers emit one ordered, idempotent +membership projection. The web app never trusts a success redirect or provider +payload as access truth. + +The founder offer is an immutable USD 5/month offer revision. "Lifetime" means +that price remains guaranteed only while the canonical Founder subscription is +active and uninterrupted; it is not a permanent account entitlement. A pending +cancellation keeps access and Founder status through paid-through time and may +be reversed before service ends without breaking continuity. Once service +actually ends, Founder status and price eligibility end. Any later signup uses +the then-current public offer. Involuntary payment failure receives the approved +grace period, but terminal failure, refund, chargeback, dispute, fraud or +administrative termination does not preserve Founder status. Browser redirects +remain incapable of creating or erasing commercial truth. + +Founder pricing, active membership, current listening authorization and +payment/reconciliation history remain separate canonical concepts, but Founder +pricing is continuity-bound rather than an immutable positive-only account +grant. A checkout start, success redirect, failed attempt or unconfirmed +provider event grants none of them. + +Free invitations are signed, single-use, EarlyBird-scoped, auditable, revocable +and indefinite until used or revoked. They work in staging and production. A +Free-to-paid transition consumes the free grant. + +MercadoPago displays USD 5 and the ARS equivalent from BCRA A3500, locks the +renewal amount 72 hours before collection and retains the previous valid amount +when the rate source is unavailable. Unknown or incomplete provider state fails +closed. + +## Integration and rollback + +All provider delivery is idempotent, ordered and reconciled. Sandbox lifecycle +tests cover duplicates, reordering, retry, grace, cancellation, refund, dispute +and revoke before any real charge is enabled. Rollback disables new checkout +and media lease issuance; durable membership evidence is preserved. diff --git a/docs/decisions/0005-earlybirds-fast-forward-lane.md b/docs/decisions/0005-earlybirds-fast-forward-lane.md new file mode 100644 index 00000000..62986e8f --- /dev/null +++ b/docs/decisions/0005-earlybirds-fast-forward-lane.md @@ -0,0 +1,31 @@ +# Isolated Fast Forward delivery lane for EarlyBirds + +*Accepted 2026-08-06 for the EarlyBirds milestone.* + +## Decision + +Short feature branches merge into the shared `early-birds` branch. That branch +is never rebased after publication. Current green `main` is merged into it at +controlled checkpoints; final convergence is one reviewed merge into then-current +`main`. + +Small slices run changed-file lint, focused tests and a local smoke. Integration +checkpoints run TypeScript, relevant suites, migration/container smoke and one +browser path. Full CI, browser/device audio, auth/commerce adversarial tests, +load/soak and rollback rehearsal run once for a release candidate. + +Preview uses its own compose project, database, secrets, cookies, OAuth callback +and bounded stream origin. Event-day safety is binary: if convergence is not +accepted, stop EarlyBirds and run the known-good event release. + +## Capacity and operations + +The planning budget is 450 kbit/s per listener with 40% network headroom: 3,000 +committed, 4,000 expansion and 5,000 critical. Measurements, not the advertised +3 Gbit/s NIC, decide scaling. Bunny CDN is prepared but activated only at the +expansion or origin-quality trigger. + +Prometheus, node-exporter, cAdvisor, Alertmanager and an external decoded-audio +canary report to the private `Harmonic Beacon · Ops` Telegram group. Warnings +group and repeat hourly; critical alerts send immediately and repeat every 15 +minutes; recovery always notifies. Alerts contain no PII or secrets. diff --git a/docs/design/LISTENER_REACTIVE_CAMPFIRE.md b/docs/design/LISTENER_REACTIVE_CAMPFIRE.md new file mode 100644 index 00000000..96195773 --- /dev/null +++ b/docs/design/LISTENER_REACTIVE_CAMPFIRE.md @@ -0,0 +1,107 @@ +# Listener reactive field + +Listener renders a visualization of the source that is actually audible. +Playback stays on the browser's native HLS and HTML media path. The accepted +field is enabled on `listen.harmonicbeacon.com`; its technical tuning laboratory +is hidden by default. + +## Signal contract + +- The Beacon fundamental is exactly **40.4 Hz**. +- Harmonic identity is stable through the measurable bank up to 20 kHz. +- Absolute dB determines visual existence and weight. Deviation from a slow + baseline determines motion and trail only; it never promotes a quiet upper + harmonic above a stronger low harmonic. +- `HarmonicAnalysisFrame` is renderer-neutral. Canvas does not know whether a + frame originated beside a file-backed or future live stream. +- The current server analyzer uses stereo 48 kHz PCM, FFT 16384 and a declared + `-120..0 dB` range. The ordered remote-frame provider keeps the 24-second slow + baseline across segment boundaries. Normal cadence is four frames per second; + Reduced-motion, Save-Data and Minimal pulse request two. + +## Server-side analysis boundary + +The browser never creates an `AudioContext`, `MediaElementAudioSourceNode` or +second media element for visualization. It never changes `crossOrigin`, volume, +fades, buffer policy, source attachment or routing. Enabling or disabling the +field does not remount the player. + +For the current file-backed Beacon, the server decodes the exact AAC/fMP4 HLS +fragment that the origin delivers, computes bounded harmonic frames and caches +them by segment. It does **not** analyze the source WAV ahead of time. The client +sends the `PROGRAM-DATE-TIME` corresponding to its audible HLS position; native +HLS maps that position from its seekable live edge. The response contains only +bounded numeric analysis arrays and no account, cookie, media URL, IP or other +identity data. + +The analysis endpoint exists only on the exact staging and canonical Listener +hosts, requires the same active lease authority as the HLS manifest, accepts +only the bounded audible latency window, is independently edge-rate-limited and +returns `no-store`. Decode concurrency is globally bounded and the cache retains +the complete accepted timestamp window. Failure is visual-only: +after four bounded failures the provider hides the field while native playback +continues. Intro playback clears the frame because a synchronized intro analysis +stream is not implemented yet; frames resume at the Beacon handoff. + +The same transport boundary can later receive frames produced beside a live +encoder. The renderer and playback controller do not change. Polling is adequate +for this one-user workbench; public scale should replace it with a shared retained +transport such as SSE or WebSocket rather than decoding separately per process. + +## Visual language + +The point of view is fixed. Neither stereo balance nor aggregate energy moves +the center or camera. Each audible harmonic follows deterministic continuous +motion whose amplitude is bounded by measured energy and softly modulated by its +baseline variation. A band appears only after measured activation, glows in +proportion to activation, and fades over the configured TTL. TTL is visual +memory, not invented signal. + +Outer ribbons use a lightweight pinned-cloth model. Their inner edge remains +anchored, a gentle wave keeps the field alive, and measured harmonic activity +increases displacement toward the free edge. The original Radial ribbons mode +can render bounded translucent whole-ribbon history, leaving a ghostly trace of +the movement. + +The laboratory offers one low-cost and four full renderers over the same frame: + +- **Minimal pulse** draws one fixed measured-level halo at two frames per second. +- **Harmonic radial series** places the complete selected harmonic bank in + concentric bands; outer-spacing growth expands upper harmonic separation. +- **Radial ribbons** divides the complete bank between center and outer ribbons + using a true 0–100% Center field control. +- **Inner-anchor kelp** preserves Radial ribbons as-is and adds an experimental + top-down kelp field. Each leaf is pinned beside the fixed center; a measured + harmonic rising edge launches a causal wave from that inner anchor toward + the free edge. Propagation speed, damping and impulse are visual-only lab + controls. Center field scale and width are independent from the outer-ribbon + width. A signed camera-rotation control selects speed and direction around + the same fixed center; neither the camera nor the center follows audio energy. +- **Horizon flow** pours broad harmonic ribbons from fixed horizon positions. + +Changing renderer, cut, zoom, activation TTL, width, palette or other visual +controls never affects playback. FFT size and baseline are fixed server analysis +parameters in this build and remain visibly read-only in the laboratory. + +## Staging and acceptance + +The field is on by default. The checkbox and parameter panel are off by default +on every host. Operators can restore them only on the exact staging host with +`BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED=1`; the canonical public host never +exposes them. Presets export as versioned JSON. The accepted Inner-anchor kelp +Aurora default is: sensitivity 3, -101 dB floor, 24 s baseline, 30 ms attack, +380 ms release, 4 s whole-ribbon trail, density 0.6, upper-detail bias 1, +center field 4%, center scale 28%, center width 0.8, outer-spacing growth 65%, +zoom 220%, activation TTL 13.5 seconds, outer ribbon width 3, propagation 0.5, +damping 3, impulse 3, camera rotation -59.5 degrees/minute and FFT 16384. + +The retired client Web Audio diagnostic mode and the older regional fixture have +no runtime compatibility promise: this is an experimental product before public +release. + +Nico accepted the field and confirmed that intro and Beacon audio remained +correct before public deployment. Continue the physical matrix on Chrome, +Firefox, Android and iPhone, including ES/EN introduction handoff, Beacon-only, +Stop, reconnect, headphones, Bluetooth and a 60-minute listen. Record server +decode latency/cache behavior, client network cadence, CPU, memory and frame +pacing. diff --git a/docs/design/prototypes/listener-ux/README.md b/docs/design/prototypes/listener-ux/README.md new file mode 100644 index 00000000..983c16d5 --- /dev/null +++ b/docs/design/prototypes/listener-ux/README.md @@ -0,0 +1,76 @@ +# Listener UX concept prototypes + +Three disposable, deterministic explorations of the Listener experience. They are decision artifacts, not application code: nothing here imports the Listener runtime, calls an API, authorizes playback, or starts a checkout. + +The prototypes use the canonical Harmonic Beacon fonts, palette, mark geometry and visual tokens already vendored in this repository. They were produced from `upstream/early-birds@6b01ba52591e13d65f66c97e6c09e50096a2bba8`; the canonical brand snapshot records source commit `0052e5f45a108b6069fc11e4c6565f4da4f77d9f`. + +## Open and compare + +Open any HTML file directly in a browser. Use the temporary state switcher at the top, or add a query parameter such as `?state=founder` or `?state=checkout`. The switcher disappears when `capture=1` is present. + +- [Immersive Field](immersive-field.html) +- [Listening Altar](listening-altar.html) +- [Spatial Dashboard](spatial-dashboard.html) + +Each concept includes these static states: + +- visitor sign-in; +- Free quota and renewal; +- active Founding Listener membership; +- listening with Pause on the left and compact Stop on the right; +- paused session; +- profile details; +- checkout choice. + +The controls only change the visual state in the document. Provider buttons, sign-in controls and transport controls have no external behavior. + +All three directions reproduce the same static global-navigation contract: seven desktop destinations plus EN/ES, and hamburger/brand/language on mobile. Listener quota, membership and profile controls live in a separate product strip below it. The Lissajous remains a small header mark only; the listening center uses an abstract signal nucleus rather than treating the logo as a visualization. + +## Comparison + +| Concept | Core idea | Strengths | Risks | Mobile treatment | +| --- | --- | --- | --- | --- | +| **Immersive Field** | The Beacon field is the product; UI sits at its edges. | Most immediate and atmospheric; one dominant action; listening state feels spacious; easiest path from the existing background-field language. | Text over an expressive field needs strict contrast discipline; future controls could clutter the composition; peripheral status is less discoverable. | Removes the editorial side columns, preserves the center, quota chip and bottom transport. | +| **Listening Altar** | A stable, ceremonial center contains the entire listening act. | Strong hierarchy; calm and trustworthy; controls never drift; visitor, listening and paused states share one understandable frame. | Can feel overly solemn or static; the central card consumes substantial small-screen space; secondary product features have no obvious home. | The altar becomes the viewport, with compact rings, readout and transport in one column. | +| **Spatial Dashboard** | Listening, allowance and identity occupy distinct planes. | Clearest operational state; scales best to listening history, membership and future quest rewards; profile signals are immediately legible. | Risks feeling like conventional software; secondary data can compete with listening; requires aggressive progressive disclosure on mobile. | Context and profile planes collapse; only the full-height player and top-level allowance remain. | + +## Recommendation to test + +Start user review with **Immersive Field** as the primary direction and **Listening Altar** as the conservative alternative. Immersive Field best expresses a rich Beacon client without turning the signal into a dashboard. Listening Altar is the strongest fallback if people need a more explicit visual container. Keep Spatial Dashboard as the reference for how account history, earned time or quests could later expand without forcing those surfaces into the listening view. + +This is a design recommendation only. It does not authorize a production redesign. + +## Responsive and motion notes + +- The comparison targets are 390 × 844 and 1440 × 900. The CSS also retains a 320 px minimum width and compact handling below 360 px. +- Touch controls are at least 44 px tall; the primary action is 48 px tall. +- Keyboard focus uses the canonical light-gold outline rather than color alone. +- `prefers-reduced-motion: reduce` removes transitions and animations. The field is CSS-only and already static, so reduced-motion keeps the same composition without simulated movement. +- Status colors retain meaning: gold is action/membership, bone is content, muted ink is secondary information, and the Stop control uses a restrained red semantic treatment. + +## Screenshots + +All screenshots show the listening state and are generated by `capture.mjs` with reduced motion enabled. + +| Concept | 390 px | 1440 px | +| --- | --- | --- | +| Immersive Field | [PNG](screenshots/immersive-field-390.png) | [PNG](screenshots/immersive-field-1440.png) | +| Listening Altar | [PNG](screenshots/listening-altar-390.png) | [PNG](screenshots/listening-altar-1440.png) | +| Spatial Dashboard | [PNG](screenshots/spatial-dashboard-390.png) | [PNG](screenshots/spatial-dashboard-1440.png) | + +## Reproduce the checks + +From the repository root, with project dependencies installed: + +```bash +node docs/design/prototypes/listener-ux/capture.mjs +``` + +The script derives `mark.svg` from the canonical path, opens all 42 concept/state/viewport combinations, rejects horizontal overflow or a missing current-state surface, and writes the six listening-state screenshots. It never starts the app or contacts a remote service. + +## Explicit non-goals + +- No changes to Listener pages, components, auth, membership, quotas, payments or playback. +- No changes to audio, stream manifests, LiveKit, events or Live. +- No API calls, browser storage, telemetry or dynamic user data. +- No deployment or selection of a winning concept. diff --git a/docs/design/prototypes/listener-ux/capture.mjs b/docs/design/prototypes/listener-ux/capture.mjs new file mode 100644 index 00000000..601fcece --- /dev/null +++ b/docs/design/prototypes/listener-ux/capture.mjs @@ -0,0 +1,135 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { chromium } from "playwright"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../../../.."); +const markSource = readFileSync(join(root, "src/brand/canonical/hb-mark.ts"), "utf8"); +const markPath = markSource.match(/HB_LISSAJOUS_PATH\s*=\s*\n?\s*"([^"]+)"/)?.[1]; + +if (!markPath) throw new Error("Canonical Lissajous path not found"); + +const markSvg = `\n`; +writeFileSync(join(here, "mark.svg"), markSvg); + +const concepts = ["immersive-field", "listening-altar", "spatial-dashboard"]; +const states = ["visitor", "free", "founder", "listening", "paused", "profile", "checkout"]; +const viewports = [ + { name: "390", width: 390, height: 844 }, + { name: "1440", width: 1440, height: 900 }, +]; + +const browser = await chromium.launch({ headless: true }); + +try { + for (const concept of concepts) { + for (const viewport of viewports) { + const page = await browser.newPage({ viewport }); + await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "dark" }); + const remoteRequests = []; + page.on("request", (request) => { + if (/^https?:/.test(request.url())) remoteRequests.push(request.url()); + }); + + for (const state of states) { + const url = new URL(pathToFileURL(join(here, `${concept}.html`))); + url.searchParams.set("state", state); + url.searchParams.set("capture", "1"); + await page.goto(url.href); + await page.waitForFunction(() => document.fonts.status === "loaded"); + + const geometry = await page.evaluate((currentState) => { + const isRendered = (node) => { + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const smallTargets = [...document.querySelectorAll("a, button, input")] + .filter(isRendered) + .filter((node) => { + const rect = node.getBoundingClientRect(); + return rect.width < 43.5 || rect.height < 43.5; + }) + .map((node) => node.getAttribute("aria-label") || node.textContent?.trim() || node.tagName); + const navRect = (selector) => { + const rect = document.querySelector(selector)?.getBoundingClientRect(); + return rect ? { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom } : null; + }; + + return { + documentWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + visibleStateNodes: [...document.querySelectorAll(`[data-show~="${currentState}"]`)].filter(isRendered).length, + smallTargets, + brokenImages: [...document.images].filter((image) => image.naturalWidth === 0).length, + headerMarks: document.querySelectorAll('.topbar .brand img[src="mark.svg"]').length, + mainMarks: document.querySelectorAll('main img[src="mark.svg"]').length, + navLinks: document.querySelectorAll(".nav-links a").length, + navLabels: [...document.querySelectorAll(".nav-links a")].map((link) => link.textContent?.trim()), + visibleNavLinks: [...document.querySelectorAll(".nav-links a")].filter(isRendered).length, + menuVisible: [...document.querySelectorAll(".nav-menu")].filter(isRendered).length, + accountControlsInHeader: document.querySelectorAll(".topbar .chip, .topbar .member-mark, .topbar .avatar").length, + navPositions: { + brand: navRect(".global-nav .brand"), + language: navRect(".global-nav .nav-language"), + menu: navRect(".global-nav .nav-menu"), + }, + }; + }, state); + + if (geometry.documentWidth > geometry.viewportWidth + 1) { + throw new Error(`${concept}/${state}/${viewport.name}: horizontal overflow ${geometry.documentWidth} > ${geometry.viewportWidth}`); + } + if (geometry.visibleStateNodes === 0) { + throw new Error(`${concept}/${state}/${viewport.name}: state has no visible content`); + } + if (geometry.smallTargets.length > 0) { + throw new Error(`${concept}/${state}/${viewport.name}: undersized targets: ${geometry.smallTargets.join(", ")}`); + } + if (geometry.brokenImages > 0) { + throw new Error(`${concept}/${state}/${viewport.name}: ${geometry.brokenImages} broken images`); + } + if (geometry.headerMarks !== 1 || geometry.mainMarks !== 0) { + throw new Error(`${concept}/${state}/${viewport.name}: Lissajous must appear once in the header and never in main`); + } + const desktopNavigation = viewport.width > 1120; + if (geometry.navLinks !== 7 || geometry.visibleNavLinks !== (desktopNavigation ? 7 : 0) || geometry.menuVisible !== (desktopNavigation ? 0 : 1)) { + throw new Error(`${concept}/${state}/${viewport.name}: global navigation does not match its desktop/mobile contract`); + } + if (geometry.navLabels.join("|") !== "Events|Listen|News|Why it works|Team|HIT|Contact") { + throw new Error(`${concept}/${state}/${viewport.name}: global navigation labels/order drifted from the canonical asset`); + } + if (!desktopNavigation && !(geometry.navPositions.brand.right < geometry.navPositions.language.left && geometry.navPositions.language.right < geometry.navPositions.menu.left)) { + throw new Error(`${concept}/${state}/${viewport.name}: mobile navigation must render brand, language, then hamburger`); + } + if (!desktopNavigation && !(Math.abs(geometry.navPositions.brand.top - geometry.navPositions.language.top) < 2 && Math.abs(geometry.navPositions.language.top - geometry.navPositions.menu.top) < 2)) { + throw new Error(`${concept}/${state}/${viewport.name}: mobile navigation items must share one row`); + } + if (geometry.accountControlsInHeader !== 0) { + throw new Error(`${concept}/${state}/${viewport.name}: Listener account controls leaked into global navigation`); + } + } + + if (remoteRequests.length > 0) { + throw new Error(`${concept}/${viewport.name}: unexpected remote requests: ${remoteRequests.join(", ")}`); + } + + const captureUrl = new URL(pathToFileURL(join(here, `${concept}.html`))); + captureUrl.searchParams.set("state", "listening"); + captureUrl.searchParams.set("capture", "1"); + await page.goto(captureUrl.href); + await page.waitForFunction(() => document.fonts.status === "loaded"); + await page.screenshot({ + path: join(here, "screenshots", `${concept}-${viewport.name}.png`), + fullPage: false, + animations: "disabled", + }); + await page.close(); + } + } +} finally { + await browser.close(); +} + +console.log(`Verified ${concepts.length * states.length * viewports.length} state/viewport combinations and wrote 6 screenshots.`); diff --git a/docs/design/prototypes/listener-ux/immersive-field.html b/docs/design/prototypes/listener-ux/immersive-field.html new file mode 100644 index 00000000..6584cb6a --- /dev/null +++ b/docs/design/prototypes/listener-ux/immersive-field.html @@ -0,0 +1,75 @@ + + + + + + Listener prototype — Immersive Field + + + + + +
+
+ +
+
Free2h 41mFounding Listener
+
+
+

Immersive Field

+

Listen from your harmonic center.

+

The Beacon fills the room. Access and controls stay quiet until they are needed.

+
+

Enter the Beacon

Begin listening

+ + +
+
+
+
+
Ready when you areThe Beacon is present
+
Listening now18:42
+
Session heldPaused
+
+ +
+
+
Beacon · continuous signalReady to listen
+
+
+
Beacon · continuous signalListening 18:42
+
+
+
+
Session heldPaused at 18:42
+
+
+
+

Your account

Nico

+ Founding Listener
Total listening41 h 26 min
This session18 min 42 sec
MembershipActive · $5/month
+
+
+

Full access

Founding Listener

+

$5 USD / month

  • Unlimited Beacon listening
  • Founding Listener profile mark
  • Cancel whenever you need
+ +
+
+ + diff --git a/docs/design/prototypes/listener-ux/listening-altar.html b/docs/design/prototypes/listener-ux/listening-altar.html new file mode 100644 index 00000000..2cdee3d7 --- /dev/null +++ b/docs/design/prototypes/listener-ux/listening-altar.html @@ -0,0 +1,33 @@ + + + Listener prototype — Listening Altar + + +
+
+
Free2h 41mFounding Listener
+
+ +
+

Continuous signal

The Beacon

+
+
ReadyRemember your harmonic center.
+
Listening now18:42
+
Session heldPaused
+

Enter the Beacon

+
+
+
+

Your server-authorized time stops when listening stops.

+

Unlimited while your Founding Listener membership is active.

+
+ +
+

Your account

Nico

Founding Listener
Total listening41 h 26 min
This session18 min 42 sec
MembershipActive · $5/month
+

Full access

Founding Listener

$5 USD / month

  • Unlimited Beacon listening
  • Founding Listener profile mark
  • Cancel whenever you need
+
+ + diff --git a/docs/design/prototypes/listener-ux/mark.svg b/docs/design/prototypes/listener-ux/mark.svg new file mode 100644 index 00000000..4d629bdc --- /dev/null +++ b/docs/design/prototypes/listener-ux/mark.svg @@ -0,0 +1 @@ + diff --git a/docs/design/prototypes/listener-ux/prototype.js b/docs/design/prototypes/listener-ux/prototype.js new file mode 100644 index 00000000..c73d9581 --- /dev/null +++ b/docs/design/prototypes/listener-ux/prototype.js @@ -0,0 +1,48 @@ +const states = ["visitor", "free", "founder", "listening", "paused", "profile", "checkout"]; + +const params = new URLSearchParams(window.location.search); +if (params.get("capture") === "1") { + document.documentElement.dataset.capture = "true"; +} + +function isVisibleFor(element, state) { + return (element.dataset.show ?? "").split(/\s+/).includes(state); +} + +function applyState(nextState, updateUrl = true) { + const state = states.includes(nextState) ? nextState : "listening"; + document.body.dataset.state = state; + + document.querySelectorAll("[data-show]").forEach((element) => { + element.hidden = !isVisibleFor(element, state); + }); + + document.querySelectorAll("[data-state-button]").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.stateButton === state)); + }); + + const label = document.querySelector("[data-current-state]"); + if (label) label.textContent = state; + + if (updateUrl) { + const url = new URL(window.location.href); + url.searchParams.set("state", state); + history.replaceState({}, "", url); + } +} + +document.querySelectorAll("[data-state-button]").forEach((button) => { + button.addEventListener("click", () => applyState(button.dataset.stateButton)); +}); + +document.querySelectorAll("[data-open-state]").forEach((button) => { + button.addEventListener("click", () => applyState(button.dataset.openState)); +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && ["profile", "checkout"].includes(document.body.dataset.state)) { + applyState("free"); + } +}); + +applyState(params.get("state") ?? "listening", false); diff --git a/docs/design/prototypes/listener-ux/screenshots/immersive-field-1440.png b/docs/design/prototypes/listener-ux/screenshots/immersive-field-1440.png new file mode 100644 index 00000000..b7a55443 Binary files /dev/null and b/docs/design/prototypes/listener-ux/screenshots/immersive-field-1440.png differ diff --git a/docs/design/prototypes/listener-ux/screenshots/immersive-field-390.png b/docs/design/prototypes/listener-ux/screenshots/immersive-field-390.png new file mode 100644 index 00000000..b6bdfb30 Binary files /dev/null and b/docs/design/prototypes/listener-ux/screenshots/immersive-field-390.png differ diff --git a/docs/design/prototypes/listener-ux/screenshots/listening-altar-1440.png b/docs/design/prototypes/listener-ux/screenshots/listening-altar-1440.png new file mode 100644 index 00000000..9edd1824 Binary files /dev/null and b/docs/design/prototypes/listener-ux/screenshots/listening-altar-1440.png differ diff --git a/docs/design/prototypes/listener-ux/screenshots/listening-altar-390.png b/docs/design/prototypes/listener-ux/screenshots/listening-altar-390.png new file mode 100644 index 00000000..3ddc2c5d Binary files /dev/null and b/docs/design/prototypes/listener-ux/screenshots/listening-altar-390.png differ diff --git a/docs/design/prototypes/listener-ux/screenshots/spatial-dashboard-1440.png b/docs/design/prototypes/listener-ux/screenshots/spatial-dashboard-1440.png new file mode 100644 index 00000000..7f5d3a14 Binary files /dev/null and b/docs/design/prototypes/listener-ux/screenshots/spatial-dashboard-1440.png differ diff --git a/docs/design/prototypes/listener-ux/screenshots/spatial-dashboard-390.png b/docs/design/prototypes/listener-ux/screenshots/spatial-dashboard-390.png new file mode 100644 index 00000000..16dd4d4c Binary files /dev/null and b/docs/design/prototypes/listener-ux/screenshots/spatial-dashboard-390.png differ diff --git a/docs/design/prototypes/listener-ux/shared.css b/docs/design/prototypes/listener-ux/shared.css new file mode 100644 index 00000000..363057e3 --- /dev/null +++ b/docs/design/prototypes/listener-ux/shared.css @@ -0,0 +1,1308 @@ +@font-face { + font-family: "HB Inter"; + src: url("../../../../src/app/fonts/inter/Inter-latin-wght.woff2") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 300 600; +} + +@font-face { + font-family: "HB Cormorant"; + src: url("../../../../src/app/fonts/cormorant-garamond/CormorantGaramond-wght.woff2") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400 600; +} + +:root { + color-scheme: dark; + --hb-bg-0: #16120d; + --hb-bg-1: #1e1812; + --hb-bg-2: #241d15; + --hb-bg-card: #1b150f; + --hb-bone: #f4eee2; + --hb-ink-800: #e9e0d0; + --hb-ink-600: #ada089; + --hb-ink-500: #8a7f6b; + --hb-pearl-400: #6e5e44; + --hb-gold: #c9a24e; + --hb-gold-2: #e3c77e; + --hb-gold-deep: #a07e33; + --hb-success: #b8d2a9; + --hb-danger: #e3a092; + --hb-hair: rgba(201, 162, 78, 0.24); + --hb-hair-soft: rgba(244, 238, 226, 0.11); + --hb-surface: rgba(244, 238, 226, 0.035); + --hb-serif: "HB Cormorant", "Cormorant Garamond", Georgia, serif; + --hb-sans: "HB Inter", Inter, system-ui, -apple-system, sans-serif; + --hb-radius: 18px; + --hb-shadow: 0 24px 72px rgba(0, 0, 0, 0.42); + --safe-x: clamp(18px, 4vw, 60px); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; +} + +body { + margin: 0; + overflow-x: hidden; + color: var(--hb-ink-800); + background: var(--hb-bg-0); + font-family: var(--hb-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +button, +input { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 2px solid var(--hb-gold-2); + outline-offset: 3px; +} + +[hidden] { + display: none !important; +} + +.prototype-toolbar { + position: fixed; + z-index: 100; + top: 14px; + left: 50%; + display: flex; + max-width: calc(100vw - 28px); + align-items: center; + gap: 6px; + padding: 6px; + overflow-x: auto; + color: var(--hb-ink-600); + background: rgba(22, 18, 13, 0.94); + border: 1px solid var(--hb-hair-soft); + border-radius: 999px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.45); + transform: translateX(-50%); + scrollbar-width: none; +} + +.prototype-toolbar::-webkit-scrollbar { + display: none; +} + +.prototype-toolbar button { + min-width: max-content; + min-height: 34px; + padding: 0 12px; + color: inherit; + background: transparent; + border: 0; + border-radius: 999px; + cursor: pointer; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.prototype-toolbar button[aria-pressed="true"] { + color: #1a140c; + background: var(--hb-gold-2); +} + +html[data-capture="true"] .prototype-toolbar { + display: none; +} + +.prototype { + position: relative; + isolation: isolate; + min-height: 100svh; + overflow: hidden; + background: + radial-gradient(95% 70% at 12% -10%, rgba(201, 162, 78, 0.12), transparent 62%), + radial-gradient(90% 80% at 92% 110%, rgba(110, 94, 68, 0.18), transparent 66%), + linear-gradient(154deg, var(--hb-bg-0), var(--hb-bg-1)); +} + +.prototype::before { + position: absolute; + z-index: -2; + inset: -12%; + content: ""; + pointer-events: none; + background: + repeating-radial-gradient( + ellipse 68% 42% at 50% 54%, + transparent 0 64px, + rgba(201, 162, 78, 0.08) 65px 67px, + transparent 68px 128px + ); + opacity: 0.72; +} + +.prototype::after { + position: absolute; + z-index: -1; + inset: 0; + content: ""; + pointer-events: none; + background-image: + radial-gradient(circle at 14% 22%, rgba(227, 199, 126, 0.75) 0 0.8px, transparent 1.3px), + radial-gradient(circle at 78% 16%, rgba(173, 160, 137, 0.62) 0 0.8px, transparent 1.3px), + radial-gradient(circle at 86% 78%, rgba(160, 126, 51, 0.72) 0 0.8px, transparent 1.3px); + background-size: 310px 310px, 430px 430px, 370px 370px; + opacity: 0.32; +} + +.topbar { + position: relative; + z-index: 10; + min-height: 72px; + padding: 0 24px; + color: var(--hb-bone); + background: rgba(22, 18, 13, 0.92); + border-bottom: 1px solid rgba(244, 238, 226, 0.1); +} + +.global-nav { + display: grid; + width: min(100%, 1180px); + min-height: 72px; + align-items: center; + grid-template-columns: auto 1fr auto; + gap: 18px; + margin-inline: auto; +} + +.nav-menu { + display: none; + width: 44px; + height: 44px; + padding: 13px 11px; + background: transparent; + border: 1px solid var(--hb-hair-soft); + border-radius: 50%; +} + +.nav-menu span { + display: block; + height: 1px; + margin: 4px 0; + background: var(--hb-ink-800); +} + +.nav-links { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} + +.nav-links a { + display: inline-flex; + min-width: 44px; + min-height: 44px; + align-items: center; + justify-content: center; + padding: 0 9px; + color: var(--hb-ink-600); + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.12em; + text-decoration: none; + text-transform: uppercase; +} + +.nav-links a[aria-current="page"] { + color: var(--hb-gold); +} + +.nav-language { + display: inline-flex; + align-items: center; + color: var(--hb-ink-500); + font-size: 10px; +} + +.nav-language button { + min-width: 44px; + min-height: 44px; + padding: 0; + color: var(--hb-ink-600); + background: transparent; + border: 0; + cursor: pointer; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.13em; +} + +.nav-language button[aria-pressed="true"] { + color: var(--hb-gold-2); +} + +.listener-context-bar { + position: relative; + z-index: 9; + display: flex; + min-height: 58px; + align-items: center; + justify-content: flex-end; + padding: 7px var(--safe-x); + border-bottom: 1px solid rgba(244, 238, 226, 0.06); +} + +.brand { + display: inline-flex; + min-height: 44px; + align-items: center; + gap: 10px; + color: var(--hb-bone); + text-decoration: none; +} + +.brand img { + width: 34px; + height: 34px; + filter: drop-shadow(0 3px 14px rgba(201, 162, 78, 0.22)); +} + +.brand span { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + white-space: nowrap; +} + +.top-actions { + display: flex; + align-items: center; + gap: 9px; +} + +.chip, +.icon-button { + display: inline-flex; + min-height: 44px; + align-items: center; + justify-content: center; + color: var(--hb-ink-600); + background: rgba(30, 24, 18, 0.66); + border: 1px solid var(--hb-hair-soft); + border-radius: 999px; +} + +.chip { + gap: 8px; + padding: 0 14px; + font-size: 12px; +} + +.chip strong { + color: var(--hb-bone); + font-weight: 500; +} + +.icon-button { + flex: 0 0 44px; + width: 44px; + padding: 0; + cursor: pointer; +} + +.avatar { + color: #1a140c; + background: linear-gradient(135deg, var(--hb-gold-2), var(--hb-gold-deep)); + border-color: rgba(227, 199, 126, 0.4); + font-weight: 600; +} + +.eyebrow { + margin: 0; + color: var(--hb-gold); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.3em; + line-height: 1.4; + text-transform: uppercase; +} + +.heading { + margin: 0; + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: clamp(38px, 5.8vw, 72px); + font-weight: 500; + letter-spacing: -0.025em; + line-height: 0.98; +} + +.heading em { + color: var(--hb-gold-2); + font-style: normal; +} + +.lede { + max-width: 52ch; + margin: 0; + color: var(--hb-ink-600); + font-size: 14px; + line-height: 1.65; +} + +.surface { + color: var(--hb-ink-800); + background: + linear-gradient(145deg, rgba(244, 238, 226, 0.035), transparent 52%), + rgba(27, 21, 15, 0.68); + border: 1px solid var(--hb-hair-soft); + border-radius: var(--hb-radius); + box-shadow: var(--hb-shadow); +} + +.surface--clear { + background: + linear-gradient(145deg, rgba(244, 238, 226, 0.02), transparent 52%), + rgba(22, 18, 13, 0.12); + backdrop-filter: blur(2px) saturate(1.03); +} + +.button { + display: inline-flex; + min-height: 48px; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 24px; + color: #1a140c; + background: linear-gradient(135deg, var(--hb-gold-2), var(--hb-gold) 54%, var(--hb-gold-deep)); + border: 1px solid rgba(227, 199, 126, 0.4); + border-radius: 999px; + box-shadow: 0 12px 32px rgba(201, 162, 78, 0.2); + cursor: pointer; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 280ms ease; +} + +.button:hover { + box-shadow: 0 16px 44px rgba(201, 162, 78, 0.28); + transform: translateY(-1px); +} + +.button--ghost { + color: var(--hb-ink-800); + background: rgba(30, 24, 18, 0.7); + border-color: var(--hb-hair); + box-shadow: none; +} + +.button--quiet { + min-width: 48px; + padding: 0 14px; + color: var(--hb-ink-600); + background: transparent; + border-color: transparent; + box-shadow: none; +} + +.button--danger { + color: var(--hb-danger); + background: rgba(120, 52, 42, 0.1); + border-color: rgba(227, 160, 146, 0.28); + box-shadow: none; +} + +.field-label { + display: grid; + gap: 7px; + color: var(--hb-ink-800); + font-size: 13px; +} + +.field-input { + width: 100%; + min-height: 48px; + padding: 0 14px; + color: var(--hb-bone); + background: rgba(22, 18, 13, 0.84); + border: 1px solid var(--hb-hair-soft); + border-radius: 12px; +} + +.field-input::placeholder { + color: var(--hb-ink-500); +} + +.quota { + display: grid; + gap: 7px; +} + +.quota__line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.quota__line strong { + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: 25px; + font-weight: 500; +} + +.quota__line span, +.meta { + color: var(--hb-ink-600); + font-size: 12px; + line-height: 1.5; +} + +.quota__track { + height: 4px; + overflow: hidden; + background: rgba(244, 238, 226, 0.09); + border-radius: 999px; +} + +.quota__track::before { + display: block; + width: 68%; + height: 100%; + content: ""; + background: linear-gradient(90deg, var(--hb-gold-deep), var(--hb-gold-2)); + border-radius: inherit; +} + +.listening-time { + color: var(--hb-gold-2); + font-variant-numeric: tabular-nums; + letter-spacing: 0.08em; +} + +.transport { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.transport .pause { + min-width: 158px; +} + +.transport .stop { + width: 48px; + min-width: 48px; + padding: 0; +} + +.member-mark { + display: inline-flex; + min-height: 28px; + align-items: center; + gap: 7px; + padding: 0 10px; + color: var(--hb-gold-2); + background: rgba(201, 162, 78, 0.08); + border: 1px solid var(--hb-hair); + border-radius: 999px; + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.member-mark::before { + width: 6px; + height: 6px; + content: ""; + background: var(--hb-gold-2); + border-radius: 50%; + box-shadow: 0 0 12px rgba(227, 199, 126, 0.46); +} + +.overlay { + position: fixed; + z-index: 50; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgba(9, 7, 5, 0.7); + backdrop-filter: blur(12px); +} + +.overlay__panel { + width: min(100%, 460px); + max-height: calc(100svh - 40px); + padding: 26px; + overflow-y: auto; +} + +.overlay__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 22px; +} + +.overlay h2 { + margin: 0; + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: 34px; + font-weight: 500; +} + +.profile-grid { + display: grid; + gap: 12px; +} + +.profile-row { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 10px 0; + border-bottom: 1px solid var(--hb-hair-soft); +} + +.profile-row span { + color: var(--hb-ink-600); + font-size: 12px; +} + +.profile-row strong { + color: var(--hb-bone); + font-size: 13px; + font-weight: 500; + text-align: right; +} + +.checkout-price { + margin: 18px 0 4px; + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: 52px; + font-weight: 500; +} + +.checkout-price small { + color: var(--hb-ink-600); + font-family: var(--hb-sans); + font-size: 13px; +} + +.checkout-list { + display: grid; + gap: 10px; + margin: 22px 0; + padding: 0; + color: var(--hb-ink-800); + font-size: 13px; + list-style: none; +} + +.checkout-list li::before { + margin-right: 9px; + color: var(--hb-gold); + content: "◆"; + font-size: 8px; +} + +.visitor-panel { + display: grid; + gap: 14px; +} + +.visitor-panel h2 { + margin: 0; + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: 31px; + font-weight: 500; +} + +.state-copy { + color: var(--hb-ink-600); + font-size: 12px; +} + +/* Immersive Field */ +.concept-immersive .immersive-layout { + display: grid; + min-height: calc(100svh - 130px); + grid-template-columns: minmax(230px, 0.72fr) minmax(420px, 1.55fr) minmax(250px, 0.78fr); + align-items: center; + gap: clamp(24px, 4vw, 72px); + padding: 42px var(--safe-x) 120px; +} + +.immersive-intro { + display: grid; + align-content: center; + gap: 18px; +} + +.field-core { + position: relative; + display: grid; + width: min(48vw, 620px); + aspect-ratio: 1; + place-items: center; + justify-self: center; +} + +.field-core::before, +.field-core::after { + position: absolute; + content: ""; + border: 1px solid rgba(201, 162, 78, 0.15); + border-radius: 50%; +} + +.field-core::before { + inset: 9%; + box-shadow: + 0 0 0 48px rgba(201, 162, 78, 0.025), + 0 0 0 112px rgba(201, 162, 78, 0.018); +} + +.field-core::after { + inset: 27%; + border-color: rgba(227, 199, 126, 0.24); + box-shadow: 0 0 86px rgba(201, 162, 78, 0.09); +} + +.core-mark { + position: relative; + z-index: 2; + display: grid; + width: 42%; + aspect-ratio: 1; + place-items: center; + background: radial-gradient(circle, rgba(201, 162, 78, 0.15), rgba(22, 18, 13, 0.12) 58%, transparent 72%); + border-radius: 50%; +} + +.signal-nucleus { + position: relative; + width: 66%; + aspect-ratio: 1; + background: + radial-gradient(circle, rgba(244, 225, 176, 0.96) 0 2%, rgba(201, 162, 78, 0.44) 3% 8%, transparent 24%), + radial-gradient(circle, rgba(201, 162, 78, 0.12), transparent 64%); + border: 1px solid rgba(201, 162, 78, 0.24); + border-radius: 50%; + box-shadow: + 0 0 34px rgba(201, 162, 78, 0.13), + inset 0 0 28px rgba(201, 162, 78, 0.08); +} + +.signal-nucleus::before { + position: absolute; + inset: 23%; + content: ""; + border: 1px solid rgba(227, 199, 126, 0.34); + border-radius: 50%; + box-shadow: + 0 0 0 18px rgba(201, 162, 78, 0.04), + 0 0 0 36px rgba(201, 162, 78, 0.025); +} + +.signal-nucleus::after { + position: absolute; + top: 31%; + left: -15%; + width: 130%; + height: 38%; + content: ""; + border-top: 1px solid rgba(227, 199, 126, 0.32); + border-bottom: 1px solid rgba(201, 162, 78, 0.16); + border-radius: 50%; + transform: rotate(-9deg); +} + +.core-caption { + position: absolute; + z-index: 3; + bottom: 14%; + display: grid; + gap: 4px; + text-align: center; +} + +.core-caption strong { + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: 27px; + font-weight: 500; +} + +.immersive-status { + display: grid; + gap: 18px; + padding: 22px; +} + +.immersive-dock { + position: absolute; + z-index: 12; + bottom: 24px; + left: 50%; + display: flex; + width: min(660px, calc(100% - 32px)); + min-height: 76px; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 13px 16px 13px 22px; + transform: translateX(-50%); +} + +.dock-copy { + display: grid; + gap: 4px; +} + +.dock-copy strong { + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: 21px; + font-weight: 500; +} + +/* Listening Altar */ +.concept-altar::before { + opacity: 0.48; +} + +.altar-layout { + display: grid; + min-height: calc(100svh - 130px); + grid-template-columns: minmax(250px, 0.75fr) minmax(520px, 1.45fr) minmax(250px, 0.75fr); + align-items: center; + gap: clamp(24px, 4vw, 64px); + padding: 18px var(--safe-x); +} + +.altar-side { + display: grid; + gap: 18px; +} + +.altar-frame { + position: relative; + display: flex; + flex-direction: column; + min-height: min(690px, calc(100svh - 166px)); + padding: 28px; + overflow: hidden; + background: linear-gradient(160deg, rgba(36, 29, 21, 0.58), rgba(22, 18, 13, 0.72)); + border: 1px solid rgba(201, 162, 78, 0.22); + border-radius: 34px 34px 18px 18px; + box-shadow: 0 36px 110px rgba(0, 0, 0, 0.48); +} + +.altar-frame::before { + position: absolute; + top: 0; + left: 50%; + width: 54%; + height: 1px; + content: ""; + background: linear-gradient(90deg, transparent, var(--hb-gold-2), transparent); + transform: translateX(-50%); +} + +.altar-title { + display: grid; + gap: 7px; + text-align: center; +} + +.altar-title h1 { + margin: 0; + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: 36px; + font-weight: 500; +} + +.altar-focus { + position: relative; + display: grid; + flex: 1; + min-height: 250px; + place-items: center; +} + +.altar-rings { + position: absolute; + width: min(78%, 380px); + aspect-ratio: 1; + border: 1px solid rgba(201, 162, 78, 0.16); + border-radius: 50%; + box-shadow: + 0 0 0 36px rgba(201, 162, 78, 0.025), + 0 0 0 74px rgba(201, 162, 78, 0.018); +} + +.altar-mark { + position: relative; + z-index: 2; + width: 108px; +} + +.altar-readout { + z-index: 3; + display: grid; + gap: 4px; + margin: 0 0 16px; + text-align: center; +} + +.altar-readout strong { + color: var(--hb-bone); + font-family: var(--hb-serif); + font-size: 24px; + font-weight: 500; +} + +.altar-shelf { + display: grid; + gap: 14px; + padding-top: 20px; + border-top: 1px solid var(--hb-hair-soft); +} + +.altar-shelf .button { + width: 100%; +} + +.altar-note { + margin: 14px auto 0; + color: var(--hb-ink-600); + font-size: 12px; + line-height: 1.5; + text-align: center; +} + +/* Spatial Dashboard */ +.dashboard-layout { + display: grid; + min-height: calc(100svh - 130px); + grid-template-columns: minmax(220px, 300px) minmax(460px, 1fr) minmax(240px, 320px); + gap: 16px; + padding: 18px; +} + +.dashboard-column { + display: grid; + min-width: 0; + align-content: start; + gap: 16px; +} + +.dashboard-card { + padding: 20px; +} + +.dashboard-card h2, +.dashboard-card h3 { + margin: 0; + color: var(--hb-bone); + font-weight: 500; +} + +.dashboard-card h2 { + font-family: var(--hb-serif); + font-size: 29px; +} + +.dashboard-card h3 { + font-size: 13px; +} + +.dashboard-player { + position: relative; + display: grid; + min-height: calc(100svh - 166px); + grid-template-rows: auto 1fr auto; + padding: 24px; + overflow: hidden; +} + +.dashboard-player::before { + position: absolute; + inset: 10% -15%; + content: ""; + pointer-events: none; + background: repeating-radial-gradient(circle at 50% 52%, transparent 0 48px, rgba(201, 162, 78, 0.1) 49px 51px, transparent 52px 94px); + opacity: 0.62; +} + +.dashboard-player__head, +.dashboard-player__foot, +.dashboard-core { + position: relative; + z-index: 2; +} + +.dashboard-player__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.dashboard-core { + display: grid; + place-items: center; +} + +.dashboard-core__mark { + display: grid; + width: min(42%, 240px); + aspect-ratio: 1; + place-items: center; + background: radial-gradient(circle, rgba(201, 162, 78, 0.16), transparent 72%); +} + +.dashboard-core__mark .signal-nucleus { + width: 68%; +} + +.dashboard-player__foot { + display: grid; + gap: 14px; + padding: 18px; + background: rgba(22, 18, 13, 0.36); + border: 1px solid var(--hb-hair-soft); + border-radius: 16px; +} + +.stat-list { + display: grid; + gap: 2px; +} + +.stat { + display: flex; + min-height: 42px; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid rgba(244, 238, 226, 0.06); + font-size: 12px; +} + +.stat span { + color: var(--hb-ink-600); +} + +.stat strong { + color: var(--hb-bone); + font-weight: 500; +} + +.member-link { + display: inline-flex; + width: fit-content; + min-height: 44px; + align-items: center; + padding: 0; + color: var(--hb-gold-2); + background: transparent; + border: 0; + cursor: pointer; + font-size: 12px; + text-align: left; + text-decoration: underline; + text-underline-offset: 4px; +} + +@media (max-width: 1120px) { + .topbar, + .global-nav { + min-height: 68px; + } + + .topbar { + padding-inline: 16px; + } + + .global-nav { + grid-template-columns: 1fr auto 44px; + gap: 10px; + } + + .nav-menu { + display: block; + grid-column: 3; + grid-row: 1; + } + + .nav-links { + display: none; + } + + .brand { + grid-column: 1; + grid-row: 1; + justify-self: start; + } + + .nav-language { + grid-column: 2; + grid-row: 1; + } + + .listener-context-bar { + min-height: 56px; + } +} + +@media (max-width: 980px) { + .concept-immersive .immersive-layout, + .altar-layout { + grid-template-columns: 1fr; + } + + .immersive-intro, + .immersive-status, + .altar-side { + display: none; + } + + body[data-state="visitor"] .concept-immersive .immersive-intro { + display: grid; + width: min(100%, 480px); + justify-self: center; + } + + body[data-state="visitor"] .concept-immersive .field-core { + display: none; + } + + .field-core { + width: min(82vw, 600px); + } + + .altar-layout { + padding-block: 24px 100px; + } + + .altar-frame { + width: min(100%, 620px); + justify-self: center; + } + + .dashboard-layout { + grid-template-columns: 1fr; + } + + .dashboard-column--context, + .dashboard-column--profile { + display: none; + } + + .dashboard-player { + min-height: calc(100svh - 168px); + } +} + +@media (max-width: 600px) { + .topbar { + padding-inline: 16px; + } + + .top-actions .member-mark { + max-width: 104px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .brand img { + width: 31px; + height: 31px; + } + + .brand span { + font-size: 10px; + } + + .prototype-toolbar { + top: auto; + bottom: 8px; + width: calc(100vw - 16px); + } + + .heading { + font-size: 43px; + } + + .concept-immersive .immersive-layout { + min-height: calc(100svh - 124px); + padding: 10px 12px 104px; + } + + .field-core { + width: min(112vw, 470px); + } + + .field-core::before { + inset: 12%; + box-shadow: + 0 0 0 34px rgba(201, 162, 78, 0.025), + 0 0 0 74px rgba(201, 162, 78, 0.018); + } + + .core-mark { + width: 46%; + } + + .core-caption { + bottom: 10%; + } + + .immersive-dock { + bottom: 14px; + min-height: 72px; + padding: 10px 10px 10px 16px; + } + + .dock-copy span { + max-width: 20ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dock-copy strong { + font-size: 18px; + } + + .transport { + gap: 6px; + } + + .transport .pause { + min-width: 118px; + padding-inline: 16px; + } + + .altar-layout { + min-height: calc(100svh - 124px); + padding: 8px 12px; + } + + .altar-frame { + min-height: calc(100svh - 144px); + padding: 20px; + border-radius: 26px 26px 16px 16px; + } + + .altar-title h1 { + font-size: 30px; + } + + .altar-rings { + width: 68%; + } + + .altar-focus { + min-height: 190px; + } + + .dashboard-layout { + min-height: calc(100svh - 124px); + padding: 10px; + } + + .dashboard-player { + min-height: calc(100svh - 144px); + padding: 18px; + } + + .dashboard-player__head { + align-items: center; + } + + .dashboard-player__head .member-mark { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dashboard-core__mark { + width: 58%; + } + + .overlay { + align-items: end; + padding: 10px; + } + + .overlay__panel { + max-height: calc(100svh - 18px); + padding: 22px; + border-radius: 22px 22px 14px 14px; + } +} + +@media (max-width: 360px) { + .brand span { + display: none; + } + + .top-actions .chip { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation: none !important; + transition: none !important; + } + + .button:hover { + transform: none; + } +} + +html[data-capture="true"] *, +html[data-capture="true"] *::before, +html[data-capture="true"] *::after { + caret-color: transparent !important; + transition: none !important; +} diff --git a/docs/design/prototypes/listener-ux/spatial-dashboard.html b/docs/design/prototypes/listener-ux/spatial-dashboard.html new file mode 100644 index 00000000..844c279a --- /dev/null +++ b/docs/design/prototypes/listener-ux/spatial-dashboard.html @@ -0,0 +1,32 @@ + + + Listener prototype — Spatial Dashboard + + +
+
+
Free2h 41mFounding Listener
+
+ +
+

Continuous signal

The Beacon

Live session
+
ReadyListen from your center
Listening now18:42
Session heldPaused
+

Enter the Beacon

+
Beacon · continuous signal
+
Session 18:42
+
Paused at 18:42
+
+ +
+

Your account

Nico

Founding Listener
Total listening41 h 26 min
This session18 min 42 sec
MembershipActive · $5/month
+

Full access

Founding Listener

$5 USD / month

  • Unlimited Beacon listening
  • Founding Listener profile mark
  • Cancel whenever you need
+
+ + diff --git a/docs/operations/BEACON_ACCOUNT_SOCIAL_PROVIDERS.md b/docs/operations/BEACON_ACCOUNT_SOCIAL_PROVIDERS.md new file mode 100644 index 00000000..3ed8217f --- /dev/null +++ b/docs/operations/BEACON_ACCOUNT_SOCIAL_PROVIDERS.md @@ -0,0 +1,179 @@ +# Beacon Account social providers + +Google and Apple belong only to the central Beacon Account authority. Listener +and Live are confidential OIDC relying parties; they must not receive provider +client secrets, provider tokens, or direct social callbacks. + +Both providers are independently default-off. A disabled provider is absent +from the Account UI. A readiness result of `providers: ok` means that the +configured enabled/disabled state is internally valid; it is not evidence that +a disabled provider has completed human acceptance. + +## Fixed security contract + +- Staging authority: `https://account-staging.harmonicbeacon.com`. +- Production authority: `https://account.harmonicbeacon.com`. +- Google uses Authorization Code, the Account-owned state cookie, and + `prompt=select_account` with online access. Account switching must remain + explicit. +- Apple uses its Services ID as the OAuth client ID and a current ES256 + client-secret JWT. Raw `.p8` material is never installed in the application. +- An Account has exactly one access method: verified email/password, Google, + or Apple. Matching email never links or merges accounts. +- Provider access, refresh, and ID tokens are not authorization keys for + Listener, Live, membership, staff, tickets, or events. +- Provider secrets and account identifiers never belong in Git, GitHub, chat, + screenshots, shell arguments, browser storage, metrics, or logs. + +## Google Cloud setup + +Create separate **Web application** OAuth clients for staging and production. +Do not reuse the legacy direct-Listener client. + +Register exactly one callback on each client: + +- staging: + `https://account-staging.harmonicbeacon.com/api/account/auth/callback/google` +- production: + `https://account.harmonicbeacon.com/api/account/auth/callback/google` + +The Account flow does not require a sibling product origin or callback. If the +Google consent screen remains in Testing, add only the intended human testers. +The application requests the provider's ordinary identity scopes; Google email +is profile data, never membership or linking authority. + +Prepare the client ID and secret only in the corresponding root-owned provider +bundle. The activation lifecycle, rather than a manual edit, installs them in +the Account environment: + +```text +BEACON_ACCOUNT_GOOGLE_CLIENT_ID=.apps.googleusercontent.com +BEACON_ACCOUNT_GOOGLE_CLIENT_SECRET= +``` + +Use exactly one of these fixed files, owned by `root:root` with mode `0600`: + +- `/etc/harmonic-beacon/account-provider-staging-google.env` +- `/etc/harmonic-beacon/account-provider-production-google.env` + +Never place staging and production credentials in the same bundle or runtime. + +## Apple Developer setup + +An Apple Developer Program Account Holder or Admin with 2FA must create or +confirm: + +1. a primary App ID with Sign in with Apple enabled; +2. a Services ID for Beacon Account; +3. the exact Account web domain and return URL for the target environment; +4. Team ID, Key ID, and a Sign in with Apple private `.p8` key; +5. an ES256 client-secret JWT with: + - header `alg=ES256` and `kid=`; + - `iss=`; + - `sub=`; + - `aud=https://appleid.apple.com`; + - a valid `iat` and an `exp` no more than six months later. + +The exact Apple callbacks are: + +- staging: + `https://account-staging.harmonicbeacon.com/api/account/auth/callback/apple` +- production: + `https://account.harmonicbeacon.com/api/account/auth/callback/apple` + +Generate the JWT offline from the protected `.p8`. Put only the Services ID and +generated JWT in the target provider bundle: + +```text +BEACON_ACCOUNT_APPLE_CLIENT_ID= +BEACON_ACCOUNT_APPLE_CLIENT_SECRET= +``` + +Use exactly one of these fixed files, owned by `root:root` with mode `0600`: + +- `/etc/harmonic-beacon/account-provider-staging-apple.env` +- `/etc/harmonic-beacon/account-provider-production-apple.env` + +The application rejects a raw `.p8`, a malformed JWT, the wrong audience or +subject, and an expired JWT. Apple may provide name and email only on first +consent. Later callbacks without them remain bound by Apple subject and use a +neutral provider-independent Beacon profile; they never trigger email linking. + +## Safe staging activation + +Do one provider at a time. + +1. Confirm the exact reviewed release, clean checkout, exact running image and + root-owned deployment coordinates. +2. Create the exact two-line provider bundle at the fixed path above. Do not + pass either value on a command line and do not print the file. +3. Run, for example: + + ```sh + sudo scripts/beacon-account/activate-social-provider.sh \ + staging google /etc/harmonic-beacon/beacon-account-deploy.env + ``` + + The lifecycle creates and verifies a fresh encrypted database backup, + stores the prior Account environment in a root-only rollback directory, + builds a complete candidate environment inside the exact image with no + network, and validates the complete production/staging pair before any + cutover. There is no installed-but-disabled intermediate state. +4. The lifecycle atomically replaces only the target Account environment and + recreates only the target Account app. It does not recreate the mail worker, + database, Listener, Live, event, stream, or payment services. Any readiness, + discovery, JWKS, or provider-visibility failure automatically restores the + previous environment and app. +5. Require Account readiness at the exact candidate SHA with + `checks.providers=ok`. Confirm the Account page shows only the provider just + enabled and email/password remains available. +6. Complete supervised human acceptance before enabling the other provider or + touching production. + +The successful command prints a root-only rollback-state path. Manual rollback +uses that exact state and the same deployment coordinates: + +```sh +sudo scripts/beacon-account/rollback-social-provider.sh \ + /var/lib/harmonic-beacon/account-social-providers/ \ + /etc/harmonic-beacon/beacon-account-deploy.env +``` + +Rollback restores the complete prior environment and recreates only the +Account app. Disabling a provider hides new sign-in without deleting accounts, +profiles, sessions, or product data. Do not delete provider identities as +rollback. + +## Human acceptance + +For each provider, use two distinct disposable staging identities and record +only sanitized pass/fail evidence. + +- Complete first consent and the exact Account callback. +- Confirm the canonical Beacon display name can be edited and survives reload. +- Enter Listener through Account SSO without another provider prompt. +- Sign out the current device and sign in again. +- Deliberately switch A to B and back to A. The first Listener render, reload, + back/forward, bfcache restoration, and duplicate tab must never retain the + previous account's profile, Founder state, or authorization. +- Exercise a delayed callback, back-button callback, replay, and state mismatch. + They must fail closed without weakening state or creating a redirect loop. +- For Apple, cover private relay, first-consent name/email, and a repeat callback + where name/email may be absent. +- Confirm no email-based merge, provider-token persistence in product sessions, + membership mutation, payment call, event capability, or audio change. + +## Production promotion and rotation + +Production remains off until staging acceptance is complete and the production +Account DNS/TLS edge, backup, migration, static-client inventory, mail path, +Listener/Live RP cutover, and rollback are independently ready. Promote Google +and Apple separately; staging success for one provider does not authorize the +other. + +Rotate a Google secret by installing the replacement with the provider still +enabled, recreating only Account, completing readiness and a real sign-in, and +then revoking the old secret in Google Cloud. Rotate an Apple client-secret JWT +before expiry using the same reviewed Team ID, Key ID, Services ID, and +protected `.p8`; verify readiness and a real sign-in before retiring the old +JWT. Key revocation and ordinary JWT rotation are separate operations. diff --git a/docs/operations/BEACON_ACCOUNT_STAGING_ACCEPTANCE.md b/docs/operations/BEACON_ACCOUNT_STAGING_ACCEPTANCE.md new file mode 100644 index 00000000..9e370c8a --- /dev/null +++ b/docs/operations/BEACON_ACCOUNT_STAGING_ACCEPTANCE.md @@ -0,0 +1,43 @@ +# Beacon Account staging acceptance + +Use only a disposable experimental address and a password that is not reused +elsewhere. Never paste a password, action link or token into GitHub, chat, logs +or screenshots. + +## Password contract + +- Minimum: 8 characters. +- Maximum: 128 characters. +- No composition or complexity rules: digits, letters, spaces and symbols are + not individually required. +- Passwords still use the versioned scrypt credential format. Email + verification, rate limits, reauthentication and session revocation remain + mandatory and independent of password length. + +## Staging checklist + +1. Start from `https://earlybirds-staging.harmonicbeacon.com` and enter the + Account flow. +2. Create a credential account with a display name, disposable email and an + 8–128 character password, repeat it, and exercise the accessible show/hide + control. Confirm mismatches stay client-side and the response is visible + beside the submit action without revealing whether an address already + exists. +3. Confirm the email sender is Harmonic Beacon and the action URL uses exact + HTTPS host `account-staging.harmonicbeacon.com`. +4. Open the verification link within 15 minutes, then sign in and return to + Listener staging. +5. Change the Beacon display name, reload and confirm persistence. +6. Sign out of the current device, sign in again and confirm the page remains + usable throughout. +7. Request password recovery. Confirm the public response remains generic and + a separate reset email arrives. +8. Complete reset within 15 minutes. Confirm the action token is one-use, the + old password fails and the repeated new 8–128 character password succeeds. +9. Open a second browser session, choose all-device logout and confirm both + product sessions converge to signed out. +10. Repeat the visible flow in ES and EN. Record only sanitized status and + timestamps; never record credentials or action URLs. + +This checklist does not authorize payments, Provider activation, production +Account, Live, events or audio changes. diff --git a/docs/operations/DEPENDENCY_SECURITY.md b/docs/operations/DEPENDENCY_SECURITY.md index e68ec408..49f23967 100644 --- a/docs/operations/DEPENDENCY_SECURITY.md +++ b/docs/operations/DEPENDENCY_SECURITY.md @@ -18,10 +18,21 @@ has no `next/image` imports, but the production build and a native Sharp JPEG round trip remain required gates because the Sharp override crosses a major. Remove the overrides once a stable Next.js release declares fixed ranges. -`npm audit --omit=dev --audit-level=high` is a pull-request gate. At this -baseline it reports one low-severity esbuild issue limited to the Windows -development server; production runs Linux standalone output and does not expose -the esbuild development server. Review or remove that exception by 2026-09-01. +`npm run audit:production` is the root pull-request and deploy gate. It parses +the structured npm audit report and fails on every unreviewed high or critical +finding. Its sole temporary exception is advisory 1145093 +(`GHSA-ggr8-5vv4-36mx`) for exactly `deepmerge-ts@7.1.5` through +`@prisma/config@7.9.1` and `prisma@7.9.1`. Prisma pins that dependency and the +patched deepmerge release is a major while Prisma 8 remains pre-release. The +exposure is bounded because Prisma config processes only the repository-owned +`prisma.config.ts` during trusted build and migration operations, never request +or user data. The guard checks the advisory ID, dependency versions and exact +installed paths, rejects any additional high/critical finding, and expires +closed on 2026-09-15. Remove it earlier when a stable Prisma release adopts the +patched dependency. + +The prior production `tsx` chain now resolves to `tsx@4.23.12` and patched +`esbuild@0.28.2` within the already-declared compatible range. Every independently deployed Node package is audited, not only the repository root. The tapestry service is pinned to Sharp 0.35.3 after its prior 0.34 line diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md new file mode 100644 index 00000000..291e12d6 --- /dev/null +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -0,0 +1,182 @@ +# Founding Listener public acceptance + +This sheet records the human release gate for the bounded Founding Listener +public test at `https://listen.harmonicbeacon.com/`. It does not authorize paid +checkout, a worldwide campaign, an app-store release, a merge to `main`, an +event-stack change or an acoustic change. + +## Planned weekly-Free cutover — not yet deployed + +This is the authoritative planned policy, not evidence of a release, test or +deployment. A registered Free account receives three hours per personal fixed +seven-day cycle, anchored at its first real authorized playback. Base time does +not roll over. The server owns time and remaining allowance; two devices meter +the union of active listening once, and both private intros and Beacon count. +Stop/idle presence stops metering, while an unreported disconnect is bounded by +the lease horizon. Active canonical membership/invitation and Free for All are +unlimited and non-metered. Discretionary credits are auditable idempotent +grants with immutable facts, a monotonic consumed total and optional expiry. + +The cutover retires schedule/timezone and welcome access from authorization and +UI. Its additive migration retains the old tables for audit/history only. Once +migrated, rollback is stop/kill-switch followed by a roll-forward repair; it +must never reactivate the older daily-window or welcome authorization. + +No weekly-Free acceptance has been recorded yet. The remainder of this document +is retained historical evidence for the previous release policy. + +Do not paste account details, OAuth material, invitation tokens, cookies or +temporary operator values into GitHub or test notes. Record only the tester, +device/browser, result and a non-sensitive symptom. + +## Historical fixed candidate — previous daily/welcome policy + +- Listener application: `dad29d4dc5010603a5bbc7ed309c8f78e7c0f384` +- Listener schema: `20260807100000_early_bird_welcome_access` +- Stream origin: `https://stream.harmonicbeacon.com` +- Approved intro languages: Spanish and English +- Ordinary Free access: registered account plus one recurring two-hour daily + window, changeable again after seven days +- First access: one explicit account-bound 30-minute listen before selecting a + recurring schedule +- Founding Listener access: canonical active membership projection +- Connections: at most two active devices per account +- Free for All: an independent reversible operator override, temporarily OFF + for coordinated registered-Free acceptance +- PayPal and Mercado Pago: disabled for this acceptance and expected to fail + closed +- Apple: absent until Apple Developer Program credentials and 2FA are supplied +- Event application and `live.harmonicbeacon.com`: out of scope and unchanged + +Documentation may advance without rebuilding the application. Health must +attest the application SHA above rather than the current branch head. + +## Automated preflight + +Before a human session, require: + +- PR #203 checks green; +- Listener liveness/readiness, PostgreSQL, origin and decoded canary green; +- health attests the exact application SHA and schema above; +- Alertmanager has no unexplained active critical alert; +- Google authorization reaches the exact Listener callback with one-time state + and PKCE S256; +- missing or foreign browser Origin fails auth mutations closed; +- unconfigured Apple is absent, not a dead public button; +- registered Free selection is server-authoritative and a stream lease cannot + outlive the active window; +- first-listen activation is explicit, idempotent, single-use and its leases + and manifests cannot outlive the exact 30-minute server boundary; +- canonical Founder/invitation projection still outranks ordinary Free and + terminal membership states fail closed; +- anonymous Free for All lease and ES/EN media ranges work while the override + is ON; +- event production health remains unchanged. + +## Human Google and ordinary Free flow + +Free for All makes anonymous listening intentionally possible, so ordinary +registered-Free acceptance needs a short coordinated interval with that +override OFF. Restore it immediately after the flow if the public demo should +remain open. + +1. Open the Listener in a clean browser profile. With Free for All OFF, choose + Google and complete the real provider callback with a supervised test + account. +2. Confirm the callback returns to `listen.harmonicbeacon.com`, creates only a + Listener identity/session and never exposes provider tokens or requests + camera/microphone access. +3. With a new account, press **Listen for 30 minutes now** and confirm access + begins without selecting or locking a recurring schedule. Refresh and a + second device must retain the original end rather than extending it. +4. With a second new account, choose **Listen free now** without starting the + first listen. Confirm no first-listen row is consumed and the daily schedule + is the only active grant. +5. Confirm the two-hour window is shown in the browser's local time together + with the next window and the date/time when it can be changed again. +6. Begin **With introduction**. The intro may pause and seek. Confirm its + natural completion hands off to the current Beacon live edge. +7. Stop, choose **Beacon only** and listen again. The Beacon exposes Stop but + no Pause or Seek; listening again rejoins the current live point. +8. Change the intro selector. Spanish must play the Spanish Amara Sol asset and + English the English asset. The browser locale chooses the initial UI/intro; + the selector overrides only the intro. +9. Reload, background/foreground the browser and reconnect the network once. + The UI must remain truthful, avoid duplicate playback and recover or offer + one clear retry. +10. Open the same account on a second device; both may listen. A supervised + third active device must displace only the oldest lease and explain that + state truthfully. +11. Confirm logout is available both during and outside the Free window. After + logout, the Listener session endpoint must be anonymous. +12. At the exact first-listen and Free-window boundaries, playback must stop after the bounded + authorization horizon and a new lease/manifest must fail until the next + window. This row may be exercised with a synthetic clock in automation and + one shorter supervised server-side fixture rather than waiting two hours. +13. Leave the waiting page open across a scheduled start, and the player open + across an end. Both views must update from server authority without a + manual reload. + +Do not change the selected schedule merely to repeat a test: the seven-day lock +is product behavior. Use a separate supervised account for a custom future +time. DST gap/ambiguity, idempotency and cooldown are covered by automated +tests; physical acceptance only confirms local-time comprehension. + +The first supervised real-provider pass completed Google callback, logout and +sign-in again on 2026-08-07. A custom window selected one minute ahead became +authorized by the server and entered Listener after reload. The open waiting +page did not refresh itself at the boundary; #216 now has an implementation +that must be confirmed in the next deployed acceptance. The +sanitized server audit confirmed one recent identity/session, exact seven-day +cooldown, scrubbed OAuth tokens and no stored session IP or user-agent. + +## Free for All operator flow + +1. With the override OFF, verify an anonymous lease and manifest fail closed. +2. Apply the Listener public-disable command so the kill switch is OFF and no + new personal lease can race the transition. +3. Inside the disabled Listener container, run + `npx tsx scripts/listener-quiesce-for-free-for-all.ts`. It must converge and + report the aggregate accounts settled without identifiers. +4. Only then set Listener enabled + FFA ON and recreate only Listener. +5. Verify health, then confirm anonymous playback works without creating an + account, quota cycle, membership or Purchase. +6. Disable it and verify denial again; re-enable it only if the current public + demo decision requires it. + +Existing signed manifests or already buffered media may drain for the short +signature/manifest horizon. This is expected and must not be described as +instant revocation. + +## Physical acceptance matrix + +| Date/time | Device / OS | Browser | Locale | Google callback | Weekly quota | Intro / handoff | Beacon live edge | 2→3 devices | Reconnect | Logout | Tester / notes | +|---|---|---|---|---|---|---|---|---|---|---|---| +| _pending_ | Desktop | Chromium | ES/EN | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | | +| _pending_ | Desktop | Firefox | ES/EN | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | | +| _pending_ | Android physical | Chrome | ES/EN | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | | +| 2026-08-07 | iPhone physical | Safari | ES/EN | Previously proven | Tested flow passed | PASS | PASS | Not separately recorded | PASS | PASS | Nico confirmed the deployed iPhone Listener flow worked correctly; #219 closed. | + +Run one 60-minute physical listen covering intro, handoff, Stop/restart, +background/foreground and a network transition. Report audible glitches as a +human signal only; do not alter codec, gain, buffers or routing from this sheet. + +## Acceptance, external blockers and rollback + +Only Nico records the public-test acceptance decision. Failed rows stay failed +or blocked; they are not averaged into a pass. + +Apple's exact external blocker is an authenticated Apple Developer Program +Account Holder/Admin with 2FA, a primary App ID with Sign in with Apple, a +Services ID associated with that App ID, the `listen.harmonicbeacon.com` and +staging domains/absolute callback URLs, Team ID, Key ID, the one-time-download +private `.p8` key and a generated client-secret JWT. The Services ID is the +OAuth client ID. It must remain absent until all material exists; the private +key/client secret must be delivered and installed securely, never committed or +pasted into GitHub. A normal Apple Account on an iPhone is sufficient for the +physical login test only after this developer configuration exists. + +If the isolated Listener degrades, restore root-only +`/etc/harmonic-beacon/earlybirds-preview.env.pre-dad29d4`, select Listener image +`55bf282`, retain the preview database, additive welcome-access table and origin +media, and run the preview health smoke. Do not touch the event runtime. diff --git a/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md b/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md new file mode 100644 index 00000000..2f3ae7f6 --- /dev/null +++ b/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md @@ -0,0 +1,110 @@ +# EarlyBirds source media provenance + +Recorded read-only on 2026-08-06. These checksums identify source masters only. +They do **not** approve a codec, derivative, mix, loudness treatment or public +release. Every delivery artifact remains blocked on Nico's audio/content review. + +## Continuous Beacon master + +| Field | Value | +|---|---| +| Host path | `/home/nicolas/Music/beacon/luz_de_manana_20260624-155633.wav` | +| SHA-256 | `479b4132fc44766e3e1316fad21681685d4a7cb3d1f81a365ddb72f95e4e6d89` | +| Bytes | `2,628,259,840` | +| Duration | `6,844.426437 s` | +| Encoding | PCM float 32-bit little-endian | +| Rate/channels | 48,000 Hz, stereo | +| Source bitrate | 3,072,000 bit/s | + +The inventory was produced incrementally by +`services/beacon-stream/scripts/inventory.mjs`; the machine-local mode-0600 +record is outside Git at +`/home/nicolas/.cache/harmonic-beacon/early-birds-master-inventory.json`. + +## Selected drop-in voice masters + +| Language | Host path | SHA-256 | Bytes | Duration | Source format | +|---|---|---|---:|---:|---| +| ES | `/home/nicolas/Downloads/BeaconEarlyAdopters/Proyeccion_Caldeamiento_Amara_Sol_ES_VOICE.wav` | `b6771528b963980b47dae4512a7b8feb933168837caf03680f737770c1f6f190` | 16,566,798 | 345.140 s | PCM signed 16-bit, 24,000 Hz, mono | +| EN | `/home/nicolas/Downloads/BeaconEarlyAdopters/Proyeccion_Caldeamiento_Amara_Sol_EN_VOICE.wav` | `a32bed738b0090c051622c780c091dc90ba56e21e2c71f9e3d1e76795eeddfa3` | 15,841,038 | 330.020 s | PCM signed 16-bit, 24,000 Hz, mono | + +The approved product direction is an offline candidate render with an approved +Beacon excerpt ducked by 9 dB beneath the unchanged voice master. No candidate +has been generated or approved by this inventory step. + +## Promotion invariant + +An approved artifact gets a new immutable artifact ID and records source hashes, +encoder/tool versions, codec/container, sample rate, channels, loudness/peak +measurements, UTC epoch, segment inventory and a link to the human review. A +correction creates a new artifact; it never overwrites a source or accepted +previous version. + +## Approved staging pair — 2026-08-06 + +Nico explicitly approved the following exact sources and AAC-LC 320 kbit/s, +48 kHz stereo delivery for EarlyBirds staging. Neither derivative applies gain, +normalization, dynamics or other signal processing. + +| Role | Source | Source SHA-256 | Artifact | Artifact SHA-256 | Duration | Decoded level | +|---|---|---|---|---|---:|---| +| Continuous Beacon | `luz_de_manana_20260624-155633_2hs.wav` | `feb0cac547eee8a2012ede32f9358e1cad4b66f6aea3b1b839610e71fad42685` | `beacon-luz-20260624-2hs-aac320-v2` | recorded in its immutable `artifact.json` | 7,200 s | -14.2 LUFS, -0.2 dBFS true peak | +| EN intro | `BeaconDropIn-Amara-sol_r1_session.wav`, mtime `2026-08-06 18:16:41 ART` | `aa519b117f885b5ec457dc1736e18175e6a307d301bd5c295b9c58ee85a01168` | `amara-sol-en-r1-approved-aac320-v3.m4a` | `a67068458f3d72dcd13be1e8dc753d21e238c270195f93e26599aa2910a181db` | 332.939 s | -11.3 LUFS, -0.5 dBFS true peak | +| ES intro | `BeaconDropIn-Amara-sol_ES_r1_session.wav`, mtime `2026-08-07 02:21:48 ART` | `e59443ab765a4eb94c7d2ea96176647c5b0e5d2945966ea3de599270edec656b` | `amara-sol-es-r1-approved-aac320-v1.m4a` | `376b68eb485cb562e1ff2d702a23f05fdb67af76d619d26e839d077edc16a201` | 347.010 s | -11.3 LUFS, -0.4 dBFS true peak | + +The EN v3 source supersedes the earlier same-named exports by immutable hash and +adds the approved long Beacon fade-in. Its opening five-second mean level rises +from -17.1 dB through -15.2, -13.4 and -12.5 dB in consecutive windows. The v2 +artifact remains available only for rollback. The ES v1 source is Nico's +2026-08-07 approved current-gain Spanish mix. Its derivative likewise changes +only codec/container and keeps the authored 48 kHz stereo signal unchanged. + +## Approved and published English intro revision — 2026-08-16 + +Nico supplied and explicitly approved a newly recorded English session as the +public Listener replacement. The prior EN v3 artifact remains immutable and +available only for rollback. + +| Field | Value | +|---|---| +| Source | `/home/nicolas/BeaconDropIn-Amara-sol/export/BeaconDropIn-Amara-sol_EN_r1_session.wav` | +| Source SHA-256 | `f0d885893c529fb903431e9d5d6117ae30f16d02fa6b69b413860a3ebaec2a65` | +| Source format | PCM signed 16-bit, 48,000 Hz, stereo | +| Source bytes / duration | 87,264,044 / 454.500 s | +| Approved artifact | `amara-sol-en-r2-approved-aac320-v1.m4a` | +| Approved artifact SHA-256 | `86ce75249b506277651e632a671787827ddfc394a9777c56d9f3987d4fb7cd59` | +| Approved format | AAC-LC, requested 320 kbit/s, 48,000 Hz, stereo, fast-start M4A | +| Approved bytes / duration | 16,920,010 / 454.500 s | +| Decoded measurement | -13.6 LUFS integrated, 3.1 LU LRA, 0.0 dBFS true peak | + +The conversion applies no gain, normalization, limiter, dynamics, resampling or +channel change. It was promoted by changing only the immutable EN artifact path +and recreating the Listener container at application SHA +`1e1e43e7f3f39f95371c535cde8547ce73de467a`. Public health/readiness were green, +the Beacon origin container was not recreated, the ES intro path remained +unchanged and no database migration ran. Rollback restores +`amara-sol-en-r1-approved-aac320-v3.m4a` and recreates only Listener. + +## Approved Spanish intro revision — 2026-08-16 + +Nico supplied and explicitly approved a newly recorded Spanish session as the +Listener replacement. The prior ES v1 artifact remains immutable and available +for rollback. + +| Field | Value | +|---|---| +| Source | `/home/nicolas/BeaconDropIn-Amara-sol/export/BeaconDropIn-Amara-sol_ES_r1_session.wav` | +| Source SHA-256 | `2e9ad74daddb4350e66a4182af816b38b9a6ef3e6517f2f106fcf722d4bee388` | +| Source format | PCM signed 16-bit, 48,000 Hz, stereo | +| Source bytes / duration | 87,264,044 / 454.500 s | +| Approved artifact | `amara-sol-es-r2-approved-aac320-v1.m4a` | +| Approved artifact SHA-256 | `4d4b0ecf472a8a1d50468d2e673521b2974c7989d3c6dabe43705e1b68007c5d` | +| Approved format | AAC-LC, requested 320 kbit/s, 48,000 Hz, stereo, fast-start M4A | +| Approved bytes / duration | 17,035,677 / 454.500 s | +| Decoded measurement | -12.9 LUFS integrated, 2.7 LU LRA, 0.0 dBFS true peak | + +The conversion applies no gain, normalization, limiter, dynamics, resampling or +channel change. Promotion changes only the immutable ES artifact path and +recreates Listener. The Beacon origin, English intro, database and event audio +remain unchanged. Rollback restores `amara-sol-es-r1-approved-aac320-v1.m4a` +and recreates only Listener. diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md new file mode 100644 index 00000000..15b988ac --- /dev/null +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -0,0 +1,760 @@ +# EarlyBirds isolated staging runtime + +## 2026-08-13 Live-lifecycle rollback floor + +The isolated payment authority runs exact SHA +`b1038ddb579817e39add567c5b7b055e2f716095`. A supervised PayPal Live approval intent was created +for the exact USD 5 offer; it awaits a buyer account different from the merchant and created no +subscription or charge. New sales were returned to OFF immediately. PayPal Live lifecycle +ingestion remains ON so signed webhooks, reconciliation and cancellation stay available; Mercado +Pago Live remains OFF. + +From this first Live checkout attempt onward, `b1038ddb` is the minimum authority binary. Routine +incident recovery keeps the current database, disables new sales, leaves the affected provider's +lifecycle ingestion active, reconciles from the provider and rolls forward. The older `8e10f16` +image and pre-`b1038` database backup must not be paired with current data or used as routine +rollback targets. The Listener UI may still roll back independently to `fcdde379` while its +contract remains compatible. + +## 2026-08-12 payment-authority Live-preflight checkpoint + +The isolated membership authority runs exact merge SHA +`8e10f16fe3471a097021f7f1ee41eb8f88f4f154`, image +`harmonic-beacon/earlybirds-authority:8e10f16fe3471a097021f7f1ee41eb8f88f4f154` +and unchanged Alembic head `7b4c1e9a2d60`. API and worker are healthy with +zero restarts and migration exit `0`. PayPal Sandbox and Mercado Pago TEST remain +ready for staging acceptance; both Live providers and Live new sales remain OFF. +The release adds only a read-only, redacted Live catalog/merchant/webhook +preflight. Productive secrets are not installed and no checkout, subscription, +payment, event service or audio surface changed. Rollback retains exact prior +authority image `60584936603525027c9891e0865efc58055a3d5d` and protected backup +`/mnt/beacon-data/staging-backups/authority-live-preflight-20260812T193128Z`. + +## 2026-08-12 Listener silence-recovery checkpoint deployed with Live sales OFF + +The isolated Listener runs exact merge SHA +`4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`, image +`harmonic-beacon/earlybirds-preview-listener:4ac408f` and unchanged Prisma head +`20260810223000_listener_founder_continuity`. It adds a bounded media-liveness +watchdog and same-lease recovery for the reported silent-playback failure. It +does not change origin, audio assets, codec, gain, fades, HLS timing or event +services. Public health/readiness and fail-closed payment smokes are green. +Immediate rollback is retained as container +`earlybirds-preview-listener-1-pre-4ac408f-20260812T085101Z` and protected backup +`/mnt/beacon-data/staging-backups/listener-release-20260812T084938Z`. + +## 2026-08-12 terminal-status checkpoint (superseded application image) + +The isolated Listener now runs merge SHA +`fcdde37948e7f826641d5e4438f7666765aeda22`, image +`harmonic-beacon/earlybirds-preview-listener:fcdde37` and unchanged Prisma +head `20260810223000_listener_founder_continuity`. The isolated membership +authority then ran merge SHA `60584936603525027c9891e0865efc58055a3d5d` +at unchanged Alembic head `7b4c1e9a2d60`. + +- PayPal Live, Mercado Pago Live and both public Listener checkout flags are + explicitly disabled. Existing PayPal Sandbox and Mercado Pago TEST lifecycle + remain ready in the isolated authority; the staging workbench exposes only + Mercado Pago TEST. No real checkout or charge was created during deployment. +- The stable Listener checkout, cancellation and two signed-webhook boundaries + are installed only on `listen.harmonicbeacon.com`. Public checkout returns + `404` while disabled, unauthenticated cancellation returns `401`, webhook + `GET` returns `405` and unsigned callbacks fail closed while Live providers + are disabled. +- `/`, `/listener/terms`, `/listener/privacy`, liveness and readiness return + `200` on the public Listener. The same UI/legal routes return `200` on the + exact staging host, whose payment workbench uses the same release image. +- The account surface now presents canonical pending/expired/refunded/revoked + outcomes without retaining the Founder badge or unlimited access. A fresh + PayPal Sandbox refund physically proved the terminal Free fallback before this + exact image was built and deployed. +- The origin retained container `ed7ce1c99f79`; event app, playlist bot and + LiveKit retained containers `527b5d590844`, `c67664aabcca` and + `b81a99a8c3c9`. All remained healthy and no event configuration was changed. +- Root-owned database, environment and nginx backups are retained under + `/mnt/beacon-data/staging-backups/listener-commercial-20260812T052454Z` and + `/mnt/beacon-data/staging-backups/listener-commercial-20260812T052916Z`. + Immediate Listener rollback selects the retained stopped containers + `earlybirds-preview-listener-1-pre-fcdde37-20260812T0810Z` and + `listener-ui-dev-pre-fcdde37-20260812T0810Z`, both exact image `ca8a040`. + Authority rollback remains the matched protected image/environment procedure. + No schema rollback is required or authorized. + +## 2026-08-11 Founder continuity cutover + +The isolated Listener runs runtime SHA +`0e8ae6678f10ff3b48a0ff24d2257415a62e956b`, image +`harmonic-beacon/earlybirds-preview-listener:0e8ae66` and Prisma head +`20260810223000_listener_founder_continuity`. The isolated authority runs image +`harmonic-beacon/earlybirds-authority:ec532198dd45175812be66ac91fdae414d40f9c1` +at Alembic head `7b4c1e9a2d60`. Webapp head +`20d8eaedbee6efddb0d31ba982c10b84eddc805b` adds only preview tooling and +release-provenance corrections after the runtime commit. + +- Founder is USD 5/month only while service is uninterrupted. A real lapse or + terminal refund, chargeback, dispute, fraud or administrative revocation + closes the continuity episode irreversibly. No pre-release eligibility, + subscription, checkout or projection was grandfathered. +- The guarded command-v2 sweep completed with 19 projections and three retired + command-v1 jobs. Seventeen Listener accounts converged. Two synthetic + authority-only accounts are terminal, audited 404 failures rather than retry + loops; the queue has no pending, retrying or running jobs. +- Authority and Listener contracts are byte-identical at authority v3 and + membership command v2. PayPal and Mercado Pago lifecycle adapters remain + sandbox/test-only. Persistent Listener checkout is OFF for both providers; + the staging workbench enables only Mercado Pago TEST. Free for All is OFF. +- Health, readiness, weekly-Free authorization, first-play quota anchoring, + exact seven-day renewal, two-device eviction and generation-bound manifest + smokes passed. Public Listener and staging internal routes remain 404, + anonymous lease acquisition remains 401, and provider webhook GETs remain + 405. `live.harmonicbeacon.com` stayed healthy and unchanged throughout. +- The pre-cutover database dumps and exact root-owned environments are retained + with mode 0600 at + `/mnt/beacon-data/staging-backups/founder-continuity-20260811T032400Z`. + They are root-only, not encrypted. Recovery is the Listener/authority stop + switch followed by either restoring that matched database+environment set or + rolling forward. Do not run a pre-continuity binary against the migrated + databases: the retired positive-only eligibility model is not a valid + rollback authority. +- A fresh supervised PayPal and Mercado Pago sandbox lifecycle remains the + human acceptance gate before enabling checkout or closing the payment and + continuity umbrellas. No production provider flag or real charge is enabled. + +## 2026-08-09 public reactive field release + +The isolated public Listener runs application SHA +`1f8368d2fda19b30b74c95af884d862838f73305`, image +`harmonic-beacon/earlybirds-preview-listener:1f8368d` and unchanged schema +`20260808160000_listener_weekly_quota`. The accepted Radial ribbons field uses +server-side frames behind the active listening lease, so browser playback stays +on the native HLS/HTML media path. The technical Reactive Field Lab is explicitly +OFF and can be enabled only on the exact staging host with +`BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED=1`. Recovery selects same-schema +image `ae1d0ba` and restores the pre-release Listener nginx/env backups without +changing PostgreSQL, origin media or event services. + +## 2026-08-08 weekly-Free Listener release + +The isolated public Listener runs application SHA +`68b930ca86d8b13df2dd288199f04b47af1053fe`, image +`harmonic-beacon/earlybirds-preview-listener:68b930c` and schema +`20260808160000_listener_weekly_quota`. Weekly policy smoke head is `8444ed7`; +the later runtime changes renewal presentation to days/hours, sets the +explicitly approved 70% initial volume, and simplifies intro selection to one +pre-play checkbox plus a styled language menu. +Free for All is OFF. + +- Each registered Free account has three hours in a fixed seven-day cycle, + anchored at its first real authorized playback. There is no rollover, + separate welcome grant, daily schedule, timezone or DST authority. +- Concurrent devices consume their listening union once. Intro and Beacon both + count. Generation-bound leases, monotonic presence sequence, Stop and bounded + expiry enforce the server-owned balance. +- Active membership and FFA are unlimited/non-metered. Additional time uses + server-only, idempotent grants with immutable facts, monotonic consumed totals + and optional expiry. +- Pre-release daily/welcome APIs are absent and return 404. Their old database + rows are inert migration history and are never read for authorization. +- Existing accounts begin a fresh cycle only on their first Free playback after + this deployment; prior experimental usage is not deducted. +- Local and CI gates passed: 1,396 tests with 28 standard skips, TypeScript, + ESLint, production build, Prisma, PostgreSQL transaction tests, preview, + origin and frozen-audio checks. +- Runtime smoke proved a three-hour virgin balance, first-play anchor, exact + seven-day cycle, third-device eviction and a generation-bound manifest. +- `beacon-app` remained on `64634e94f21a0a0d60d0f5745c159fe224f2895b` + with the same container ID and zero restarts. The stream origin also retained + the same container ID and zero restarts. + +This is an experimental pre-release: previous Free-policy clients and binaries +are unsupported. Recovery is Listener stop/kill-switch plus roll-forward repair; +never restore daily-schedule or welcome authorization. + +## 2026-08-08 public Listener convergence (historical previous release) + +The isolated Listener runs application SHA +`20406dae49e8cbabba38d0cb099d8f400276113e`, image +`harmonic-beacon/earlybirds-preview-listener:20406da` and schema +`20260807200000_listener_regional_presence`. Free for All is OFF; ordinary +public entry therefore requires canonical identity plus welcome, recurring +Free, invitation or Founder authority. + +- Public invitation entry is canonicalized to `listen.harmonicbeacon.com` before + OAuth. Staging cannot mint the invitation cookie or accept redemption; exact + staging POST aliases return an unlogged, no-store 404. Only canonical HTTPS, + Host and trusted Origin may mutate redemption state. +- Public bearer paths are no-store/no-referrer and suppressed from access logs. + Redemption is rate-limited and terminal outcomes clear the cookie; a + transient authority failure retains it for a safe retry. +- Bounded runtime identity/access settings prefer `BEACON_LISTENER_*` and accept + legacy `EARLY_BIRDS_*` during the rollback window. Credential bundles cannot + mix generations, and conflicts or partial bundles fail readiness without + logging values. The current compose intentionally emits legacy keys until the + next coordinated rollout. +- Invitation-cookie phase 2B emits canonical + `__Host-hb_listener_invitation` and the rollback-compatible legacy cookie, + reads canonical-first, rejects duplicate or conflicting raw Cookie headers, + preserves both across 401/503 and clears both on success or terminal 409. + The deployed canonical redemption smoke passed against the isolated authority; + 98 focused invitation/namespace tests and 32 preview tests are green. +- The public-disable command retries liveness/readiness during normal Next.js + startup. It was physically exercised after deployment: the first probe saw a + connection reset, later probes passed, readiness was green and anonymous + lease denial returned 503. A failed terminal denial still stops only Listener. +- Final evidence: 1,222 tests with 19 standard skips, ESLint, TypeScript, + production build, Prisma, 26 nginx contract checks, preview/origin/ + observability gates and public browser smoke are green. Listener and origin + have zero restarts; `live.harmonicbeacon.com` remains untouched. +- Historical pre-weekly rollback retains schema/media and selects image `b8a04fe`; image + `2344b10` is the additional retained fallback. Use the exact root-only env and + nginx backups created by the deployment, and run `nginx -t` plus the complete + health/access smoke. This procedure becomes invalid after the weekly policy + marker is applied; then stop/kill-switch and roll forward. Never roll back the + additive schema. + +## 2026-08-07 first-listen access and boundary synchronization (historical) + +This historical candidate ran application SHA +`dad29d4dc5010603a5bbc7ed309c8f78e7c0f384`, image +`harmonic-beacon/earlybirds-preview-listener:dad29d4` and schema +`20260807100000_early_bird_welcome_access`. Free for All remains OFF for the +coordinated registered-access acceptance. + +- A new signed-in account may explicitly start one durable 30-minute first + listen before selecting its recurring schedule. Registration, page view, + FFA, Founder membership and schedule selection do not consume it. +- Stream lease and manifest authorization are capped at the exact server-side + welcome end. A protected synthetic runtime smoke proved unused state, + activation, exact duration, replay without extension, signed media and + rejection of a second activation. +- An already-open waiting/player page now revalidates once at the scheduled + start/end boundary and on resume, without continuous pre-boundary polling. + Physical timing acceptance remains in #216. +- The passwordless email seam from #221 is present but intentionally hidden. + Real delivery remains blocked on `SairaAsua/proyecciones-mito#44`; no Gmail + OAuth material is installed in Listener. +- Local checkpoint passed 1,124 tests with 19 standard skips, ESLint, + TypeScript, production build, Prisma validation and preview checks. PR #222 + CI passed stream, observability and preview builds. Host/public health, + PostgreSQL, origin and event-production health are green; Listener has zero + restarts. +- Rollback restores root-only + `/etc/harmonic-beacon/earlybirds-preview.env.pre-dad29d4`, selects Listener + image `55bf282` and retains PostgreSQL, the additive welcome table and all + approved media. + +## 2026-08-07 registered Free and identity hardening + +This prior candidate ran application SHA +`575b75aae5609b1813485d955a3e8ea753018084` and schema +`20260807070000_early_bird_free_schedule`. The global Free for All override is +independent from account schedules and membership. + +- A registered account may select one recurring two-hour daily window using a + canonical IANA time zone. The server owns authorization, the rolling + seven-day change boundary and DST resolution. Stream leases and manifests + cannot outlive the active window. +- A synthetic identity-only staging pass selected Listen now, rendered the + active Free home, acquired a lease bounded by the exact window end and + fetched a valid HLS manifest. Free for All was then restored and anonymous + playback passed again. +- Google authorization reached the real Google account chooser in Chromium + with the exact Listener callback, one-time state and PKCE S256. No real + account was selected. Apple is absent and fails closed until its external + developer credentials exist. +- Browser auth mutations now require an exact trusted Origin; Apple/provider + callbacks remain state/cookie and PKCE bound. A synthetic session verified + logout and confirmed that provider tokens, IP address and user-agent are not + retained in the Listener tables. +- All 1,087 tests, ESLint, TypeScript, production build, frozen-audio, + stream-origin, observability and preview checks passed. Public readiness, + ES/EN drop-ins, FFA lease and decoded canary are green. +- The two builds used only temporary Docker build cache. Pruning only unused + cache left every image, container, volume and runtime datum intact. Approved + media remains on `/mnt/beacon-data`; moving containerd itself still requires + a production maintenance window. +- Rollback restores root-only + `/etc/harmonic-beacon/earlybirds-preview.env.pre-575b75a`, selects Listener + image `d7ed952`, and retains PostgreSQL, origin media and the authority. + +## 2026-08-07 live-edge transport refinement + +The isolated Listener now runs application SHA +`2f057e0a31e384ba4d47cd14652afe1967c830ae` (image +`harmonic-beacon/earlybirds-preview-listener:2f057e0`). Nico explicitly +approved a stability-first Listener buffer after hearing choppiness while +moving the volume control. + +- The Beacon is live-edge only: it exposes Stop, never Pause or Seek, and a + later Listen seeks to the current configured edge. +- Pause and Seek remain available only while a private introduction is active. +- The current stability policy supersedes that first tuning: clients target + thirty six-second segments behind the edge and a measured 180-second + memory/MSE continuity window (including the bounded segment reservoir needed + by WebKit); low-latency mode remains off. The origin retains + fifty entries so the configured buffer is backed by real playlist history. +- Volume input updates media elements directly instead of re-rendering the + Listener constellation for every slider movement. +- A real 390x844 Chromium pass fit the complete active-Beacon UI without + vertical or horizontal overflow. Five rapid volume changes left playback + active at the requested volume with 27.8 seconds buffered ahead. +- All 1,049 tests, ESLint, TypeScript, production build, frozen-audio-path, + stream-origin, observability and staging-preview checks passed. +- Host smoke passed and public health attests the exact SHA. Rollback restores + root-only `/etc/harmonic-beacon/earlybirds-preview.env.pre-2f057e0` and + recreates only Listener release `04e578b`; retain PostgreSQL and media. + +## 2026-08-07 Listener presentation deployment + +The isolated Listener was updated to application SHA +`04e578b5d4abc7b73f3ac782abb4dfc6fc70efa8` (image +`harmonic-beacon/earlybirds-preview-listener:04e578b`). This presentation slice +does not change the stream origin, approved media artifacts, audio constants, +event application or membership authority. + +- Local and CI checkpoints passed: 1,047 tests, ESLint, TypeScript, production + build, frozen-audio-path verification, stream-origin checks, observability + checks and the isolated preview build validation. +- The forward-only migration exited successfully; PostgreSQL, Listener + liveness/readiness and stream liveness/readiness passed the host smoke. Public + `/api/health` attests the exact SHA and schema + `20260806040000_early_birds_listener`. +- Public ES/EN layout passed at 1440 and 390 pixels. A real authenticated + Chromium pass at 390 pixels reported zero horizontal overflow and zero + camera/microphone requests. +- The authenticated transport completed introduction, pause, resume, Skip to + Beacon, Beacon playback and Stop, ending in the truthful `stopped` state. +- `live.harmonicbeacon.com/api/health` and the unchanged stream origin remained + healthy after replacement. +- Rollback restores root-only + `/etc/harmonic-beacon/earlybirds-preview.env.pre-04e578b`, selects release + `0b186df` and recreates only the preview Listener. Preview PostgreSQL and all + approved media must be retained. + +## 2026-08-06 staging deployment record + +The isolated preview is currently running on `mona`; this is operational +evidence, not authorization to promote it to `main` or production. + +- Listener application image SHA: + `60bf1182c6ed0d3b946dde103e2e43bb5feb69f9`. Branch head may be a later + host-tooling or documentation-only commit; `/api/health` attests the exact + running application image. +- Free authority preview SHA: + `21c3637ee0f520ee79d20c247e2914699ed8a73a`, with Alembic head + `b8c4d1e7f260` and paid checkout still disabled. +- Runtime, observability and nginx fixes are on the `early-birds` branch. The + deployed application health response attests the exact Listener image SHA; + later documentation-only commits do not require rebuilding that image. +- Both exact hosts have valid Let's Encrypt certificates expiring 2026-11-04 + and emit `X-Harmonic-Beacon-Environment: early-birds-staging`; production + does not emit that attestation. +- PostgreSQL, migrations, Listener, origin, authority API/worker, Prometheus, + Alertmanager, node-exporter, cAdvisor and the decoded HTTP segment canary are + healthy with zero runtime restarts. The authority has no published host port + and paid checkout remains fail-closed. +- Health exposes the checked-in Prisma head + `20260806040000_early_birds_listener` instead of `unknown`; the host smoke + verifies it without requiring Node on `mona`. +- Private drop-ins now answer `HEAD` from metadata and stream only the requested + byte range. A real authenticated browser observed an 11,210,434-byte ES file, + a four-byte `206` response, both media elements paused and no media errors. +- Canonical authority responses that contradict `access_allowed` fail closed. + Segment grants cannot outlive the manifest/lease horizon. Paid checkout's + future PENDING-to-ACTIVE path now advances durable revisions 1 to 2 while + providers remain disabled. +- Legacy invitation bearer queries are immediately moved into a short-lived + `__Host-`, HttpOnly, Secure, SameSite=Lax cookie and redirected to a clean URL. + Exact invitation entry locations are excluded from nginx access logs on HTTP + and HTTPS. A synthetic probe confirmed clean redirect, cookie attributes and + absence from nginx logs. +- Canonical Free acceptance passed through identity-only synthetic login, + signed one-use invitation, private authority redemption, membership + projection, session cookie and Listener home. +- The current canonical lifecycle smoke also passed same-account idempotent + replay, cross-account one-use rejection, three-device/oldest-lease eviction, + durable revocation reconciliation, existing-stream denial and Listener + redirect. A fresh human invitation and its non-secret UUID sidecar are stored + root-only at mode `0600`; the previous invitation was revoked before archival. +- Public real-browser layout checks passed in ES and EN at 1440, 1024, 390 and + 320 pixels. The DB-backed authenticated fixture passed the same responsive + matrix without requesting camera or microphone access. +- A disposable canonical invitation passed real-browser activation and an + immediate second sign-in of the same account. The second device path signs in + before attempting sign-up, avoiding Better Auth's intentional account-create + rate limit. After authority revocation and durable reconciliation, the same + browser path remained at the redeem boundary and truthfully denied access. +- Rollback stopped only Listener/origin, retained healthy preview PostgreSQL, + kept `live.harmonicbeacon.com` healthy, and restored staging via the normal + start/smoke path. +- The origin now serves approved artifact + `beacon-luz-20260624-2hs-aac320-v2`, derived without gain processing from + `luz_de_manana_20260624-155633_2hs.wav`. It is AAC-LC 320 kbps, stereo, + 48 kHz, -14.2 LUFS with decoded peak -0.2 dBFS. The private EN intro is + `amara-sol-en-r1-approved-aac320-v3.m4a`, re-exported on 2026-08-06 at + 18:16 ART with the approved long Beacon fade-in and derived without gain + processing. It is -11.3 LUFS with decoded peak -0.5 dBFS. The obsolete -35.6 + LUFS ES derivative is disabled and ES remains truthfully unavailable. + Event/LiveKit audio is unchanged. +- Rollback snapshots are + `/etc/harmonic-beacon/earlybirds-preview.env.pre-60bf118` and + `/etc/harmonic-beacon/earlybirds-preview.env.pre-audio-2hs-v2`, + `/etc/harmonic-beacon/earlybirds-ops.env.pre-audio-2hs-v2` and + `/etc/harmonic-beacon/earlybirds-authority-deploy.env.pre-21c3637`; the prior + Listener and authority images remain installed and preview databases must be + retained. +- The format-neutral `staging-smoke` load plan was dry-run on the external, + NTP-synchronized `daimonmatrix` generator with zero network requests. Its + plan hash is + `2ed8d7dc1717768fe846a87cdad1a67cf681ce58809e7b4e72106b4f1dcd22c6`; + executing even that ten-client step still requires a real approved artifact, + short-lived signed manifest and an explicit monitored run window. +- The `origin-media-3000` profile was also dry-run as four deterministic + 750-client shards split across two NTP-synchronized external generators + (`legion` and `daimonmatrix`). All four PLANNED artifacts have mode `0600`, + cover shard indices `0..3`, sum to exactly 3,000 clients, use distinct ordinal + hashes, share plan hash + `f7d3254d510530172ed1fcc708fb6f7c70487e5d75f5416da3c9ebb591a28d1e` + and attest zero network requests. This proves distribution readiness, not + throughput or customer capacity. +- The 4,000 expansion and 5,000 critical profiles were subsequently dry-run + across the same two external generators as six and eight shards. The verified + plans cover every index, sum to exactly 4,000 and 5,000 clients, use two + distinct generator fingerprints, preserve mode `0600` and attest zero network + requests. Their plan hashes are respectively + `67b68f412789c1ae3ad8e950c49480704d5c06f33b445788272a0a73fb73a3dd` + and `845206f4b8c1e605953a1efd8066b73b9bba87e2487627f3022bd337ec6d44ec`. + The exact redacted evidence record is + `docs/ops/evidence/2026-08-07-listener-4k5k-dryruns.md`. No load was executed; + measured origin, application and customer capacity remain open. + +Protected runtime configuration remains under `/etc/harmonic-beacon/`; this +record never includes its values. The supervised human Free invitation is +root-owned and mode `0600` on the host. + +This is the non-deploying EB-08 staging lane for exactly: + +- `https://earlybirds-staging.harmonicbeacon.com` — Next Listener on host loopback `127.0.0.1:13000`. +- `https://listen.harmonicbeacon.com` — constrained public edge to the same + Listener, usable only during an operator-controlled Free for All window. +- `https://stream.harmonicbeacon.com` — bounded stream origin on host loopback `127.0.0.1:18080`. + +It is a separate Compose project named `earlybirds-preview`. It does not join, +replace, stop, or migrate the weekend event stack. PostgreSQL is reachable only +on the internal `preview_db` container network; its named volume is +`earlybirds-preview-postgres`. The Listener alone also joins +`listener_egress`, allowing it to fetch the public HTTPS stream hostname. +Beacon-stream remains on its separate internal observability network. + +No deployment, DNS change, certificate request, nginx installation, host +firewall change, OAuth registration, or provider call is performed by these +files or lifecycle scripts. + +The isolated staging host may expose the separately gated PayPal Sandbox and +Mercado Pago TEST workbench for supervised acceptance. The public Listener uses +different Live checkout flags, which remain OFF in this preview contract. No +real-provider credential or production checkout is enabled by opening the +public Listener kill switch. + +## Prepare synthetic inputs + +Copy `ops/early-birds-preview/preview.env.synthetic.example` to a `0600` path +outside Git and set only `BEACON_STREAM_ARTIFACTS_HOST_PATH` to an existing, +generated synthetic fixture directory. No artifact, codec work, approved audio, +drop-in, user export, or event volume belongs in this lane. + +The lifecycle guard deliberately requires: + +- the preview database user/name and fixed nginx ports; +- the reviewed HTTPS Listener and stream origins above; +- visibly `synthetic-` secrets and artifact identity; +- blank Google/Apple client IDs and secrets; +- the synthetic login seam; and +- both public/team-entry kill switches equal to `0` or `1`, with the team form + allowlisted only for `earlybirds-staging.harmonicbeacon.com`. + +It rejects other Harmonic Beacon domains, HTTP stream configuration, +production/provider values, event database identities, real OAuth values, and +non-synthetic secrets. The example starts with `EARLY_BIRDS_ENABLED=0`, so the +Listener serves its truthful unavailable state until an operator deliberately +opens it after the gates pass. + +### Optional private authority handoff + +The default fixture deliberately points +`EARLY_BIRDS_AUTHORITY_BASE_URL` at `https://authority.example.invalid` and is +not connected to an authority. To exercise Free acceptance with the external +canonical membership authority, its independently owned Compose project must: + +1. run in synthetic/staging mode with every paid-provider integration and + checkout entry disabled; +2. join a dedicated external Docker network named + `earlybirds_authority_private`, created with Docker `Internal=true`; the + Uvicorn `api` service/container must be reachable there by its actual private + name `pmp-myth-api` on port `8765`; +3. accept the matching synthetic bearer/key ID from + `EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN` and + `EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID`; and +4. address this Listener as `http://earlybirds-listener:3000` for authenticated + membership projection pushes. + +Then set these values in the protected preview env: + +```dotenv +EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK=earlybirds_authority_private +EARLY_BIRDS_AUTHORITY_BASE_URL=http://pmp-myth-api:8765 +EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID=synthetic-v1 +EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN=synthetic- +``` + +The lifecycle helper then adds `authority-network.override.yml`; otherwise it +does not. The helper refuses a network that is absent or not internal. The +override adds only that private external network and exposes no host port. Its +only intended members are `pmp-myth-api` and this `listener`; verify membership +before opening the entry switches. Network creation and authority configuration +stay with that service's operator; these scripts never create or mutate the +external project. + +## Validate without starting + +From the repository root: + +```bash +npm --prefix ops/early-birds-preview run check +npm --prefix ops/early-birds-preview test +npm --prefix ops/early-birds-preview run validate +``` + +`validate` renders the three-file Compose model and asserts its services, +loopback bindings, network isolation, migration dependency, blank OAuth inputs, +and production-mode HTTPS origin. `validate:build` additionally builds the +Listener, migration, and stream images without starting them. + +## Forward-only start and smoke + +```bash +scripts/early-birds-preview/start.sh /secure/earlybirds-preview.env +scripts/early-birds-preview/health-smoke.sh /secure/earlybirds-preview.env +``` + +`start.sh` is the ordinary Listener release path. It migrates and recreates +the application only; it never rebuilds, recreates or restarts the audio +origin. This separation is a release invariant: a short app/control-plane +deployment must not interrupt an already playing HLS stream. + +Startup is fail closed: + +1. preview PostgreSQL must become healthy; +2. `npx prisma migrate deploy` must complete successfully over the direct, + internal PostgreSQL connection; and +3. only then may the Listener start. + +The smoke verifies the successful migration container, PostgreSQL readiness, +Listener `/api/health` liveness, Listener `/api/health/ready` database +readiness, stream `/healthz` liveness on loopback, and stream `/readyz` inside +its private container network. It does not claim playback or decoded-audio +acceptance. + +Creating or changing the isolated origin is a separate maintenance operation: + +```bash +scripts/early-birds-preview/start-origin.sh /secure/earlybirds-preview.env +``` + +That command must be announced as an origin maintenance window and followed +immediately by the health smoke and decoded-audio canary. It is never part of +an ordinary UI/API release. + +To rerun the idempotent forward migration separately: + +```bash +scripts/early-birds-preview/rehearse-migration.sh /secure/earlybirds-preview.env +``` + +There is no down-migration command. Schema repair is an additive forward +migration; route rollback retains the preview data for inspection. + +## Nginx and TLS handoff + +The three host files in `ops/early-birds-preview/nginx/` are standalone vhost +templates. Each names only its exact hostname, includes an ACME webroot path and +the exact future certificate paths, and proxies only its fixed loopback port. +The stream vhost exposes `/healthz` and `/v1/hls/`; container-private `/readyz` +and metrics are not proxied. The Listener vhost exposes the unified Listener +entry canonically at `/`, plus `/api/early-birds/`, Next static assets and health; +legacy `/early-birds/home` redirects to `/`. It blocks `/api/internal/` +and returns 404 for the image's weekend, staff and event surfaces; its exact +staging checkout and cancellation routes remain independently gated. +The `listen.harmonicbeacon.com` vhost is narrower: it exposes only `/`, Next +static assets, health, the dedicated Listener OAuth/session namespace, the +exact ordinary-Free quota endpoint, stream leases/manifests and configured +drop-ins. Synthetic login, membership projection and all other app routes +remain unreachable from that host. Public invitations use only the exact +Listener and legacy entry/redeem pages plus their two exact POST aliases on +`listen`; the edge applies no-store/no-referrer, suppresses bearer-path logs and +rate-limits redemption at 30 requests/minute with a 20-request burst. Staging +entry and magic-link bearer paths redirect once through exact unlogged +no-store/no-referrer locations to the canonical host. Both staging redeem POST +aliases return an unlogged, no-store 404 so neither can fall through the broad +legacy prefix. The application additionally returns a hidden 404 for Better +Auth's email/password endpoints, so the public namespace offers only configured +Google and Apple social providers. The public vhost now also contains only the +exact, same-origin Listener checkout and cancellation locations plus the two +stable signed-provider webhook locations documented in +`FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md`; all are fail-closed while their Live +flags and authority credentials remain disabled. No prefix proxy exposes other +membership or authority routes. + +A host operator must review certificate/DNS ownership, provision each named +certificate, install these as new site files, and run `nginx -t` before any +reload. Do not edit, replace, symlink over, or reload the existing live/event +vhost as part of this staging lane. + +Keep `EARLY_BIRDS_STREAM_ORIGIN=https://stream.harmonicbeacon.com`. The Listener +runs with `NODE_ENV=production`; its application contract still rejects HTTP +origins. Public HTTPS egress is an explicit staging topology choice, not a +relaxation of production validation. + +## Open, stop, and rollback + +The emergency public-entry switch is one reviewed command. Always preview the +exact operation first; dry-run takes the same lock and validates the complete +protected environment but writes no backup, changes no value and invokes no +container or HTTP command: + +```bash +sudo scripts/early-birds-preview/disable-public.sh --dry-run \ + /etc/harmonic-beacon/earlybirds-preview.env +sudo scripts/early-birds-preview/disable-public.sh --apply \ + /etc/harmonic-beacon/earlybirds-preview.env +``` + +Apply requires root and a mode-`0600` environment. It takes an exclusive lock, +refuses duplicate switch assignments, creates a timestamped mode-`0600` backup, +atomically sets `EARLY_BIRDS_ENABLED`, `EARLY_BIRDS_FREE_FOR_ALL` and +`EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED` to `0`, and recreates only Listener. +It then requires liveness, readiness and an anonymous lease denial with HTTP +503. PostgreSQL, origin, LiveKit, playlist-bot and the event project are not +targeted. If recreation or smoke fails after the atomic replacement, the script +keeps the flags disabled and stops only Listener rather than risking an older +enabled process. + +The command prints the exact backup path. Keep public entry disabled while the +incident is investigated. To roll back a mistaken operator invocation, under +the same maintenance lock copy that exact backup to a new mode-`0600` candidate +beside the env file, run `require_synthetic_env` against the candidate, replace +the env atomically, recreate only Listener with `--no-deps --no-build`, and run +the full preview health smoke plus the intended access-mode smoke. Never restore +an arbitrary or older backup and never roll back the additive database. + +After migration, both liveness/readiness probes, nginx syntax, TLS, and +synthetic negative-access checks pass, change only: + +```dotenv +EARLY_BIRDS_ENABLED=1 +EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=1 +``` + +Recreate the Listener through `start.sh`, rerun the smoke, and exercise only +`@e2e.invalid` synthetic identities with the separate test-login bearer. +Providers with incomplete credentials are absent from both the public UI and +the Better Auth runtime. A shared +preview runtime may instead use `listen.harmonicbeacon.com` as its canonical +OAuth base URL, keep both Listener hosts in `EARLY_BIRDS_TRUSTED_ORIGINS`, and +configure one or both complete provider credential pairs. Synthetic team entry +remains allowlisted only on the staging hostname. +Return both switches to `0` after the supervised team window. + +### Optional email magic-link fallback + +The fallback remains absent until the dedicated PMP Listener mail sidecar is +healthy with the exact private contract in +`docs/architecture/EARLY_BIRDS_MAGIC_LINK.md`. Do not copy or mount its Gmail +OAuth grant into the Listener or sidecar API. After the adapter is deployed on +`earlybirds_authority_private`, apply the additive +`20260807090000_early_bird_magic_link_throttles` migration and configure all +three values together: + +```dotenv +BEACON_LISTENER_MAGIC_LINK_DELIVERY_URL=http://listener-mail-api:8765/api/internal/v1/listener-magic-links/deliver +EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN= +EARLY_BIRDS_MAGIC_LINK_RATE_SECRET= +``` + +Partial or invalid configuration exposes neither the UI nor the auth endpoint. +Rollback clears all three values and recreates only the isolated Listener. + +### Ordinary Free weekly allowance + +When Free for All is off, a signed-in account without canonical membership gets +three hours in its fixed personal seven-day cycle. The first real Free playback +creates the anchor. Browser time, timezone and the retained legacy schedule and +welcome rows never authorize access. The access, lease, heartbeat and manifest +boundaries all reconcile the same server-owned quota; two simultaneous devices +consume the union once. + +### Operator-controlled Free for All + +`EARLY_BIRDS_FREE_FOR_ALL` is independent from the Listener kill switch. Before +turning it on, first use `disable-public.sh --apply` so no new personal lease can +race the drain. Then settle and evict every extant personal lease inside the +disabled Listener container: + +```bash +docker compose --env-file /etc/harmonic-beacon/earlybirds-preview.env \ + -f /opt/early-birds-preview/compose.yml exec -T listener \ + npx tsx scripts/listener-quiesce-for-free-for-all.ts +``` + +The command must report convergence. It records only an aggregate account +count and fails closed unless both public entry and FFA are OFF. After that, +atomically update the protected environment to the values below and recreate +only Listener to let anonymous visitors listen without creating a membership: + +```dotenv +EARLY_BIRDS_ENABLED=1 +EARLY_BIRDS_FREE_FOR_ALL=1 +BEACON_STREAM_ALLOWED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com,https://listen.harmonicbeacon.com +``` + +Public leases use one non-PII technical account, keep raw browser device IDs out +of PostgreSQL, and retain the same short-lived signed-origin boundary. This mode +does not create a membership or unlock any event, staff, payment or internal +surface. Set `EARLY_BIRDS_FREE_FOR_ALL=0` and recreate only the Listener to end +the moment; anonymous heartbeat and manifest requests then fail immediately, +while normal signed-in membership access resumes. No schema rollback or data +deletion is required. + +Before opening the public hostname, verify its certificate, the exact origin +CORS pair above, `/api/health/ready`, an anonymous lease/manifest/playback, and +that `/api/early-birds/auth/session` is reachable without disclosing a session, +while `/api/early-birds/auth/sign-in/email`, `/api/early-birds/test-login` and +`/api/internal/` all return 404. + +Normal stop retains all preview data: + +```bash +scripts/early-birds-preview/stop.sh /secure/earlybirds-preview.env +``` + +Ordinary app rollback stops only Listener while retaining PostgreSQL and the +approved long-lived origin for diagnosis and a forward fix: + +```bash +scripts/early-birds-preview/rollback.sh /secure/earlybirds-preview.env +``` + +Set `EARLY_BIRDS_ENABLED=0` and `EARLY_BIRDS_FREE_FOR_ALL=0` before the next +start. If the origin itself is diagnosed as faulty, use the separately scoped +`ops/early-birds/scripts/stop-stream.sh` command. None of these scripts uses +`docker compose down`, deletes a volume, or targets the event/live project. + +## Staging release gate + +Record config/test/build output, migration status, smoke output, kill-switch +state, rollback/stop/restart evidence, and the reviewed nginx/TLS handoff. Audio +provenance, external decoded-audio canaries, physical device listening, +load/soak, real identity-provider registration, and commerce reconciliation are +separate release prerequisites; this plumbing does not satisfy or simulate +them. Do not promote this runtime to production without the explicit gates in +`docs/plans/EARLY_BIRDS.md`. diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md new file mode 100644 index 00000000..64474bc8 --- /dev/null +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -0,0 +1,208 @@ +# Founding Listener commercial launch + +Status: technically implemented behind fail-closed flags; real sales remain OFF until the supervised +provider cutover. This runbook is Listener-only. It does not deploy, restart or reconfigure event, +LiveKit, Ticket Tailor, playlist-bot, tapestry, event audio or `live.harmonicbeacon.com`. + +## Product contract + +- Founding Listener is USD 5/month, recurring, with no trial or setup fee. +- PayPal charges USD 5. Mercado Pago charges the canonical BCRA-derived ARS amount displayed by + its checkout. +- Founder status and price exist only while service is uninterrupted. Cancellation retains access + through the paid boundary. A real lapse, refund, reversal, dispute, chargeback, fraud or admin + termination ends Founder continuity. +- Cancellation is prospective and automatic: it disables future renewals, does not refund the + current paid period and does not require a human queue. Refunds are exceptional, manual provider + operations only; the authority still ingests their signed events as terminal evidence. +- Browser redirects, Free, invitations and Free For All never grant membership or emit Purchase. + +Public terms and privacy are published at `/listener/terms` and `/listener/privacy`. They are a +truthful launch baseline, not a substitute for counsel review. Human owner: Nico/AlterMundi. + +## Verified pre-release evidence — 2026-08-12 + +- Mercado Pago TEST completed checkout, canonical activation, pause, reactivation and a fresh + reconciliation using synthetic buyer/card data. +- PayPal Sandbox completed a fresh USD 5 activation, pending cancellation, reversal before the + boundary and a full refund. The refund terminalized continuity, removed the Founder profile and + returned the account to Free. +- Private paid-lifecycle metrics and Telegram warning/critical/recovery rules are deployed. A + database backup was restored into an isolated rehearsal database and verified. +- Production provider and new-sales flags remain OFF. No real payment was attempted. + +These accepted browser lifecycles are explicitly non-production: PayPal used +Sandbox and Mercado Pago used TEST. PayPal Live has since completed activation, +cancellation and reactivation with a non-merchant buyer. Mercado Pago Live +still requires its supervised lifecycle. A real refund is intentionally not +part of launch acceptance. + +Passwordless email delivery is deployed in the dedicated Listener-only sidecar at exact backend +SHA `456ece2b38e203a2d12c54864115e03ebaa1a89c`. The API, worker and PostgreSQL queue have no host +ports, use separate storage and did not restart or modify any event service. A controlled message +reached Gmail with terminal delivery state `SENT`; the real-browser callback, isolated email-only +Free entry and logout check passed. The other remaining gates are human/external: +final legal/copy acceptance, one +supervised low-value Live lifecycle per enabled provider and explicit approval to open public +checkout. Production fonts are now hermetic under #327/#329. + +Google OAuth rotation #328 is complete. The persistent Listener and disposable staging workbench +both use the replacement client and root-only secret. Canonical login, logout and re-login passed; +the staging callback passed without `authError`. The previous client was revoked only after those +checks and is recoverable in Google Cloud for 30 days for administrative recovery. Never restore +the exposed secret from an environment backup. + +The exact public Listener image is +`5d1073f598272d81a14a64d55a4220c2c13e9a74`. Health attests that SHA. The dormant staging-only +Live workbench remains on `acc90ba35fea52f63ef18337e3a555ef637c552f`. The no-port withdrawal +operator remains independently pinned at `0a475717d45d32cec38afdb8fc35fb772a994017`. +`acc90ba35fea52f63ef18337e3a555ef637c552f` remains available as the previous +contract-compatible application-only rollback target; the operator and current database must remain running so legal +requests already received can still be processed. The weekly-quota database policy itself is +forward-only. + +The `5d1073f` release keeps the mandatory consumer actions fixed on desktop but places them after +Listener content on screens up to 640 px, so they remain prominent without obscuring the hero or +primary entry action. Real-browser ES/EN checks at 390x844 confirmed the actions in document flow; +the desktop check retained the fixed bottom-right placement. No checkout, membership, provider, +event, LiveKit, media or audio behavior changed. + +The public no-login `BOTÓN DE ARREPENTIMIENTO` and `BOTÓN DE BAJA DE SERVICIO` are deployed with an +immediate opaque receipt, bounded durable queue and no automatic provider action. Root-only timers +export and prune through the pinned operator sidecar. Prometheus loads warning/critical/freshness +rules at 20h/24h; the runtime smoke accepted and resolved one synthetic request of each kind, then +returned the open queue and alerts to zero without exporting PII. + +The exact isolated payment-authority image is +`4e5b208e902969285c8f68067f7fd13b7e2eb68d`. It includes the reviewed Mercado Pago adverse-event +hardening from backend PR #80 and missing-PayPal-approval recovery from backend PR #82. API and +worker are healthy, Alembic is at head `7b4c1e9a2d60`, and +the exact public webhook routes fail closed while Live is disabled. Productive PayPal and Mercado +Pago credentials are installed only in the root-owned runtime store. Read-only preflights verified +the PayPal Live catalog/webhook and Mercado Pago productive merchant/webhook configuration with +new sales forced OFF. On 2026-08-15 the abandoned supervised PayPal approval was verified missing +through the official provider API and retired with the application operator. The repair produced no +subscription, charge, Founder continuity, Purchase or direct SQL mutation; the request is +tombstoned and outstanding PayPal Live bindings are zero. New sales remain OFF while PayPal Live +lifecycle/reconciliation stays ready. The exact pre-deploy backup +`/var/backups/harmonic-beacon/earlybirds-authority-pre-4e5b208-20260815T090209Z.dump` +and older images are retained only as forensic/disaster-recovery artifacts; they are not routine +post-transaction rollback targets. + +After that repair, the private staging workbench was returned to its dormant +state: exact image `acc90ba`, effective workbench gate `0`, both public Live +flags `0`, authority new sales disabled and both staging/canonical workbench +POSTs `404`. Its root-owned allowlist/provider/CSRF configuration remains +installed at mode `0600` for a future explicitly approved rehearsal. + +## Independent switches + +Listener app, all default OFF: + +```text +BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=0 +BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED=0 +``` + +Canonical authority, all default OFF until provider configuration is validated: + +```text +PMP_MYTH_EARLY_BIRDS_PAID_CHECKOUT_ENABLED=false +PMP_MYTH_EARLY_BIRDS_PAYPAL_LIVE_ENABLED=false +PMP_MYTH_EARLY_BIRDS_MERCADO_PAGO_LIVE_ENABLED=false +``` + +The authority's new-sales switch may be turned off without disabling signed webhooks, +reconciliation, expiry or cancellation for existing members. Never respond to an incident by +deleting bindings, events, jobs or projections. + +## Public boundaries + +- Browser checkout: exact same-origin `POST /api/listener/checkout`. +- Browser membership action: exact same-origin `POST /api/listener/membership/action` with + canonical `cancel|reactivate`. +- PayPal Live webhook: `POST /v1/webhooks/listener/paypal`. +- Mercado Pago Live webhook: `POST /v1/webhooks/listener/mercado-pago`. +- Every other authority route stays loopback/private. Event vhosts expose none of these routes. +- The browser supplies provider plus a random attempt ID for checkout. Mercado Pago additionally + requires the payer email entered specifically for that provider; it may differ from the Listener + sign-in email, is forwarded without becoming profile identity and is retained only as keyed + evidence rather than readable PII. PayPal receives no payer email from Listener. Account, + current membership provider and provider subscription ID remain server-derived. Membership + management supplies only a random attempt ID plus canonical action. Provider IDs never enter the + browser response. + +For reversible cancellation before the service boundary, PayPal uses suspend/activate and Mercado +Pago uses pause/reactivate. A terminal provider cancellation, lapse or adverse event is never +converted back into a reversible action. + +## Preflight and cutover + +1. Back up the Listener database and record current Listener and authority image SHAs. +2. Install root-only provider secrets; verify ownership/mode without printing values. +3. Keep every Live provider and Listener checkout flag OFF. Temporarily set + `PMP_MYTH_EARLY_BIRDS_PAID_CHECKOUT_ENABLED=false` so the read-only preflight cannot coexist + with Sandbox/TEST new sales, then run inside the exact authority container: + `pmp-myth-listener-live-preflight --provider paypal`, + `pmp-myth-listener-live-preflight --provider mercado_pago`, or `--provider all`. + The command performs only provider reads and emits no IDs, secrets or PII. Require + `status=verified` and `new_sales=disabled`; on any failure, keep all Live flags OFF. +4. Validate private readiness, exact signed-webhook negative cases and reconciliation. From an + external operator host, run + `node scripts/early-birds-preview/listener-live-dormant-check.mjs` and require `PASS` while + sales are dormant. The provider preflight does not replace webhook signature or lifecycle tests. +5. Install the reviewed Listener nginx template and verify exact routes plus final 404. Do not + reload nginx unless `nginx -t` is green. +6. Enable the matching Listener checkout flag only after the authority reports that Live provider + ready and the public copy/terms have human approval. +7. Execute one supervised real USD 5 membership with an agreed account. Confirm provider event, + canonical projection, profile badge, unlimited access, renewal boundary and no raw PII in logs. +8. Request cancellation in the profile. Confirm future renewals stop, the projection becomes + pending-end and access continues through paid-through. Reactivate before the boundary and verify + continuity. Never issue a real refund merely as a rehearsal. +9. Expand availability only after webhook/reconciliation lag and alerts remain healthy. + +## Incident and rollback + +- Checkout/provider incident: turn off both app checkout flags and the authority new-sales flag. + Existing lifecycle workers and webhooks stay running. +- Live authority floor: after any new Live checkout attempt, provider binding or event, + `4e5b208e902969285c8f68067f7fd13b7e2eb68d` is the minimum supported authority binary. Do not run + `b1038ddb579817e39add567c5b7b055e2f716095` or + `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` after a new approval exists: the first predates + safe provider-404 retirement and the second also predates required Mercado Pago adverse-event + hardening. Do not routinely restore a pre-cutover database backup; it could discard canonical + checkout/lifecycle evidence. +- Authority regression after Live cutover: keep the current database, turn new sales OFF, retain + the affected provider's Live lifecycle flag so signed webhooks, reconciliation, cancellation and + existing access continue, then deploy a repaired `4e5b208`-compatible-or-newer image and reconcile + from the provider. Recovery is roll-forward. A pre-cutover database restore is reserved for an + explicitly commanded disaster recovery with both providers frozen and a complete provider-led + reconciliation plan; it is not an ordinary rollback. +- Listener regression: roll back only the Listener image while keeping a contract-compatible + authority. `acc90ba3` remains the bounded contract-compatible application rollback for the current + authority. Preserve the independently pinned `0a475717` withdrawal operator + and database so already-received legal requests remain processable; hide new + legal submissions with their feature switch if necessary. + If compatibility is uncertain, keep Listener disabled and roll forward. +- Provider-specific incident: disable only that app checkout flag. Do not route a pending checkout + to the other provider or manufacture membership. +- Webhook/reconciliation lag: stop new sales, keep ingestion active, reconcile from provider APIs, + and do not infer access from return URLs. +- Exceptional refund/dispute: a human explicitly performs or validates the provider operation, + then the authority follows the canonical signed provider event. This is not the normal + cancellation path and is never triggered automatically. Support records only the provider + operation and opaque account in the private ledger; no card/bank data enters GitHub or + application logs. + +## Human release gates still required + +- Counsel/merchant review of public terms, privacy, refund and tax/invoicing obligations. +- One supervised real purchase and cancellation per provider. +- Explicit approval to turn on real sales. The checked-in defaults remain OFF. + +The concise current-state handoff is `docs/operations/LISTENER_LAUNCH_NOW.md`. + +Supervised real-provider acceptance must use the separate, one-account staging workbench described +in `LISTENER_PRIVATE_LIVE_WORKBENCH.md`. It leaves this public checkout surface OFF and preserves the +normal staging Sandbox/TEST route. diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md new file mode 100644 index 00000000..b792e68a --- /dev/null +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -0,0 +1,228 @@ +# Founding Listener release-candidate handoff + +Date: 2026-08-09 + +Public acceptance host: `https://listen.harmonicbeacon.com/` + +Integration branch: `early-birds` + +Draft pull request: `AlterMundi/harmonic-beacon-webapp#203` + +This is the handoff for a bounded real public test. It does not authorize a +merge to `main`, a worldwide campaign, paid-provider activation, real charges, +an event-stack deployment or an acoustic change. + +## Current commercial checkpoint — 2026-08-12 + +The weekly-Free candidate has advanced to a complete Founding Listener pre-release lane: + +- canonical uninterrupted Founder continuity and USD 5/month offer; +- PayPal Sandbox and Mercado Pago TEST browser acceptance; +- self-service cancel/reactivate at the paid boundary and terminal Free fallback; +- private paid-operation metrics, alerts, backup/restore and sales kill switches; +- production provider adapters and public checkout present but fail-closed/default-off. + +The release is not yet authorized for public real sales. Passwordless email delivery is deployed in an +event-isolated sidecar at exact backend SHA +`SairaAsua/proyecciones-mito@456ece2b38e203a2d12c54864115e03ebaa1a89c`; a controlled email reached +Gmail with terminal state `SENT`. The human callback/Free/logout check remains. Google OAuth +rotation and real callback/logout/re-login acceptance are complete. Productive provider credentials +are installed root-only. Remaining gates include final ES/EN legal/copy review, completion of one +supervised low-value Live lifecycle per provider and explicit main/public-sales approval. Hermetic fonts are complete. See +`docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and issue #315 for the current checklist. + +## Status: weekly Free deployed for acceptance + +Release `1f8368d2fda19b30b74c95af884d862838f73305` is deployed on the isolated +Listener. The active policy is three hours per account per fixed seven-day cycle, +anchored at first real authorized Free playback, with no base rollover and +server-time metering. Two devices consume their listening union once; intro and +Beacon both count; Stop/disconnect are bounded by leases. Active canonical +membership/invitation and FFA remain unlimited/non-metered. Optional credits +use auditable idempotent grants with immutable facts, a monotonic consumed +total and optional expiry. Daily scheduling and the separate welcome grant are +absent from UI and authorization; their public APIs return 404. + +This remains an experimental pre-release. Prior Free-policy clients and +binaries are unsupported. Recovery means stop/kill-switch and roll-forward +repair, never restoring daily/welcome authorization. + +## Candidate identity + +| Artifact | Exact value | +|---|---| +| Deployed Listener application | `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` | +| Previous same-schema application rollback | `fcdde379` | +| Commercial checkpoint documentation | `78ede811161cf47104f3758e6703d06d2328ea6f` | +| Listener database schema | `20260808160000_listener_weekly_quota` | +| Canonical payment authority application and post-Live rollback floor | `b1038ddb579817e39add567c5b7b055e2f716095` | +| Listener mail sidecar application | `456ece2b38e203a2d12c54864115e03ebaa1a89c` | +| Public mode | Free for All OFF during coordinated registered-Free acceptance | +| Recovery | Stop/kill-switch and roll forward; old policy images unsupported | + +The current authority adds the reviewed Mercado Pago adverse-event hardening and a read-only Live +provider preflight. Productive credentials are installed root-only. A supervised PayPal Live +approval intent was created for USD 5 and is awaiting a non-merchant buyer; it created no +subscription or charge. New sales and public checkout are OFF while PayPal Live lifecycle +ingestion remains ON. Once any Live checkout attempt exists, `b1038ddb` is the authority binary +floor: preserve the current database, reconcile provider state and roll forward. Older images and +pre-cutover backups are forensic/disaster-recovery artifacts, not routine rollback targets. + +Health must attest the deployed application SHA, not the later documentation or +test-only branch head. + +## Completion matrix + +| Requirement | State | Authoritative evidence | +|---|---|---| +| Ordinary Free requires identity when FFA is OFF | Proven | Runtime anonymous lease returned 401; protected synthetic identity completed schedule and stream flow. | +| Google authorization start | Proven | Real Chromium reached Google's chooser with exact Listener callback, one-time state and PKCE S256. | +| Google provider callback and account return | Human proven | A supervised human completed real Google sign-in, logout and sign-in again. A sanitized database audit found one recent provider identity/session while OAuth tokens, session IP and user-agent remained absent. | +| Apple identity | External blocker | Apple Developer Program login/2FA, App ID, Services ID, key/team identifiers, private key and generated client-secret JWT are absent. Provider stays hidden. | +| Public email/password and synthetic entry absent | Proven | Listener edge returns 404 for email sign-in, test-login and internal/event/staff surfaces. Public invitation redemption is an explicit, bounded exception below. | +| Public invitation redemption | Deployed; one human gate remains | Staging bearer entry redirects once to canonical `listen`; staging cannot mint the cookie or accept redemption. Canonical HTTPS+Host+Origin is the only mutation boundary, bearer paths are unlogged/no-store/no-referrer and terminal cookies are cleared. Automated nginx/browser negatives pass; one real Google+valid-invitation flow remains. | +| OAuth/session privacy and CSRF boundary | Proven | Exact-Origin mutation gate, callback state/cookie+PKCE, token scrubbing, zero persisted session IP/user-agent and logout tests/runtime smoke. | +| Passwordless email fallback | Deployed; one human gate remains | The event-isolated sidecar is healthy, exact-host/auth negatives pass and a controlled Gmail delivery reached `SENT`; open the received link and prove email-only Listener → Free → logout. | +| Three-hour weekly Free quota | Proven | Unit/integration/PostgreSQL matrix plus deployed virgin-account smoke. | +| First-play cycle anchor | Proven | Page view and lease preparation do not anchor; the first authorized listening transition creates one immutable anchor. | +| Exact seven-day reset/no rollover | Proven | Server-clock cycle arithmetic, multi-cycle inactivity and reset tests are green. | +| Browser clock/timezone independence | Proven | Authorization and countdown derive from server time; no timezone or DST input remains. | +| Live remaining/renewal state | Deployed; human timing pending | Countdown follows server snapshots, Stop halts consumption and boundary retries revalidate without reload. | +| Lease cannot outlive quota | Proven | Lease/heartbeat/manifest all repeat the same account lock, settlement and exhaustion boundary. | +| Maximum two devices | Proven | Runtime third device displaced the oldest; its heartbeat returned 410 `displaced`; newest lease fetched signed HLS. | +| Canonical Founder access anytime | Proven | Canonical projection is evaluated before Free; ACTIVE/GRACE/paid-through and terminal/refund boundaries are tested and deployed. | +| Free/FFA never fabricate membership or Purchase | Proven | Separate schedule/technical-account tables and route-level override; no payment/Meta event is emitted by Listener paths. | +| FFA reversible | Proven | OFF denied anonymous lease; ON restored anonymous lease 200 without schema or membership mutation. | +| ES/EN and override | Proven | Locale default, explicit intro override, private byte ranges and distinct immutable assets pass tests/runtime. The deployed Free-account smoke proved Spanish returns authorized `206 audio/mp4` instead of a false membership denial under concurrent lease signaling. | +| Intro to Beacon lifecycle | Automated/browser and iPhone human proven | Intro play/pause/seek, natural handoff, mutual exclusion, live-edge Stop/rejoin and duplicate guards pass. Nico confirmed the deployed iPhone flow worked correctly after the gesture-safe fix. | +| Mobile one-screen interaction | Browser and iPhone proven; broader physical matrix pending | Chromium 390x844 has no overflow; mode targets are 52 px and primary action 56 px. iPhone playback passed; physical keyboard/screen-reader and Android/Firefox review remains. | +| Audio guardrail | Proven | Frozen-audio gate is green; the public field uses server-side analysis and changed no asset, codec, rate, channel, gain, fade, buffer, routing or event audio. | +| Reactive harmonic field | Deployed; extended physical matrix pending | Nico accepted the selected Radial ribbons preset after confirming correct intro and Beacon audio. Public frames require the active listening lease; the technical Lab is default-off and staging-only. | +| App/origin/DB/canary | Proven | Public readiness, exact schema/SHA, stream health and decoded canary are green. | +| Telegram warning/critical/recovery | Proven | Dedicated delivery and recovery were exercised; Alertmanager currently has zero active alerts. | +| Storage | Proven | Approved media is on `/mnt/beacon-data`; after the final image build root retained about 65 GB free and the secondary volume remained about 6% used/89 GB free. | +| Capacity plan | Prepared, not measured | Deterministic external 3k/4k/5k shards are recorded. No same-host 150-client test or high-load claim was made. | +| Full gates | Proven | 1,461 tests with 28 standard skips, ESLint, TypeScript, build, Prisma, preview, origin and nginx checks are green for the deployed visual release. | + +## Delivered commits + +- `f8a8ece` — server-owned weekly quota, grants, leases and migrations; +- `af5ae6a`, `f5a8152` — generation/sequence playback signaling and duplicated-tab identity isolation; +- `407516d` — rollout, FFA quiescence, future-effective membership and operational hardening; +- `808bf0e` — removal of pre-release daily/welcome APIs and implicit old-client defaults; +- `8444ed7` — exact deployed weekly runtime smoke. +- `7036eb3` — human-readable renewal countdown in days and hours. +- `6a5d4b6` — explicitly approved 70% initial Listener volume. +- `68b930c` — one-checkbox introduction choice and readable dark select menu. +- `49fd9c8` — truthful prepared-source lifecycle, coherent Pause/Stop layout, + unified control panel and bottom weekly status/membership action. +- `ae1d0ba` — Free-authorized ES/EN intro range delivery under quota/heartbeat + contention, with bounded serialization retry and recoverable UI failure. +- `1f8368d` — accepted server-analyzed Radial ribbons field on the canonical + Listener, with the Reactive Field Lab default-off and staging-only. + +Historical pre-weekly experiments: + +- `d7ed952` — recurring registered-Free windows and combined access authority; +- `4b9e0fa` — exact-Origin auth mutation gate and session metadata scrubbing; +- `575b75a` — logout outside a Free window; +- `637c5e0` — deployed identity/Free operational evidence; +- `e0bc329`, `d4a7986` — current public acceptance runbook; +- `aba2057` — reproducible deployed registered-Free runtime smoke; +- `a21273a` — passwordless email fallback seam, hidden until delivery exists; +- `55bf282` — iPhone gesture-safe intro handoff; +- `dad29d4` — one-time welcome access and boundary synchronization; +- `b843c7d` / merge `2de5923` — truthful failures, locale-safe SSR and responsive/accessibility hardening; +- `563bebf` / merge `67ceefc` — canonical, privacy-preserving public invitation redemption; +- `c7145a1` / merge `2344b10` — bounded Listener runtime namespace compatibility; +- `497772c` / merge `b8a04fe` — startup-tolerant public disable/kill-switch verification. +- `200242d` / merge `20406da` — canonical-first invitation cookie with + rollback-compatible dual-write, conflict rejection and dual-clear. + +PR #203 remains draft and mergeable. The exact application SHA above is the +deployed image; later documentation-only commits do not require rebuilding it. + +## Current runtime and operations + +- Listener, PostgreSQL and stream origin are isolated from the event project. +- Free for All is OFF so ordinary public access requires canonical identity and + server-authorized weekly Free quota, invitation or Founder access. It remains an + independent, reversible operator override. +- Listener health/readiness, origin, PostgreSQL and decoded canary are green. +- Alertmanager has no active alert. A prior root-disk warning was real, then + resolved after removing only old unreferenced Listener/authority image tags. +- Current image is `4ac408f`. Image `fcdde379` remains the same-schema application recovery + target; earlier policy images are historical and + are not valid rollback targets. +- The authority has an independent post-Live floor: `b1038ddb` is the minimum binary. New sales + stop with flags while provider lifecycle ingestion and the current canonical database remain; + recovery reconciles and rolls forward. +- The fixed public-disable command was exercised after deployment. Its first + health probe observed the normal Next.js startup connection reset, retried, + then proved liveness, readiness and anonymous lease denial before exiting 0. +- `live.harmonicbeacon.com`, LiveKit, event Beacon audio and the event database + were not changed. + +## GitHub coordination truth + +- #214 is implemented and deployed as ordinary weekly Free quota. +- #195 remains open: measured external load/CDN rehearsal. +- #196 remains open only for Apple developer credentials and physical Apple + acceptance; the real Google callback/logout/relogin passed. +- #197's continuity-bound authority/Listener correction is merged and deployed to + isolated staging. Byte-exact contracts, terminal tombstones, uninterrupted + Founder semantics and synthetic pre-release retirement are proven. The card can + close independently of provider activation. +- #198 remains open: physical acoustic/accessibility and 60-minute acceptance. +- #201 is In Progress: the human acceptance matrix. +- #216's old daily-window acceptance is obsolete; weekly reset/countdown human + acceptance replaces it. +- #217 remains open only for a human to use the delivered email and prove the + callback, email-only Listener session, Free entry and logout. The dedicated + sidecar is deployed without an event maintenance window; one controlled + message already reached Gmail `SENT`. +- #218 is closed/Done with deployed runtime evidence. +- #219 is closed/Done after positive physical iPhone acceptance of the deployed + gesture-safe handoff. +- #210 remains open for the later auth/cookie and cross-repository namespace + phases; runtime environment compatibility is merged and deployed. +- #213 remains open for the final public-human invitation/experience evidence. +- #211 is deployed. #212's accepted field is public; its technical laboratory + remains default-off and can be re-enabled only on staging for later variants. +- #199/#200 have fresh provider evidence: PayPal Sandbox completed USD 5 + activation, cancel-pending-end, reversal and terminal-event handling; Mercado Pago TEST + completed checkout, pause, reactivation and reconciliation. Productive credentials are installed + root-only. One PayPal Live approval intent exists without a subscription or charge; PayPal Live + lifecycle ingestion stays ON while global new sales, both public checkout flags and Mercado Pago + Live remain OFF. + +## Remaining human sequence + +Use `docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md` as the authoritative +worksheet. + +1. Review and accept the final ES/EN offer, seller, prospective cancellation, + manual-exception refund, privacy and support copy. +2. Rotate the exposed Google OAuth client secret through the protected store and + re-run callback/logout without printing it. +3. Prove one controlled + magic-link request, email, callback and Free entry. +4. Install protected PayPal and Mercado Pago Live credentials with all sales + flags still OFF, then run one explicitly approved low-value lifecycle per provider. +5. Approve merge to `main` and public checkout separately; retain the immediate + new-sales kill switch throughout launch. + +Do not select a user's Google account, provision Apple, charge a provider, +alter audio or merge/promote the branch as part of an automated test. + +## Recovery after the weekly migration + +Previous experimental policy images are unsupported. Disable or stop only the +Listener, retain PostgreSQL and origin media, repair forward from the weekly +schema and rerun the complete health/access smoke. Do not down-migrate or +restore daily-schedule/welcome authorization. + +To end a public Free for All moment without rolling back code, set only the FFA +switch to OFF, recreate only the isolated Listener and verify anonymous +lease/manifest denial. Already signed or buffered media may drain for the short +manifest/signature horizon. diff --git a/docs/operations/LISTENER_APPLE_IDENTITY.md b/docs/operations/LISTENER_APPLE_IDENTITY.md new file mode 100644 index 00000000..0f10b914 --- /dev/null +++ b/docs/operations/LISTENER_APPLE_IDENTITY.md @@ -0,0 +1,89 @@ +# Listener Sign in with Apple + +> **Legacy cutover note:** this document describes the direct Listener provider +> runtime that remains relevant only until the central Account production +> cutover or for its rollback. Do not create new direct-Listener Apple clients +> from it. New staging and production provider setup belongs to the central +> Account authority and must follow +> `docs/operations/BEACON_ACCOUNT_SOCIAL_PROVIDERS.md`. + +Apple identity is provider-neutral, fail-closed and default-off. Google keeps +its explicit account chooser. No provider may implicitly link itself to an +existing Listener account merely because an email address matches. + +## Runtime contract + +- `BEACON_LISTENER_APPLE_ENABLED=1` is the only enable switch. Apple has no + legacy aliases: this new, unreleased integration starts canonical-only. +- The switch, Services ID and client-secret JWT must come from one complete + environment generation. Partial or mixed bundles fail readiness. +- Readiness requires an HTTPS auth base and a structurally valid, unexpired + ES256 Apple client-secret JWT scoped to the configured Services ID. Apple is + still the cryptographic verifier during the token exchange. +- Missing credentials, an expired JWT or the switch at `0` removes Apple from + the public provider list. Google and magic link remain independent. +- The Apple Services ID must admit both reviewed web domains and exact return + URLs: + - `listen.harmonicbeacon.com` → + `https://listen.harmonicbeacon.com/api/early-birds/auth/callback/apple` + - `earlybirds-staging.harmonicbeacon.com` → + `https://earlybirds-staging.harmonicbeacon.com/api/early-birds/auth/callback/apple` +- OAuth state is one-use, database-backed and paired with an HttpOnly signed + state cookie. Provider linking and implicit email linking remain disabled. +- Apple may supply name only on first consent and may omit email later. The + Apple subject identifies the provider account; missing profile fields map to + a neutral name and a deterministic, non-deliverable opaque local address. + +## One-time Apple setup + +An Apple Developer Program Account Holder or Admin with 2FA must create or +confirm: + +1. the primary App ID with Sign in with Apple enabled; +2. a Services ID used as `BEACON_LISTENER_APPLE_CLIENT_ID`; +3. both Listener web domains and exact callbacks above; +4. Team ID, Key ID and a Sign in with Apple private `.p8` key; +5. an ES256 client-secret JWT whose `iss` is the Team ID, `sub` is the Services + ID, `aud` is `https://appleid.apple.com`, `kid` is the Key ID and lifetime is + no longer than six months. + +Generate the JWT outside the repository from the Team ID, Key ID, Services ID +and `.p8`. Install only the Services ID and generated JWT +through the root-owned Listener environment or approved secret manager. Never +paste the `.p8`, JWT, IDs or 2FA material into chat, GitHub, logs or client-side +variables. Keep `BEACON_LISTENER_APPLE_ENABLED=0` while installing them. + +## Cutover and supervised acceptance + +1. Confirm the secret file is root-owned, mode `0600`, and not mounted into an + event or Live service. +2. Install the complete canonical Apple bundle in the Listener release + environment with `BEACON_LISTENER_APPLE_ENABLED=0`, then restart only the + isolated Listener container and verify `/api/health/ready`. +3. Start the disposable staging Listener with Free For All disabled and + `LISTENER_UI_PREVIEW_APPLE_ENABLED=1`. The preview launcher always overwrites + the inherited Apple gate, so a future public enablement can never turn Apple + on in staging accidentally. Verify readiness again. +4. Complete the staging acceptance first, then a first production Apple + consent, logout, repeat consent (where name may be + absent), and “Use another account” recovery after an intentionally failed + callback. Confirm Google still displays its account chooser. +5. Confirm no email-match linking occurred and only one Listener provider + account was used across the repeated Apple consent. + +Staging rollback is restarting the preview without +`LISTENER_UI_PREVIEW_APPLE_ENABLED=1`. Production rollback is +`BEACON_LISTENER_APPLE_ENABLED=0` followed by recreating only the Listener +container. This hides Apple without changing Google, sessions, audio, +membership, payments or events. If credentials are not yet available, their +safe installation plus the supervised browser acceptance above are the only +human actions remaining. + +Rotate before JWT expiry by generating a replacement from the same reviewed +Team ID, Key ID, Services ID and protected `.p8`, replacing the root-owned JWT +atomically, recreating Listener and confirming readiness before removing the +superseded JWT from its old environment location. Keep the `.p8` protected (or +revoke its Apple key deliberately); never discard it as part of routine JWT +rotation. A separate staging client secret is not required unless +Apple account policy explicitly mandates it; each runtime keeps its own +independent `APPLE_ENABLED` gate. diff --git a/docs/operations/LISTENER_AUDIO_CONTINUITY.md b/docs/operations/LISTENER_AUDIO_CONTINUITY.md new file mode 100644 index 00000000..2fce1196 --- /dev/null +++ b/docs/operations/LISTENER_AUDIO_CONTINUITY.md @@ -0,0 +1,146 @@ +# Listener audio continuity + +This runbook covers the Listener-only 24/7 Beacon stream. It does not apply to +event playback, LiveKit, playlist-bot or `live.harmonicbeacon.com`. + +## 2026-08-09 incident + +The reported silence began during a preview deployment. Host evidence shows +that manifest, heartbeat and access-state calls returned 502 for roughly 21 +seconds while the Listener was recreated; the same operation also recreated +the isolated stream origin. The affected hls.js media clock then stopped +advancing without delivering a fatal/stalled event. Server-side visualization +polling continued with that frozen program time until the analysis endpoint +correctly rejected it as stale. + +Analysis-frame traffic is not proof of audible playback. The decisive signal +is advancement of the active `HTMLMediaElement.currentTime` together with its +ready/network state and HLS lifecycle. + +## Runtime recovery + +While Beacon playback is requested, visible and not inside an introduction, +the client samples the media clock every five seconds. Fifteen seconds without +progress (five seconds after a fatal network signal and an actually exhausted +forward buffer) produces one bounded diagnostic and enters automatic recovery. +Recovery retries immediately and then with exponential backoff capped at thirty +seconds; it does not give up while the listener still requests playback. + +A fatal hls.js network signal no longer enters that destructive path while the +media clock can advance. The player keeps the exact MediaSource, audio element, +fade, presence and lease, and resets only the loader with `stopLoad()` / +`startLoad()` on the same hls.js instance. It continues from already-buffered +bytes. A successful manifest or fragment load cancels the refill timer. Only +genuine buffer exhaustion, decoder failure or a non-network fatal error reaches +the same-lease rebuild below: + +1. marks presence idle; +2. verifies the existing lease and generation; +3. destroys the one stalled hls.js instance; +4. reattaches the verified manifest, even when the URL is unchanged; +5. seeks to the current live position, calls `play()`, then marks presence + listening again. + +Each automatic media `play()` attempt is bounded to eight seconds. A browser +that leaves the promise pending after MediaSource exhaustion therefore cannot +deadlock reconnection; the same-lease backoff loop remains authoritative. + +It does not mint a second lease for an active generation, construct a second +audio graph or modify codec, buffer, gain, fades, routing or assets. Stop, +displacement and denied access remain terminal. + +## Stability-first delivery policy + +Listener is not a low-latency product. The canonical origin retains fifty +six-second entries (approximately five minutes) and a fresh browser starts +approximately thirty entries (three minutes) behind the live edge. hls.js +targets and caps three minutes of forward media. A bounded, memory-only segment +reservoir retains the same newest three-minute window before playback needs it; +this is required because WebKit's MediaSource kept only about 23 seconds in the +real-browser gate even with the 180-second hls.js configuration. Cached +fragments are served back to that exact hls.js instance during an outage, while +the last valid playlist remains available until origin recovery. The reservoir +accepts only HTTPS segment URLs from the manifest's exact origin, omits browser +credentials and referrers, caps retained bytes at 16 MiB, and is destroyed with +the player. Prefetch starts only after the listener asks to play, not merely by +visiting the page. It is never persistent storage and never logs signed URLs. + +Native HLS seeks to the same three-minute target rather than sitting directly +on the edge. A full three-minute forward reservoir is about 7.2 MB at 320 +kbit/s, but playback does not wait for the whole buffer to fill. The trade is +approximately three minutes of program delay, not three minutes of startup +silence. + +The playlist window and client target are one contract. A client target larger +than the retained playlist is fictional buffering and must not ship. Any future +change must verify both sides, decoded audio, quota accounting and the physical +device matrix. + +Native `stalled` and `suspend` events are advisory: browsers may emit them while +they still have healthy buffered media. They do not by themselves show +**Reconnecting** or rebuild the media pipeline. A native media error remains an +immediate recovery signal; otherwise the fifteen-second media-clock watchdog +is the authority. + +When the document becomes hidden during playback, Listener explicitly pauses +the introduction and Beacon, reports quota presence idle and releases the +hls.js pipeline. When visible again it verifies the same lease, rejoins at the +configured live position and reports listening only after playback succeeds. +Background time is therefore neither audible nor charged. This is a product +policy, not a browser best-effort optimization. + +The browser emits `listener:playback-diagnostic` for reservoir readiness, a +fatal HLS signal, refill recovery and media-clock recovery. The fixed payload +records retained/playable seconds, recovery action, retry count, media state, +range summaries, lease generation/sequence, bounded HLS error enums and +visibility. It never contains account/email, lease ID, IP, cookie/token/header, +user agent, signed URL or output-device fingerprint. + +## Deployment invariant + +`scripts/early-birds-preview/start.sh` migrates/recreates only Listener. +`rollback.sh` stops only Listener. Neither ordinary command may target the +long-lived origin. Origin maintenance uses the explicit `start-origin.sh` or +`ops/early-birds/scripts/stop-stream.sh` lane, with an announced window and a +decoded-audio canary. None of these commands targets the event project. + +## Acceptance + +For a release candidate, verify Beacon-only and intro handoff on Chrome, +Firefox, Android Chrome and iPhone Safari. Include foreground, one +background/foreground cycle and speakers/headphones when available. Perform a +60-minute physical listen on at least one representative mobile device. + +For deterministic recovery evidence in staging, acquire a media grant and then +interrupt only the Listener control plane while the independent origin remains +healthy. Manifest and segment requests using the already-issued URL must keep +returning 200 without any Listener/database callback until the exact registered +lease expiry; the next request must return 403. Restore Listener before expiry +for a physical playback drill. The stable URL must survive heartbeat renewal, +with one audible source and one quota presence interval. Do not restart the +origin to simulate a control-plane failure. + +This is bounded continuity, not unrestricted media access. Heartbeats renew +once per minute; each successful renewal grants at most three further minutes, +also capped by remaining quota. With the approximately three-minute playback +buffer, a failure immediately after renewal can preserve roughly six minutes +of user-perceived audio. Stop/revoke may likewise take at most the outstanding +grant horizon to drain at origin; quota settlement remains capped by the same +lease expiry. + +## Reliability tiers still required + +The five-minute origin window protects against ordinary last-mile jitter; it +does not make a single host highly available. Public-release reliability also +requires independently reviewable delivery work: + +1. publish the identical encoded timeline from at least two failure domains + and provide tested client/playlist failover without overlapping audio; +2. run synthetic audio canaries from North America, Europe and Latin America, + and retain low-cardinality, non-PII browser recovery causes; +3. define and gate on interruption-free session rate, rebuffer ratio, join + success and recovery time, including multi-hour network and origin-failure + drills. + +Until those tiers exist, do not describe the service as highly available solely +because the origin and local canary are healthy. diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md new file mode 100644 index 00000000..8ad4935f --- /dev/null +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -0,0 +1,138 @@ +# Listener launch — current state + +Last reconciled: 2026-08-16 + +This is the compact operational memory for Founding Listeners. Detailed evidence +and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and +`FOUNDING_LISTENER_RELEASE_CANDIDATE.md`. GitHub issue #315 is the live checklist. + +## Exact deployed state + +- Public candidate: `https://listen.harmonicbeacon.com/` +- Listener image/SHA: `5d1073f598272d81a14a64d55a4220c2c13e9a74` +- Previous contract-compatible Listener application image: `acc90ba35fea52f63ef18337e3a555ef637c552f` +- Withdrawal operator sidecar image/SHA: `0a475717d45d32cec38afdb8fc35fb772a994017` +- Canonical payment authority: `4e5b208e902969285c8f68067f7fd13b7e2eb68d` +- Minimum authority after any new Live checkout attempt: `4e5b208e902969285c8f68067f7fd13b7e2eb68d` +- Listener mail sidecar: `456ece2b38e203a2d12c54864115e03ebaa1a89c` +- Weekly Free: three hours per server-owned seven-day cycle +- Founding Listener: USD 5/month while service remains uninterrupted +- Free For All: OFF +- PayPal Live checkout: OFF +- Mercado Pago Live checkout: OFF +- Private staging Live workbench: OFF; staging and canonical workbench routes return 404 +- PayPal Live lifecycle/read-only reconciliation: ready with new sales OFF and no outstanding intent +- Mercado Pago Live provider: OFF +- Mercado Pago TEST lifecycle: ready; global new sales OFF +- Public consumer withdrawal/service cancellation: ON; no login, immediate opaque receipt +- Fixed Listener/origin container observer: ON, epoch `1786790963`, restart/OOM counters `0` +- External media smoke: 10 clients / 60 seconds passed from `daimonmatrix`; no larger capacity claim +- Mobile consumer actions: in document flow after Listener content; desktop remains fixed +- Public sales: OFF; only the explicitly supervised Live lifecycle is authorized + +The authority now includes the reviewed adverse-event hardening, typed recovery +for missing PayPal approvals and a read-only Live-provider preflight. The +deployed API/worker are healthy at exact revision `4e5b208`; Alembic is at +`7b4c1e9a2d60`. Productive credentials are installed +root-only. With new sales forced OFF, PayPal verified its exact Live product, +USD 5 plan and webhook event set; Mercado Pago verified its productive MLA +merchant and webhook configuration. Neither preflight creates checkout, +subscription, binding or payment. The one abandoned PayPal Live approval later +returned canonical provider 404 and was retired with the bounded application +operator: no charge, provider subscription, Founder continuity or Purchase was +created, the old approval cannot replay and no outstanding PayPal binding +remains. Global new sales and both public Listener checkout flags are OFF. +PayPal Live lifecycle ingestion remains ready so signed webhooks, +reconciliation and cancellation stay available. Mercado Pago remains on TEST +with Live OFF. + +PayPal Sandbox has passed activation, pending cancellation, reactivation and +terminal-event handling. Mercado Pago TEST has passed checkout, activation, +pause, reactivation and reconciliation. Browser redirects never grant +membership. Normal cancellation only disables future renewals: it preserves +the already-paid service period and never issues a refund. Refunds are manual, +exceptional provider operations, not a launch rehearsal or self-service flow. + +| Provider | Non-production lifecycle | Productive state | +| --- | --- | --- | +| PayPal | Sandbox activation, pending cancellation, reactivation and terminal-event ingestion accepted | Live activation, cancellation and reactivation accepted; cancellation pending end preserves the paid period and no refund was issued | +| Mercado Pago | TEST checkout, activation, pause, reactivation and reconciliation accepted | Live merchant/webhook read-only preflight verified; lifecycle and real charge not yet rehearsed | + +“Private rehearsal completed” currently means Sandbox/TEST only. A productive +Live rehearsal remains a separate, explicitly approved real-money operation. + +The first bounded external media-plane smoke passed on 2026-08-15: ten clients +from `daimonmatrix`, 348/348 successful requests, no fetch/window/scheduling +misses, no Listener/origin restart or OOM, no alert and clean five-minute +baseline and recovery windows. This is evidence for ten clients only; it does +not validate 3,000/4,000/5,000 listeners or authorize a larger run. + +The dedicated magic-link API, worker and PostgreSQL queue are isolated from the +event runtime and have no host ports. A controlled Gmail delivery reached +`SENT`; the real-browser email-only callback, Free entry and logout passed. + +Google OAuth rotation #328 is complete. Canonical login → logout → re-login and +the staging callback passed on the replacement client. The previous client was +revoked after acceptance; its secret must never be restored from an old env +backup. Only Listener and the disposable staging workbench were recreated. + +## Remaining blockers to public sales + +1. #304 — complete a physical 60-minute listen and record any watchdog recovery. +2. #317 — final mobile/account-menu billing acceptance. +3. #318 — record human ES/EN offer/legal/seller/manual-exception refund/support and invoicing + acceptance. The public no-login withdrawal and service-cancellation paths, + dedicated secret, migration, private operator, metrics and 20h/24h alerts + are deployed and smoke-tested. +4. Execute the corresponding supervised Mercado Pago Live activation, + cancellation and reactivation lifecycle with a non-merchant buyer. Do not + create a real refund as an acceptance test. +5. Confirm Founder activation, paid-through cancellation, metrics, alerts and + the absence of PII/secret leakage against those Live transactions. Terminal + refund/dispute handling remains covered synthetically and by provider-event + reconciliation; an exceptional real refund is handled manually if one ever + occurs. +6. Obtain separate explicit approvals for merge to `main` and public checkout. + +## Non-negotiable isolation + +Do not deploy, restart or reconfigure event services, Ticket Tailor, LiveKit, +playlist-bot, tapestry, event audio, Proyección del Mito experience or +`live.harmonicbeacon.com`. Do not change approved Listener audio. Do not enable +Live providers, charge real money, merge to `main` or open public checkout +without explicit approval. + +## Immediate rollback + +- Commerce incident: switch OFF Listener checkout flags and authority new-sales; + keep webhooks, reconciliation, cancellation and existing access running. +- Authority application regression after any new Live checkout attempt: keep the current database, + keep the affected provider's Live lifecycle flag ON, keep new sales OFF and roll forward with + `4e5b208` or a newer contract-compatible authority. Never deploy `b1038ddb` or `8e10f16` after a + new approval has been created: `b1038ddb` predates safe provider-404 retirement and `8e10f16` + predates adverse-webhook hardening. Never use a protected pre-cutover backup as a routine rollback: it can lose + canonical checkout/lifecycle evidence and exists only for explicitly commanded disaster + recovery followed by complete provider reconciliation. +- Listener application regression: roll back only the isolated Listener to + `acc90ba3` if contract-compatible; keep the `0a475717` withdrawal operator and + current database running so already-received legal requests remain processable. + Set `LISTENER_WITHDRAWAL_ENABLED=0` to hide the public request routes during + application recovery, then roll forward. +- Weekly quota is forward-only. Never restore the retired daily-window/welcome + authority. +- Magic delivery incident: clear the three protected magic-link values and + recreate only Listener; do not restart the event runtime. +- Google OAuth incident: keep the replacement client and roll forward with a + new secret. The revoked client remains provider-restorable for 30 days only + for controlled recovery; never restore its exposed secret from backups. + +## Public-document truth + +The broad Phase 2 patronage/provider-economy documents are future strategy, not +the implementation authority for Founding Listeners. Public and repository copy +must not claim that Harmonic Beacon has no payment or email processing: the +Sandbox/TEST subscription lanes and Gmail magic-link delivery are already real +pre-release processors. Equally, copy must not claim public Live billing is +active: productive credentials are installed and verified, PayPal Live +lifecycle/reconciliation is ready with no outstanding intent, and authority new +sales, real charges and both public checkout flags remain OFF. diff --git a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md new file mode 100644 index 00000000..2f7a5bfd --- /dev/null +++ b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md @@ -0,0 +1,156 @@ +# Private Listener Live checkout workbench + +Status: implemented and deployed on the isolated staging runtime; gate OFF when +no supervised rehearsal is active. + +This workbench exists only for one supervised real-provider acceptance on +`earlybirds-staging.harmonicbeacon.com`. It does not open checkout on +`listen.harmonicbeacon.com`, does not replace the ordinary staging Sandbox/TEST checkout and does +not touch event, LiveKit, playlist-bot, tapestry or audio services. + +## Current dormant state — 2026-08-15 + +- Exact staging workbench image: `acc90ba35fea52f63ef18337e3a555ef637c552f`. +- Effective workbench gate: `0`; both public Live checkout flags: `0`. +- Authority global new sales: disabled. +- The former abandoned PayPal approval was retired after official provider 404 + evidence without a charge, subscription, Founder state or Purchase. There is + no outstanding PayPal Live binding. +- Staging and canonical workbench POSTs both return `404`; staging home, + health/readiness and canonical Listener remain healthy. +- The root-owned account/provider/CSRF configuration is retained at mode `0600` + so a separately approved rehearsal can be started without copying secrets. +- Recreating this disposable port-13001 container did not restart the + persistent Listener, event app, LiveKit, event workers or audio origin. + +The dormant preflight was reconfirmed after the first external media smoke: + +- `pmp-myth-listener-live-preflight --provider all` returned `verified` for + the PayPal catalog/webhook and Mercado Pago merchant/webhook with + `new_sales=disabled`; +- authority API/worker, public Listener and the staging workbench were healthy + with restart count `0` at their exact documented images; +- same-origin, correctly shaped public PayPal and Mercado Pago checkout + requests returned `404`; canonical and staging Live-workbench requests also + returned `404`; +- Prometheus and Alertmanager had zero active alerts. This read-only evidence + created no checkout, approval, subscription or charge. + +This dormant state is the required baseline before selecting either provider. +Do not turn the gate or authority new sales on merely to test route reachability. + +Recheck the public/staging/event HTTP boundary from an external operator host: + +```bash +node scripts/early-birds-preview/listener-live-dormant-check.mjs +``` + +Require `status=PASS`. The fixed verifier uses no cookie, authorization header, +account or provider secret. It checks health/legal surfaces and sends only +anonymous, correctly shaped requests that can never pass session validation; +both productive provider routes and both Live-workbench hosts must return +`404`, and the event vhost must expose neither route. Pair this external check +with the authority's read-only `pmp-myth-listener-live-preflight --provider all`; +neither command creates checkout state. + +## Boundary + +- Exact browser endpoint: `POST /api/listener/checkout/live-workbench` on the staging host only. +- The endpoint is absent from the public Listener nginx vhost. A direct application request with + the public or event Host returns `404` before authentication or authority access. +- The browser sends a random attempt UUID and a short-lived session-bound CSRF proof. For Mercado + Pago only, it also sends the payer email explicitly entered for that provider; this may differ + from the Listener sign-in email. Account, provider, price, environment and callbacks remain + server-derived. PayPal receives no Listener or client-supplied payer email. +- One root-owned configuration selects exactly one opaque Listener account and one provider. +- Enabling either public Listener Live flag makes the workbench fail readiness and disappear. +- Normal `POST /api/listener/checkout` on staging continues using only PayPal Sandbox or Mercado + Pago TEST according to its existing independent flags. + +## Root-owned configuration + +Keep values outside Git, shell history, process arguments and logs. Install the effective runtime +file as `root:root`, mode `0600`. Generate the CSRF secret from at least 32 random bytes; never reuse +an OAuth, authority or provider secret. + +```text +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED=0 +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID= +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER= +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET= + +BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=0 +BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED=0 +``` + +The provider value is singular; comma-separated lists, `all`, whitespace, partial configuration +and mixed public/workbench activation all fail closed. The provider credentials remain only in the +canonical authority runtime; they are never copied into Listener configuration. + +## Supervised acceptance sequence + +1. Record exact Listener/authority images, health, readiness and the authority database backup. + Reconcile pending provider intents for the selected account first; never create a second Live + attempt while an earlier approval intent remains usable or unresolved. +2. Confirm both public Listener Live flags are `0`. Confirm public + `POST /api/listener/checkout` still returns `404` while its Live flags are OFF. +3. Select one controlled opaque account and one provider in the root-owned workbench file. Keep the + workbench gate `0` while validating ownership, mode and configuration names. +4. In the canonical authority, enable only the selected Live provider and its bounded new-sales + gate. The other Live provider must be OFF. Signed webhook/reconciliation lifecycle stays active + after new sales is closed. +5. Build the reviewed Listener commit as the exact local image + `harmonic-beacon/earlybirds-preview-listener:` with `BEACON_GIT_SHA=`. Install the + four workbench values in `/etc/harmonic-beacon/listener-live-workbench.env` as `root:root` mode + `0600`; that fixed file may contain no other variables. Start only the disposable loopback + workbench on port `13001`: + + ```bash + LISTENER_UI_PREVIEW_FREE_FOR_ALL=0 \ + LISTENER_UI_PREVIEW_LIVE_WORKBENCH_ENABLED=1 \ + LISTENER_UI_PREVIEW_EXPECTED_SHA= \ + scripts/listener-ui-preview.sh start + ``` + + The launcher refuses an absent/mismatched image revision, dev mode, FFA, Sandbox/TEST checkout, + a non-root or non-`0600` secret file, ambiguous keys and every port except + `127.0.0.1:13001`. It forces both auth-base aliases to staging and requires `/api/health` plus + `/api/health/ready` before returning. It never recreates the persistent Listener on `13000`. + Install the reviewed staging nginx template only after `nginx -t` is green; do not change any + event vhost. +6. Sign in on the exact staging hostname as the allowlisted Listener account. The private Live card + appears only for that session. Verify provider, USD 5/approved ARS offer and seller before the + human confirms payment. +7. As soon as the provider approval URL has been created, turn authority new sales OFF. Keep the + selected provider lifecycle/webhook/reconciliation flag ON until activation, cancellation and + reactivation are canonical and reconciled. Do not issue a refund as part of rehearsal; + exceptional refunds remain manual provider operations whose signed events are still ingested. +8. Verify canonical Founder projection, profile badge, unlimited access, provider event, metrics, + alerts and logs without copying approval URLs, provider IDs, PII or secrets into public records. +9. Set the workbench gate back to `0`, recreate only staging Listener, and verify its exact route is + `404`. Leave both public Listener Live flags OFF until the separate public-sales approval. + +## Request checks + +The application requires all of the following before contacting the authority: + +- exact staging Host and HTTPS forwarded protocol; +- exact same-origin `Origin`; +- browser Fetch Metadata for a same-origin CORS fetch with empty destination; +- JSON content type and a bounded body; +- a valid Listener session for the one allowlisted account; +- a 15-minute HMAC CSRF proof bound to that account, session and server-selected provider; +- a body containing only one valid `attemptId`. + +Changing provider/account in the body, replaying a proof in another session, using an expired proof, +using public/event hosts, or enabling a public Live flag all fail before checkout creation. + +## Stop and recovery + +- First set `BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED=0` and authority new sales OFF. +- Recreate only the isolated staging Listener. Keep provider webhooks, reconciliation and existing + membership lifecycle running. +- If a Listener application fault remains, stop the staging workbench or roll forward. Do not roll + back the canonical authority across a provider binding or adverse-event schema boundary. +- Never delete provider bindings, webhook events, jobs or membership projections as rollback. +- Verify normal staging Sandbox/TEST checkout and public Listener `404` independently after closure. diff --git a/docs/operations/LISTENER_REGIONAL_PRESENCE.md b/docs/operations/LISTENER_REGIONAL_PRESENCE.md new file mode 100644 index 00000000..0f1173f0 --- /dev/null +++ b/docs/operations/LISTENER_REGIONAL_PRESENCE.md @@ -0,0 +1,62 @@ +# Listener regional presence + +Founding Listener exposes a deliberately imprecise public presence signal at +`GET /api/listener/presence`. The response contains fixed macro-regions and one +of five qualitative bands (`none`, `trace`, `cluster`, `field`, `radiant`). It +never contains exact counts, identities, countries, cities, coordinates, IP +addresses, account IDs, or device IDs. + +## Runtime configuration + +The reviewed data source is DB-IP Country Lite July 2026 in MMDB format, +distributed under CC BY 4.0. Install it with +`scripts/early-birds-preview/install-geoip-country.sh`, keep it on the +secondary data volume, and mount it read-only into the container. The public +response includes the required DB-IP attribution link. Do not commit the +database to Git or download it during a request. A missing, unreadable, or +unmatched database maps the request to `UNKNOWN` and never blocks listening. + +The application derives the client address only after the configured trusted +proxy chain. Keep `TRUSTED_PROXY_HOPS` aligned with nginx/the edge topology. +Caller-supplied forwarding prefixes are ignored. Do not expose the app port +directly to the Internet. + +## Semantics + +- A prepared lease is `IDLE`. +- An audible introduction and the Beacon are both `LISTENING`. +- Pause, Stop, terminal playback failure, logout/page close, and lease + displacement report `IDLE` immediately on a best-effort basis. +- Ordinary heartbeats reconcile the state every minute; expired or abruptly + disconnected leases disappear after the existing short lease TTL. +- Two devices belonging to one signed-in account count once. +- Free-for-All devices use their ephemeral HMAC device digest as the private + grouping key because they intentionally share one technical account. + +Only the macro-region and ephemeral playback timestamps are persisted. The +source address is used for the local lookup and discarded. + +## Public cache and failure behavior + +Successful responses are cacheable for five seconds with a short stale window. +If PostgreSQL briefly fails, the process serves only its last known public +bands. With no last-known snapshot it returns `503` instead of inventing an +empty crowd. Exact counts remain available only to private operational metrics. + +## Deploy check + +1. Apply the forward-only Prisma migration. +2. Mount the Country MMDB read-only and set `BEACON_LISTENER_GEOIP_DB_PATH`. +3. Start one synthetic listener from two devices and confirm it produces one + qualitative presence unit. +4. Confirm a Free-for-All synthetic device appears independently. +5. Stop playback and confirm the region returns to its previous band without + waiting for lease expiry. +6. Send a forged left-most `X-Forwarded-For` entry through nginx and verify it + cannot select a region. +7. Remove the MMDB mount in staging and verify listening stays healthy while + presence falls back to `UNKNOWN`. + +Rollback is operational: deploy the previous app image. The additive columns, +enums, and index can remain in place safely; do not reverse the migration during +an incident. diff --git a/docs/operations/LISTENER_UI_FAST_LOOP.md b/docs/operations/LISTENER_UI_FAST_LOOP.md new file mode 100644 index 00000000..2bc5a14f --- /dev/null +++ b/docs/operations/LISTENER_UI_FAST_LOOP.md @@ -0,0 +1,95 @@ +# Listener UI fast loop + +`earlybirds-staging.harmonicbeacon.com` is the disposable Listener UI +workbench. `listen.harmonicbeacon.com` remains the persistent acceptance +release. + +The workbench runs Next development mode on Mona port `13001`, reads source +mirrored onto `/mnt/beacon-data`, and uses hot reload. It deliberately enables +Free For All only inside that disposable process so layout and transport can be +reviewed without account setup. The persistent Listener process on `13000`, its +Free For All state, and every event service remain unchanged. + +Start or replace the workbench from the `early-birds` worktree: + +```bash +scripts/listener-ui-preview.sh start +``` + +For the isolated PayPal sandbox checkout rehearsal, require an account instead +of Free For All and expose only the PayPal sandbox action in this disposable +process: + +```bash +LISTENER_UI_PREVIEW_FREE_FOR_ALL=0 \ +LISTENER_UI_PREVIEW_PAYPAL_SANDBOX_CHECKOUT_ENABLED=1 \ +scripts/listener-ui-preview.sh start +``` + +The script refuses the ambiguous combination of checkout plus Free For All. +Because synthetic login is intentionally disabled under `NODE_ENV=development`, +this payment rehearsal runs the persistent Listener's exact built image in a +separate production-mode container on the staging port. Ordinary UI iteration +continues to use Next development mode. Neither path changes the persistent +Listener release or event services. + +The payment workbench overrides both accepted Listener auth-base aliases to +`https://earlybirds-staging.harmonicbeacon.com`. OAuth state and session cookies +are host-only, so login must begin and finish on staging. Inheriting the public +Listener auth base would redirect Google to `listen.harmonicbeacon.com`, where +the staging state cookie is deliberately unavailable and the callback fails +closed with `state_mismatch`. The Google OAuth application must therefore keep +the exact staging callback registered alongside the public Listener callback. + +For the equivalent isolated Mercado Pago TEST rehearsal, select only Mercado +Pago and keep Free For All disabled: + +```bash +LISTENER_UI_PREVIEW_FREE_FOR_ALL=0 \ +LISTENER_UI_PREVIEW_MERCADO_PAGO_TEST_CHECKOUT_ENABLED=1 \ +scripts/listener-ui-preview.sh start +``` + +The workbench rejects enabling PayPal and Mercado Pago together so acceptance +evidence always identifies one provider unambiguously. + +Keep local edits synchronized while iterating: + +```bash +scripts/listener-ui-preview.sh watch +``` + +To compare an immutable intro artifact without changing the public +Listener or overwriting another artifact, restart only the disposable workbench +with its container path: + +```bash +LISTENER_UI_PREVIEW_DROPIN_EN_PATH=/media/artifacts/drop-ins/amara-sol-en-r2-approved-aac320-v1.m4a \ +scripts/listener-ui-preview.sh start + +LISTENER_UI_PREVIEW_DROPIN_ES_PATH=/media/artifacts/drop-ins/amara-sol-es-r2-approved-aac320-v1.m4a \ +scripts/listener-ui-preview.sh start +``` + +The override accepts only a bounded `.m4a` filename inside the read-only +drop-in artifact directory. The public Listener keeps its independently pinned +path until a revision has explicit human approval and completes the release +checkpoint. + +Other operations: + +```bash +scripts/listener-ui-preview.sh status +scripts/listener-ui-preview.sh logs +scripts/listener-ui-preview.sh stop +``` + +The fast loop is intentionally disposable. Do not call a visual iteration a +release. Once the team accepts a coherent batch, stop the watch loop and run +one normal checkpoint: focused tests, TypeScript/lint/build, commit, exact-image +Listener deployment, health/readiness and physical playback smoke. + +The workbench is not a compatibility target and carries no migration or +rollback promise. Dependency, schema, infrastructure, authentication or audio +signal changes do not belong in this loop; they use the normal isolated +release path. diff --git a/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md b/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md new file mode 100644 index 00000000..7ee0c670 --- /dev/null +++ b/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md @@ -0,0 +1,135 @@ +# Listener consumer-withdrawal requests + +This is the bounded operator flow for the public **BOTÓN DE +ARREPENTIMIENTO** and **BOTÓN DE BAJA DE SERVICIO**. It receives and tracks a request; it never calls PayPal, +Mercado Pago or the membership authority and it never cancels or refunds by +itself. The ordinary signed-in membership action is separate: it automatically +stops future renewals, preserves the already-paid period and never refunds it. +Refund requests are exceptional cases that require manual provider review. + +## Runtime boundary + +- Apply migration `20260813190000_listener_withdrawal_request` before exposing + the route. +- Generate a dedicated random value of at least 32 bytes for + `LISTENER_WITHDRAWAL_SECRET`. Install it only in the isolated Listener env, + owned by root and mode `0600`. Do not reuse OAuth, auth, payment, mail or event + secrets. +- Leave `LISTENER_WITHDRAWAL_ENABLED=0` while migrating, installing the secret, + systemd timers and alerts. The two pages, links and API all behave as absent + (`404`) unless the flag is exactly `1` **and** the secret is valid. Readiness + fails when the flag is on without the secret or either additive table. + Public paid + checkout stays independently OFF until the complete launch gate is accepted. +- The table contains the minimum contact data needed to find the transaction: + email, provider and optional approximate date. It + stores only a digest of the public receipt and HMAC-keyed network/email + throttles plus one fixed global bucket; + no raw IP, card data or provider transaction ID is accepted. + +Both mechanisms are public without login or registration. Identity/security +verification, when necessary, occurs during operator processing and must remain +reasonable and habitual; it must never become a registration prerequisite. +The receipt code is returned immediately. Operators must process the request +and take the corresponding measures within 24 hours. + +Official sources reviewed for this MVP: + +- [Disposición 954/2025](https://www.argentina.gob.ar/normativa/nacional/disposici%C3%B3n-954-2025-417152/texto), especially arts. 1–5; +- [Disposición 3/2026](https://www.argentina.gob.ar/normativa/nacional/disposici%C3%B3n-3-2026-423007/texto), complementary identity/security verification rules. + +## Queue procedure (within 24 hours) + +Run the CLI only from a root-owned shell with the Listener `DATABASE_URL` in a +root-only environment. Terminal capture/history must be treated as private +because `show` reveals the contact email. + +```bash +npx tsx scripts/listener-withdrawal-operator.ts list 50 +npx tsx scripts/listener-withdrawal-operator.ts show REQUEST_UUID +npx tsx scripts/listener-withdrawal-operator.ts acknowledge REQUEST_UUID operator-code +``` + +Then, outside this application: + +1. inspect `requestKind`, then correlate the email/provider/date against the canonical provider and + membership authority; +2. contact the requester when evidence is insufficient; +3. perform the authorized provider cancellation, or an exceptional manual + refund when independently justified, using the provider's audited procedure; +4. confirm canonical membership convergence; +5. record only the bounded result in this queue: + +```bash +npx tsx scripts/listener-withdrawal-operator.ts resolve REQUEST_UUID operator-code CANCELLED +``` + +Allowed terminal codes are `CANCELLED`, `REFUNDED`, +`CANCELLED_AND_REFUNDED`, `DUPLICATE` and `NOT_APPLICABLE`. The CLI requires an +acknowledged request, uses compare-and-set transitions and is idempotent for an +already-acknowledged row. It intentionally has no public read/status endpoint. + +The operational alert should count `RECEIVED` requests older than 20 hours as +warning and any non-resolved request older than 24 hours as critical. Metrics +contain only counts, oldest age and export freshness; never email, receipt, +provider IDs or request IDs. + +## Private metrics and maintenance + +Create `/etc/harmonic-beacon/listener-withdrawal-ops.env` root-owned, mode +`0600`, containing only +`LISTENER_WITHDRAWAL_CONTAINER=earlybirds-preview-withdrawal-operator-1` +(or the reviewed replacement container name). The operator inherits the +container's private `DATABASE_URL`; do not duplicate it on the host. Install +the two wrappers into `/usr/local/libexec/harmonic-beacon/`, root-owned mode +`0755`. Install the four reviewed units from `ops/early-birds/systemd/` into +`/etc/systemd/system/`, then: + +```bash +systemd-analyze verify /etc/systemd/system/harmonic-beacon-listener-withdrawal-*.{service,timer} +systemctl daemon-reload +systemctl enable --now harmonic-beacon-listener-withdrawal-metrics.timer +systemctl enable --now harmonic-beacon-listener-withdrawal-prune.timer +systemctl start harmonic-beacon-listener-withdrawal-metrics.service +``` + +The five-minute job writes +`/var/lib/harmonic-beacon/metrics/listener-withdrawal.prom` atomically for the +already-private node-exporter textfile collector. The daily job prunes only +expired HMAC throttle buckets; request/audit rows are never removed. Alerts +cover queue age at 20/24 hours and missing/stale exports at 10/20 minutes; +Alertmanager's existing `send_resolved: true` emits recovery. Neither endpoint +nor metrics path is routed through nginx. + +`withdrawal-operator` is a private, no-port, database-only sidecar pinned by +the explicit `EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG`. Before enabling the +feature, build or pull an exact immutable release containing the operator +scripts, set that sha40 tag and the identical, independent +`EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA` in the root-owned preview env, and +verify. Do not derive either from the app rollback SHA: + +```bash +docker compose --env-file /root-owned/preview.env -f ops/early-birds-preview/compose.yml config withdrawal-operator +operator_tag=$(sed -n 's/^EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG=//p' /root-owned/preview.env | tail -n1) +test "$(docker image inspect "harmonic-beacon/earlybirds-preview-listener:$operator_tag" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^BEACON_GIT_SHA=//p')" = "$operator_tag" +docker exec earlybirds-preview-withdrawal-operator-1 test -r scripts/listener-withdrawal-operator.ts +docker exec earlybirds-preview-withdrawal-operator-1 test -r src/lib/listener/consumer-withdrawal.ts +docker inspect earlybirds-preview-withdrawal-operator-1 --format '{{.State.Health.Status}}' +``` + +An app rollback must update or recreate only `listener`. Do not downgrade, +recreate or remove `withdrawal-operator`: keep it pinned at this release or a +newer reviewed release until every durable request is resolved. The sidecar +starts only after the forward-only migration and has no egress/public network. + +Before switching on, confirm `node_textfile_scrape_error == 0`, the freshness +metric advances twice, the queue alerts have no pending/firing state, and a +direct public request to `/api/internal/` remains `404`. + +## Rollback + +Set `LISTENER_WITHDRAWAL_ENABLED=0` to hide both links/routes/API, or deploy the +previous Listener image without touching `withdrawal-operator`. Keep the +additive tables: dropping them would destroy open consumer requests. The queue +can continue to be processed with this commit's root-only CLI. No event or +payment-provider rollback is involved. diff --git a/docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md b/docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md new file mode 100644 index 00000000..7a0e4aab --- /dev/null +++ b/docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md @@ -0,0 +1,192 @@ +# EarlyBirds external HLS load and soak + +## Purpose and boundary + +This procedure produces reproducible HTTP capacity evidence for the isolated +EarlyBird origin/CDN media plane. All traffic generators run on independent external hosts; +the event/production VPS `mona` is only observed as a target and must never +generate significant load. The procedure is forbidden against live production. + +It does **not** load the application manifest proxy used by a real Listener. +That route authenticates a unique session, reads membership/device-lease state +from PostgreSQL, signs and fetches the origin playlist, then validates/proxies +it. This harness shares one rotating direct media-playlist URL and one generator +HTTP pool, so it cannot establish app, database, lease, TLS/socket-per-browser +or end-to-end Listener capacity. Before setting a customer limit, run a separate +approved staging stage with unique synthetic accounts, sessions, leases and +cookies; it must retain the no-event-capability boundary. + +The harness models one media-plane client as deterministic UTC manifest polls followed by +the newly visible media-sequence segment requests. Client activation is derived +from the global client ordinal and shared UTC start. Shard `i` owns global +ordinals `i, i + shardCount, ...`, so a distributed run has the same ramp and +request plan regardless of where its shards execute. + +The tool reads only HLS control data: media sequence, `EXTINF`, URI, optional +initialization map/byte range and declared gap. Segment bodies are opaque bytes. +Master playlists, encrypted media and LL-HLS parts fail closed so they cannot be +silently undercounted. There is no codec, bitrate, rate, +channel, gain, decoder or audio-content assumption. The result cannot replace +the decoded canary or physical-device listening gate. + +## Prerequisites + +1. Use a staging deployment whose exact SHA and non-production status are + recorded. Configure the attestation response header only on that staging + vhost; production must never emit the staging value. Do not point a policy + at the live Listener or a production alias. +2. Provision independent external generator VPSs. On each, verify `hostname` + is not `mona`/`mona-*`/`mona.*`, confirm chrony/NTP is synchronized, record + the measured UTC offset in milliseconds, and set: + + ```bash + export EARLY_BIRDS_GENERATOR_ROLE=external-load-generator + ``` + +3. Create `/secure/early-birds-hls-targets.json` from the example. It contains + no secret, but must list every exact origin that can appear in the media + playlist (staging origin and staging CDN, if any). Set target limits to the + specific approved rehearsal, not automatically to global hard limits. +4. Put the current short-lived signed media-playlist URL in a local `0600` file + on each generator. Never pass it on the command line. An approved lightweight + control-plane process may replace this file atomically before expiry; the + harness observes an atomic replacement within one second. The signing key remains on the + staging control plane. Mona may mint a URL but must not run a load shard. +5. Confirm target monitoring: network egress/retransmits/errors, origin CPU and + memory, manifest/segment latency and status, and external canary continuity. + Assign one operator who can stop every generator and one who watches the + target. + +## Safety gates + +The implementation refuses a network run unless all of these hold: + +- target policy schema is valid, `production` is exactly `false`, and + environment is `staging` or loopback-only `synthetic`; +- staging origins are exact HTTPS origins with no credentials, path, query, + fragment or wildcard, and every response carries the exact staging-only + environment attestation header/value from the policy; +- the manifest and every segment remain inside those origins; redirects are + not followed; +- profile is below both global hard bounds and the narrower target policy for + clients, ramp, soak, poll cadence, concurrency, segments/poll, request + starts/second and response body bytes; a per-shard limiter enforces the RPS + ceiling during the run; +- whole-run error/miss tolerances are at most 10%, rolling-window thresholds at + most 20% and circuit-breaker thresholds at most 25%; evidence aggregation + recomputes these gates from the hashed plan rather than trusting stored + thresholds; +- large plans meet minimum shard counts and no shard owns more than 1,000 + clients; +- start is explicit UTC, at least ten seconds and at most six hours ahead; +- the operator supplies the exact confirmation printed by dry-run; it ends in + a `planHash` covering every selected profile field, origin, attestation and + effective target limit; +- the generator declares the external role, passes `--external-generator` and + is not named `mona`; +- the manifest URL file is a regular file inaccessible to group/world; +- the measured UTC clock offset is within the policy bound; +- the evidence path does not exist. + +## Stepwise execution + +Never jump directly to the 3,000/4,000/5,000 planning thresholds. Run the +smallest approved step, evaluate target health, then make a separate go/no-go +decision for the next step. The committed profiles are bounded origin-media definitions, +not authorization to execute them. + +1. Choose a unique safe run ID and one UTC start shared by every shard. Allow at + least 60 seconds to distribute commands for multi-host runs. +2. Run every shard with `--dry-run`. All evidence files must have the same + `planHash`; shard indices must be exactly `0..shardCount-1`. +3. Compare the printed confirmation phrases. They must be byte-identical. +4. Ensure the signed manifest file is current on every external generator. +5. After explicit go, run one command per shard. Example shard 2 of 4: + + ```bash + EARLY_BIRDS_GENERATOR_ROLE=external-load-generator \ + node tools/early-birds-hls-load/run.mjs \ + --policy /secure/early-birds-hls-targets.json \ + --target early-birds-staging \ + --profile origin-media-3000 \ + --run-id eb-staging-capacity-001 \ + --start-at 2026-08-07T16:00:00.000Z \ + --shard-index 2 \ + --shard-count 4 \ + --manifest-url-file /secure/early-birds-current-manifest-url \ + --clock-offset-ms 4.2 \ + --confirm 'EXACT PHRASE FROM DRY-RUN' \ + --external-generator \ + --evidence artifacts/early-birds-hls-load/shard-2.json + ``` + +6. Stop all shards with `SIGINT` if any stop condition fires. Each running shard + writes `ABORTED` evidence when it exits normally from that signal. +7. Copy the redacted shard files to one analysis host and aggregate them: + + ```bash + node tools/early-birds-hls-load/aggregate.mjs \ + --output artifacts/early-birds-hls-load/aggregate.json \ + artifacts/early-birds-hls-load/shard-0.json \ + artifacts/early-birds-hls-load/shard-1.json \ + artifacts/early-birds-hls-load/shard-2.json \ + artifacts/early-birds-hls-load/shard-3.json + ``` + +Aggregation refuses missing/duplicate shards, different run IDs/plan/input +hashes, different actual targets/thresholds, invalid measurement arithmetic or +non-external staging evidence. It records SHA-256 for every source evidence file. + +## Immediate stop conditions + +Stop the run; do not increase capacity when any of these occurs: + +- target health/readiness or external decoded canary fails; +- HTTP 5xx/errors or rebuffer-equivalent fetch misses exceed the approved + profile threshold; +- target CPU, memory, network, retransmit or interface-error alerts fire; +- the generator records scheduling misses (it is no longer producing the + intended load shape); +- a manifest sequence regresses, the playlist window outruns clients or a URL + escapes the allowlist; +- signed URL rotation fails; +- weekend event safety is in doubt. + +Use the lower measured safe limit. An advertised 1/3 Gbit/s NIC rate is not +capacity evidence, and a healthy origin with saturated direct egress is a CDN +expansion signal rather than permission to continue increasing direct load. + +## Evidence semantics + +Each mode-`0600` shard manifest records: + +- hashes of the complete profile/policy, deterministic global plan and local + client ordinal set; +- target ID/environment/origin and a hash of the manifest path; +- a hash of the generator hostname, never the hostname itself; +- planned/started/completed clients and generator schedule misses; +- manifest/segment/total request counts, HTTP status, bounded error categories, + decoded response-body bytes (not packet/wire bytes), queue-inclusive fixed-bucket + p50/p95/p99 latency estimates and queue-delay histograms; +- manifest samples, sequence regressions and playlist-window misses; +- rebuffer-equivalent fetch opportunities/misses and bounded categories; +- ten-second worst eligible error/miss windows, circuit-breaker termination and + the measured generator clock offset. + +“Rebuffer-equivalent fetch miss” is an HTTP/control-plane proxy, not a browser +rebuffer event. It includes unavailable/unparseable manifests, segment HTTP or +timeout/empty/allowlist failures, segment delivery slower than its own +`EXTINF`, playlist-window loss, sequence regression and deliberate backlog +discard caused by the per-poll safety bound. Final release evidence still needs +the browser player, decoded canary and physical listening. + +The harness trips early after a minimum sample count when request errors or +fetch misses exceed conservative circuit thresholds. Final `PASS` also gates +whole-run and worst-window error/miss rates plus manifest/segment p95. External +target alerts remain authoritative stop signals; local circuit breaking does +not replace them. + +Evidence never contains raw URLs, query strings, signed material, cookies, +headers, playlist/segment bodies or raw exception messages. Inspect the target +metrics/logs through their own redaction policy; do not paste signed URLs into +issues or chat. diff --git a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md new file mode 100644 index 00000000..d978c5ad --- /dev/null +++ b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md @@ -0,0 +1,378 @@ +# First external Listener HLS smoke + +**Status: first ten-client external smoke completed successfully on +2026-08-15.** The fixed-target observer is installed on `mona`; the fail-closed +harness, monitor, decoded canary and policy were exercised from `daimonmatrix`. +The result is media-plane evidence for exactly ten clients, not a capacity +claim for larger levels. + +## Executed checkpoint — 2026-08-15 + +- Observer implementation: PR #350 / merge `de192b6`; freshness follow-up + PR #351 / merge `d3c7869`. +- Observer epoch: `1786790963`; final state `up=1`, Listener/origin restart + counters `0`, OOM counters `0`. The observer and its root-only state were + installed without restarting Listener, origin or an event service. +- External generator: `daimonmatrix`, NTP offset `+1.002 ms`; primary load was + never generated from `mona`. +- Clean baseline: 61 samples over five minutes, all `PASS`; private redacted + evidence SHA-256 + `9342edf5b2515104ef41d1c3bf724929298c8e43ec248b63fae73f33047aab02`. +- Network run: `listener-smoke-20260815-h`, ten clients, two starts/second, + sixty-second soak. All ten clients completed; 348/348 requests returned 200, + with zero failures, fetch misses, sequence regressions, playlist-window + misses or scheduling misses. Manifest p95 was at most 25 ms and segment p95 + at most 50 ms. The redacted result SHA-256 is + `5706a16c5b7b4f444e4ef504766c064d4191910581b18f0d1a42cf5a9725d221`. +- Clean recovery: 61 samples over five minutes, all `PASS`; private redacted + evidence SHA-256 + `0fd50d1f3da40e022970257cd267dfa7fdc1c69aeba41d415abd9d4717525c75`. +- Final verification: Listener/origin restart and OOM counters remained zero; + Prometheus and Alertmanager had no active alerts; Listener, origin and live + readiness were green. LiveKit exposed only the fixed `beacon` room with its + one publisher and no event room. Expired signed URLs were removed. +- Only run `-h` issued network requests. Earlier rehearsal plans/refusals made + zero requests and are not capacity evidence. + +This is the only approved first network step for Listener capacity evidence. It +drives exactly ten media-plane clients from one external host for a sixty-second +soak. It is media-plane evidence only: it is not end-to-end Listener capacity +evidence, and it proves nothing about 3,000/4,000/5,000-listener capacity and +does not authorize those profiles. + +The target is the isolated stream origin on `mona`, which still shares the host +and physical interface with `live.harmonicbeacon.com`. Do not run this while an +event is active. Never run a shard, the canary or the monitor from `mona`. + +## Fixed safety boundary + +- Wrapper: `tools/early-birds-hls-load/run-staging-smoke.mjs`. +- Policy: `policies/listener-staging-smoke-10.json`. +- Exact origin: `https://stream.harmonicbeacon.com`. +- Profile: ten clients, two starts/second, sixty-second soak, one shard, at + most 28 request starts/second. +- Conservative media budget: about 4.5 Mbit/s; no capacity claim follows. +- The wrapper refuses a network run without a current signed manifest, a + decoded external canary and a target monitor. It polls both status files every + two seconds and, on the first failing or stale check, sends exactly one + `SIGINT` to the load child, preserving `ABORTED` evidence. + +All signed URLs and status files are exactly mode `0600` regular files (never +symlinks), remain outside Git and must never be passed in a command line, issue +or chat. Status files contain only booleans, bounded numeric telemetry, fixed +thresholds, timing and a hashed host fingerprint — never hostnames, URLs, +labels, raw Prometheus/Alertmanager payloads or secrets. + +The software never claims `GO`. The wrapper, monitor and canary only report +`PASS`/`FAIL`/`ABORTED` evidence; the named human target observer owns the +`GO`/`NO-GO` decision. + +## Roles and terminals + +Use two operators, or one operator with three visible terminals: + +1. **Target observer:** watches `mona`, Prometheus/Alertmanager, the decoded + canary and public Listener/live health. This person owns the go/no-go call. +2. **Generator operator:** runs the target monitor, the decoded canary and the + ten-client wrapper on one NTP-synchronized external host (`daimonmatrix`, + never `mona`) and can interrupt them immediately. + +The monitor, the canary and the wrapper must run co-located on that single +external generator host: the wrapper reads both status files from local disk +and rejects any status whose hashed host fingerprint differs from its own host. +They may not be split across different external hosts. + +Open local-only SSH tunnels for both loopback observability services from the +external host: + +```bash +ssh -N -L 19090:127.0.0.1:9090 -L 19093:127.0.0.1:9093 mona +``` + +or as two separate sessions: + +```bash +ssh -N -L 19090:127.0.0.1:9090 mona # Prometheus, loopback on mona +ssh -N -L 19093:127.0.0.1:9093 mona # Alertmanager, loopback on mona +``` + +Start the external monitor on the generator host. It checks staging readiness, +stream health and unchanged live readiness, queries Alertmanager directly for +readiness and active non-silenced alerts, queries Prometheus separately for +firing rules, evaluates the immediate stop thresholds from direct instant +queries (host CPU, memory, root disk, egress, TCP retransmits, interface +errors/drops, origin up and the deployed decoded canary) and maintains an +in-process restart/OOM baseline for the exact isolated Listener and origin +roles. Its private Prometheus input comes from the reviewed root-owned observer, +not cAdvisor: + +```bash +node tools/early-birds-hls-load/external-target-monitor.mjs \ + --prometheus-url http://127.0.0.1:19090 \ + --alertmanager-url http://127.0.0.1:19093 \ + --status-file /secure/listener-smoke-monitor.json +``` + +Both URLs must be uncredentialed loopback tunnel origins; anything else is +refused. Every Prometheus scalar query must return exactly one finite result — +a missing, duplicated, ambiguous, `NaN` or `Inf` result fails the probe. A +failing or unreachable Prometheus or Alertmanager fails the probe closed; the +monitor never infers Alertmanager health from Prometheus. + +### Restart/OOM preflight blocker + +The restart/OOM baseline requires the fixed +`beacon_listener_container_*` observer series for roles `listener` and +`origin`, plus one fresh observer health and epoch series. Every query must +resolve to exactly one finite sample. Before scheduling load, run the monitor +with `--once` (it probes twice: baseline plus verification) and confirm a +`PASS` status with `restartBaselineEstablished: true`, +`containerObserverFresh: true`, `containerRestartsObserved: 0` and +`oomEventsDelta: 0`. If any observer metric is absent, stale, ambiguous, +non-finite or reports failure, the monitor keeps reporting `FAIL`. The monitor +never silently claims zero restarts. +Because the baseline is +in-process, a restarted monitor reports `FAIL` again until it has re-established +and verified a fresh baseline. An observer epoch change or counter regression +is latched as lost continuity and cannot pass until the monitor itself is +restarted for a new operator-observed five-minute baseline. + +### Root-owned fixed-target observer + +Verified on `mona` (read-only inspection): Prometheus currently exposes **only +the root cgroup** for `container_start_time_seconds` and +`container_oom_events_total`; the exact Listener/origin queries return empty +vectors. cAdvisor logs repeatedly report that it cannot find +`/rootfs/var/lib/docker/image/overlayfs/layerdb/mounts/.../mount-id`. + +An earlier diagnosis blamed missing recursive slave propagation on the +cAdvisor `/:/rootfs` bind. An independent audit **disproved** it: + +- the running cAdvisor container already has `/` -> `/rootfs` with + `Propagation=rslave` and still hits the `mount-id` errors; +- Docker 29.6.2 on `mona` uses the containerd image store + (`driver-type=io.containerd.snapshotter.v1`, `Driver=overlayfs`); +- `/var/lib/docker/image` has no legacy `layerdb`, and + `docker inspect .GraphDriver` is null. + +The actual cause is that the current cAdvisor is incompatible with Docker's +containerd image store for these per-container series. Mount propagation was +never the problem, and the incorrect checked-in rslave change has been +reverted. **Recreating or restarting cAdvisor is not a fix and must never be +treated as one** — with any mount propagation flag it keeps exposing only the +root cgroup for these series. + +The selected implementation is `scripts/listener_container_observer.py` plus +the `harmonic-beacon-listener-container-observer` oneshot/timer units. It is +not a cAdvisor replacement and changes no container. Every five seconds a +root-owned, network-isolated host process performs one fixed `docker inspect` +for exactly the isolated Listener and origin names, verifies their exact +Compose project/service labels, maintains a root-only durable epoch/counter +file and atomically exports fixed-role textfile metrics for node-exporter. + +Threat boundary: + +- access to the Docker socket is root-equivalent, so the observer runs only as + a reviewed root-owned host unit; the socket is never mounted into Listener, + the load generator or another application container; +- the program accepts no arguments, paths, names or labels from callers and + invokes only `/usr/bin/docker inspect` for two compiled-in container names; +- the unit has private networking, `AF_UNIX` only, strict filesystem + protection and write access only to its metrics and state directories; +- exported labels are the fixed allowlist `role="listener|origin"`; container + IDs, hostnames, image names, account data and Docker payloads never enter + Prometheus; +- missing/stopped/wrong-label/duplicated targets, corrupt state, a backwards + counter or any inspect error best-effort exports observer failure and removes + the role series. If the output path itself is unavailable, the last success + becomes stale within thirty seconds. Freshness and exact-cardinality queries + therefore fail closed; +- start time, a cumulative replacement/restart counter and a cumulative OOM + counter are all observed. A fast OOM restart is still detected by start time + and restart count even if the terminal `OOMKilled` flag is no longer set. + +The observer was installed from the exact reviewed release for the checkpoint +above. For a future reinstall or host replacement, review the exact release and +use the same root-owned procedure: + +```bash +install -d -o root -g root -m 0755 /usr/local/libexec/harmonic-beacon +install -d -o root -g root -m 0755 /var/lib/harmonic-beacon/metrics +install -d -o root -g root -m 0700 \ + /var/lib/harmonic-beacon/listener-container-observer +install -o root -g root -m 0755 scripts/listener_container_observer.py \ + /usr/local/libexec/harmonic-beacon/listener_container_observer.py +install -o root -g root -m 0644 \ + ops/early-birds/systemd/harmonic-beacon-listener-container-observer.{service,timer} \ + /etc/systemd/system/ +systemd-analyze verify \ + /etc/systemd/system/harmonic-beacon-listener-container-observer.{service,timer} +systemctl daemon-reload +systemctl start harmonic-beacon-listener-container-observer.service +systemctl enable --now harmonic-beacon-listener-container-observer.timer +``` + +This operation must not restart Docker, cAdvisor, Listener, origin or any event +service. Back up any pre-existing destination files first. Revocation is: + +```bash +systemctl disable --now harmonic-beacon-listener-container-observer.timer +rm -f /var/lib/harmonic-beacon/metrics/listener-container-observer.prom +``` + +Keep the root-only state file for audit unless its removal is separately +approved. Removing it starts a new observer epoch and invalidates any active +monitor baseline. + +Only after the observer is deployed and verified, the operator confirms through +the loopback SSH tunnel that every exact query returns one finite series: + +```bash +for query in \ + 'beacon_listener_container_observer_up' \ + 'time() - beacon_listener_container_observer_last_success_timestamp_seconds' \ + 'beacon_listener_container_observer_epoch_start_time_seconds' \ + 'beacon_listener_container_start_time_seconds{role="listener"}' \ + 'beacon_listener_container_start_time_seconds{role="origin"}' \ + 'beacon_listener_container_restart_events_total{role="listener"}' \ + 'beacon_listener_container_restart_events_total{role="origin"}' \ + 'beacon_listener_container_oom_events_total{role="listener"}' \ + 'beacon_listener_container_oom_events_total{role="origin"}' +do + curl -fsS 'http://127.0.0.1:19090/api/v1/query' --get --data-urlencode "query=$query" +done +``` + +Require observer `up=1` and age between zero and thirty seconds, then run the +monitor `--once` preflight above. The bound covers the five-second observer +timer plus the configured fifteen-second node-exporter scrape alignment; one +missed scrape fails the continuously polled monitor. An empty or duplicate +vector, changed epoch, negative age or counter regression is a hard blocker; +never treat missing series as zero restarts or zero OOM events. + +## Five-minute baseline + +The five-minute baseline is a human/operator requirement observed on the +monitor and dashboards; no software in this slice measures or attests the five +minutes, and a sixty-second result can never demonstrate it. For five +uninterrupted minutes before scheduling load, require: + +- the monitor status to remain `PASS` and refresh at least every fifteen + seconds (which includes: Listener staging readiness, stream health and live + readiness all passing; the deployed decoded canary at `1`; zero firing + Prometheus rules; Alertmanager ready with zero active non-silenced alerts; + no container restart or OOM event); +- CPU below 50%, memory below 70%, root free space above 30%; +- TCP retransmits below 1%, zero interface errors/drops and egress below + 1.5 Gbit/s. + +Any failed sample resets the five-minute baseline. Do not continue by treating +a recovered failure as part of the same clean baseline. + +## Signed manifest and external decoded canary + +After the clean baseline, mint a new origin playlist signature on the staging +control plane using the existing root-only signing secret. Put only the signed +URL in `/secure/listener-smoke-manifest-url` on the external generator, a +regular file at exactly mode `0600` (not a symlink). The URL must be written +less than thirty seconds before the wrapper starts, use the canonical +`/v1/hls//live.m3u8` path and remain valid through the end of the +65-second ramp-plus-soak. + +Run the external canary on the same generator host. It fetches the attested +manifest and uses FFmpeg to decode six seconds; it never prints a URL or +decoder error: + +```bash +node tools/early-birds-hls-load/external-decoded-canary.mjs \ + --manifest-url-file /secure/listener-smoke-manifest-url \ + --status-file /secure/listener-smoke-canary.json +``` + +Require a fresh `PASS`, decoded seconds at least six and manifest age at most +eighteen seconds before starting the load. Keep the process running throughout +the load. Stop it after the load completes; the five-minute recovery continues +to use the target monitor and deployed canary. + +## Dry-run and sixty-second network run + +Choose one run ID and a UTC start far enough ahead to complete the dry-run, +mint/distribute the signed URL and obtain a passing external canary. The network +start must still be within the short signed-URL lifetime. + +```bash +node tools/early-birds-hls-load/run-staging-smoke.mjs \ + --run-id listener-smoke-YYYYMMDD-a \ + --start-at YYYY-MM-DDTHH:MM:SS.000Z \ + --evidence /secure/listener-smoke-plan.json \ + --dry-run +``` + +Copy the exact printed confirmation. Record the numeric UTC offset from +`timedatectl timesync-status`; its absolute value must be at most 100 ms. Use a +new evidence path for the network run: + +The network wrapper serializes runs for the same Unix account through the one +non-configurable host path +`/tmp/harmonic-beacon-listener-smoke-10-network-run.lock`. A present lock — +active, stale or ambiguous — refuses the run before preflight or child spawn. +After verifying that no wrapper is running, an operator may remove a stale +lock before the rehearsal; never remove or replace it while a run is active. +This is local coordination, not cross-host attestation: procedure must still +authorize exactly one generator host. + +```bash +EARLY_BIRDS_GENERATOR_ROLE=external-load-generator \ +node tools/early-birds-hls-load/run-staging-smoke.mjs \ + --run-id listener-smoke-YYYYMMDD-a \ + --start-at YYYY-MM-DDTHH:MM:SS.000Z \ + --evidence /secure/listener-smoke-result.json \ + --manifest-url-file /secure/listener-smoke-manifest-url \ + --canary-status-file /secure/listener-smoke-canary.json \ + --monitor-status-file /secure/listener-smoke-monitor.json \ + --clock-offset-ms MEASURED_OFFSET \ + --confirm 'EXACT DRY-RUN CONFIRMATION' +``` + +## Immediate abort thresholds + +The generator operator sends `SIGINT` immediately when any condition below is +observed. The monitor evaluates the same thresholds on every sample and the +wrapper re-verifies them from the monitor status, so a breached threshold also +aborts the wrapper automatically. Do not wait for an alert's `for` interval: + +- staging readiness, stream health, live readiness or either canary fails; +- any Prometheus rule fires, or Alertmanager shows any active non-silenced + alert or is not ready; +- request errors or rebuffer-equivalent fetch misses exceed 1%; +- manifest p95 exceeds 1 second or segment p95 exceeds 2 seconds; +- any generator scheduling miss, manifest sequence regression, playlist-window + miss, signed-URL failure or allowlist escape; +- host CPU reaches 50%, memory 70%, root free space falls to 30%; +- egress reaches 1.5 Gbit/s, TCP retransmits reach 1%, or the interface reports + any error/drop; +- the isolated Listener or origin container restarts or records an OOM event; +- any event/live degradation or doubt about event safety. + +The wrapper also aborts automatically, exactly once, when its external canary +or target monitor status becomes failing/stale. Automatic abort does not +replace the target observer. + +## Five-minute recovery and decision + +The five-minute recovery is likewise a human/operator requirement. After load +exits, stop the external decoded-canary loop and keep the target monitor +running for five minutes. Require all baseline signals to remain clean, the +deployed decoded canary to remain `1`, zero new container restarts or OOM +events and no delayed Telegram alert. Aggregate and review the mode-`0600` +evidence only after that recovery window. + +The target observer records `GO` only when the shard evidence is `PASS`, all +ten clients complete, scheduling misses are zero, latency/error/fetch gates +pass, and both the human-observed five-minute baseline and five-minute +recovery were clean. Otherwise record `NO-GO`, retain the redacted evidence, +and do not increase load. A `PASS` from the software alone is never a `GO`. + +Before any larger run, add and review intermediate profiles. The required order +starts 10 → 50 → 100 → 250; each step needs its own narrower policy, external +generators with measured ingress capacity, and a separate monitored go/no-go. diff --git a/docs/ops/evidence/2026-08-07-listener-4k5k-dryruns.md b/docs/ops/evidence/2026-08-07-listener-4k5k-dryruns.md new file mode 100644 index 00000000..f2aedf95 --- /dev/null +++ b/docs/ops/evidence/2026-08-07-listener-4k5k-dryruns.md @@ -0,0 +1,57 @@ +# Listener 4,000 / 5,000 zero-request capacity plans + +Date: 2026-08-07 + +These are distributed **planning** artifacts, not load, throughput or customer +capacity evidence. The HLS harness ran only with `--dry-run`; all fourteen shard +files attest `networkRequestsMade=false` and contain zero runtime measurements. +No manifest URL, signing key, cookie, session or network request was used. + +## Inputs and generators + +- branch base: `5ae1e030c43c118b5efa749831ba3f8b75fe9a05`; +- profile document SHA-256: + `06ad500c07d212f7caeccff70a13fad11f3a69730e338cc8f5197408535b7c4e`; +- protected non-production target-policy SHA-256: + `60cc2a02be737d522e60a14b8c8cbb6d174689dba050f5eb8002c89bc97e740f`; +- external generators: `legion` and `daimonmatrix`, both reporting + `NTPSynchronized=yes` at planning time; +- raw and collected evidence files: exact mode `0600`; +- durable archives: + `~/.local/state/harmonic-beacon/listener-load-evidence/20260807-4k5k-zero-request` + on both generators. The consolidated verifier summaries are retained on + `legion` at the same path. + +The target policy contains no credential or signed URL. It is still kept out of +Git because a high-limit policy is an operator input, not authorization to run. + +## Verified plans + +| Profile | Clients | Shards | Generators | Plan hash | Summary SHA-256 | +| --- | ---: | ---: | ---: | --- | --- | +| `origin-media-4000-expansion` | 4,000 | 6 | 2 | `67b68f412789c1ae3ad8e950c49480704d5c06f33b445788272a0a73fb73a3dd` | `9ffee73162898f99d1ebe13925e460acd44628f9d6d198aa0f5c3206a0d98452` | +| `origin-media-5000-critical` | 5,000 | 8 | 2 | `845206f4b8c1e605953a1efd8066b73b9bba87e2487627f3022bd337ec6d44ec` | `022068aa97e6be03f90d205f1907dd856db9e0d9ab1feaf2ecc86fade22b1d3f` | + +The 4,000 plan covers indices `0..5`, with local client counts +`667,667,667,667,666,666` and an exact total of 4,000. The 5,000 plan covers +indices `0..7`, with 625 clients per shard and an exact total of 5,000. Every +plan has two distinct generator fingerprints and unique client-ordinal hashes. + +Verification command: + +```bash +node tools/early-birds-hls-load/verify-planned.mjs \ + --min-generators 2 /secure/collected/-shard-*.json +``` + +The committed verifier independently checks the plan hash, input hashes, +complete unique indices, client sum, generator count, ordinal hashes, redaction, +exact source mode and absence of network/runtime activity. + +## Gate that remains closed + +This record does not authorize the next step. A network run still requires an +explicit monitored window, fresh short-lived signed manifest files, external +decoded canary, target operators and stepwise go/no-go starting at the smallest +approved client count. It must never jump directly to 4,000 or 5,000 and must +never run from `mona`. diff --git a/docs/phases/PHASE_2_PARTICIPATION.md b/docs/phases/PHASE_2_PARTICIPATION.md index 03aba15c..80ec954d 100644 --- a/docs/phases/PHASE_2_PARTICIPATION.md +++ b/docs/phases/PHASE_2_PARTICIPATION.md @@ -38,7 +38,7 @@ Five workstreams. - Early access: a separate `earlyAccess` flag on meditations, visible only to patrons - [ ] Cancellation flow: one-click, no retention offers, acknowledgement email in brand voice - [ ] Dunning: retry at day 1/3/7, pause on third failure, no account lockout -- [ ] Refund flow: 14-day no-questions-refund endpoint, exposed in the patron's account page +- [ ] Exceptional refund procedure: manual provider operation after an individually reviewed legal/support case; no automatic or self-service refund endpoint - [ ] Hearth page at `/hearth`: patrons at Hearth tier can opt to display name; rendered server-side, static-cached with 1h TTL; alphabetical or chronological, patron's choice - [ ] Annual year-end summary email with total patronage contribution (for tax-deductibility where applicable) - [ ] Gift flow: purchase annual patronage for another email address; recipient redeems with one click diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md new file mode 100644 index 00000000..9f3c3a4a --- /dev/null +++ b/docs/plans/EARLY_BIRDS.md @@ -0,0 +1,770 @@ +# EarlyBirds: product and delivery plan + +> **Status:** Accepted implementation baseline +> **Date:** 2026-08-07 +> **Integration branch:** `early-birds`; merge current green `main` at controlled checkpoints +> **Operational rule:** implementation and isolated staging are authorized. Production, +> real charges and every audio encoding/content/signature choice still require the +> explicit release and audio gates in this document. + +> **Current launch memory (2026-08-15):** the public Listener candidate runs exact +> SHA `5d1073f598272d81a14a64d55a4220c2c13e9a74`; the previous contract-compatible Listener +> image and dormant private Live workbench remain on `acc90ba35fea52f63ef18337e3a555ef637c552f`; +> the private withdrawal operator remains pinned at +> `0a475717d45d32cec38afdb8fc35fb772a994017`; canonical payment authority runs +> `4e5b208e902969285c8f68067f7fd13b7e2eb68d`; the isolated mail sidecar runs +> `456ece2b38e203a2d12c54864115e03ebaa1a89c`. PayPal Sandbox and Mercado Pago +> TEST lifecycles are accepted. The abandoned PayPal Live approval was retired +> after canonical provider 404 evidence without a charge, subscription or +> Founder state; no outstanding binding remains. New sales and public checkout +> remain OFF while Live lifecycle/reconciliation stays ready. The public no-login withdrawal and service-cancellation paths, +> private queue and 20h/24h alerts are deployed and smoke-tested. See +> `docs/operations/LISTENER_LAUNCH_NOW.md` for the few remaining human/external gates. + +Reviewed inputs: `.hermes/plans/2026-08-05_beacon-founders-mvp.md` and +`docs/BEACON_FOUNDERS.md` from the daimonmatrix checkout. They remain valuable +vision inputs; this document supersedes them only as the implementation plan for +the current repository. + +## 1. Outcome + +EarlyBirds is the implementation codename for a registered Free and paid +listening product for people who want a continuous relationship with the Beacon +outside scheduled events. + +The first useful release lets a Listener: + +1. sign in with a configured Google/Apple provider or a passwordless email link; +2. use a registered-Free allowance of three hours in a personal, fixed + seven-day cycle anchored at the first real Free playback, redeem a controlled + invitation, or activate a valid paid Founding Listener membership; +3. open an authenticated, receive-only listening home; +4. hear a continuous 24/7 Beacon stream; +5. optionally begin with one reviewed private intro, using standard private + playback controls before the continuous Beacon stream is revealed; +6. return later and recover the same access without joining an event room. + +The initial 24/7 source is the approved long spatialized master, played +continuously. Its origin is an operational concern, not a public product state: +the interface identifies only the continuous Beacon stream. The delivery +service is designed so that its origin can change without replacing the +Listener product or requiring new public copy. + +EarlyBirds is developed quickly and in isolation. Weekend event releases remain +on `main` and must not depend on EarlyBirds until a later, explicit convergence +change has passed its own audio and operational acceptance. + +## 2. Decisions already accepted + +| Decision | State | Consequence | +|---|---|---| +| Develop on a long-lived `early-birds` integration branch | Accepted | Weekend work continues independently on `main`. | +| Use the approved long master as the first 24/7 source | Accepted | We can prove the listening product before the physical live uplink exists. | +| Make the stream the primary EarlyBirds experience | Accepted | Stream reliability and audio quality precede growth features. | +| Share only the Beacon stream timeline | Accepted | Intros are private media with local play, pause, seek and restart controls; their natural end reveals the shared live edge. | +| Design the stream for later reuse by event sessions | Accepted | The source and delivery contract cannot be Listener-specific. | +| Do not change the current event audio path before the next weekend | Accepted | Reuse by events is a post-weekend convergence card, not an EarlyBirds shortcut. | +| Use Fast Forward development with risk-based checkpoints | Accepted | Small isolated changes do not run the whole production release ceremony. | +| Preserve the audio guardrail | Accepted | No codec, rate, channel, gain, buffer, routing or player-path choice ships without Nico's audio approval. | +| Use deterministic HLS over HTTP | Accepted | Every listener follows one UTC-derived live edge through immutable six-second segments; the approved staging delivery is AAC-LC 320 kbps, 48 kHz stereo and event WebRTC is untouched. | +| Favor continuity over low latency in the Listener | Accepted | Listener retains a five-minute origin window and starts about three minutes behind the edge, with a measured three-minute client continuity window across MediaSource plus a bounded memory-only segment reservoir; Stop and a later Listen rejoin the current configured buffered position. Hidden documents pause playback/quota and rejoin on foreground. | +| Keep intros private | Accepted | Intro progress is device-local. The live stream runs muted underneath and is revealed at the handoff; this is not a realtime mix or crossfader. | +| Separate ordinary Free from canonical membership | Accepted | Registered Free is a server-authoritative, metered weekly allowance that never fabricates membership or Purchase; canonical memberships/invitations and Free for All remain non-metered. | +| Preserve the Founder price while service remains uninterrupted | Accepted | USD 5/month remains guaranteed only while the canonical Founder subscription stays active or inside its approved grace/paid-through continuity; once service ends, Founder status and pricing end and a later signup uses the current public offer. | +| Launch Free before paid providers | Accepted | Human acceptance of the complete Free flow is a hard gate before PayPal or MercadoPago can be enabled. Both providers remain disabled by default. | +| Defer app-store distribution | Accepted | Google Play and Apple App Store wrappers and billing are post-MVP work; the provider-neutral membership authority must leave room for them without making them a launch dependency. | +| Design for 3,000 concurrent listeners | Accepted | Expand at 4,000 and treat 5,000 as critical; alerts use measured network, CPU, memory, origin and canary health. | + +## 3. Facts from the current system + +This plan is based on the current repository and deployed architecture, not on +the older Founders proposal alone. + +- Event production is one host (`mona`) running the Next.js app, PostgreSQL, + LiveKit, playlist-bot and tapestry. This is already a shared failure domain. +- Event attendees use durable `WebSession` rows and `hb_session`. The existing + `User` table represents staff, not consumer accounts. +- NextAuth/Auth.js was retired on 2026-08-02. Reintroducing a beta auth runtime + is explicitly prohibited without a new decision and a full auth review. +- The event Beacon bed is delivered through LiveKit. Its playlist publisher is + optimized for real-time event mixing and is guarded because it previously + produced audible regressions. +- PayPal and the commerce/entitlement integration already have an authority in + `proyecciones-mito`. EarlyBirds must extend or consume that authority, not + create an unrelated payment truth inside the web app. +- The selected source master is + `luz_de_manana_20260624-155633_2hs.wav`: 6,844.426 seconds + (1:54:04.426), stereo, 48 kHz, 16-bit PCM, 1,314,129,920 bytes and + SHA-256 `feb0cac547eee8a2012ede32f9358e1cad4b66f6aea3b1b839610e71fad42685`. + Nico approved its authored gain without further normalization. Its AAC-LC + 320 kbps staging derivative measures -14.2 LUFS with a decoded peak of + -0.2 dBFS. +- The approved English intro source is the 2026-08-06 Amara Sol offline mix + `BeaconDropIn-Amara-sol_r1_session.wav`, including its authored Beacon + sidechain/effects and ending fade. Nico approved its authored gain without + further normalization. Its AAC-LC 320 kbps, stereo, 48 kHz derivative + measures -11.2 LUFS with a decoded peak of -0.4 dBFS. The separately approved + Spanish source is `BeaconDropIn-Amara-sol_ES_r1_session.wav` (2026-08-07), + SHA-256 `e59443ab765a4eb94c7d2ea96176647c5b0e5d2945966ea3de599270edec656b`; + its format-only AAC-LC derivative is 347.010 seconds, -11.3 LUFS and -0.4 dBFS + true peak. +- No production `beacon-247` LiveKit room exists. The isolated staging HTTP + origin is the current 24/7 implementation and remains separate from events. + +## 4. Corrections to the initial Founders proposal + +The two source documents capture the desired spirit, but their implementation +steps are not safe to execute literally. + +1. They name PayPal and MercadoPago as the product providers but implement + Stripe in the task sequence. EarlyBirds will use one canonical provider and + contract at a time. +2. They attach consumer identity to the existing staff `User` model. EarlyBird + accounts need a separate domain. +3. They add a second session framework and bridge it into `hb_session`. Listener + sessions remain separate from staff/event sessions. +4. They extend the current event LiveKit token route and current + `AudioContext`. The EarlyBirds MVP gets separate routes and a separate player + boundary. +5. They call a shared database, container, host and SFU "zero impact". Shared + infrastructure is impact; the preview and media origin must be isolated and + resource-bounded. +6. They treat a boolean `isFounder` as a complete pricing contract. Founder + continuity requires a versioned offer, canonical paid activation and + ordered paid-through/grace/termination evidence separate from current + listening authorization. +7. They place PWA, three identity providers, root redirects, post-event upsell + and autonomous social publishing in the first slice. None is required to + prove that a person can subscribe and listen reliably. +8. They alternate between claiming an existing live 24/7 source and saying it + still needs to be built. The initial operational origin is explicit in the + runbook while the public product remains source-neutral. + +## 5. MVP boundary + +### Included + +- The dedicated Listener hostname exposes the unified entry canonically at `/`: + public sign-in followed by registered Free, controlled invitation or + canonical Founder access. +- `/early-birds` and `/early-birds/home` are compatibility redirects during the + namespace migration only. +- Google and Apple sign-in plus an optional passwordless email fallback through + an exact, stable Better Auth version and the existing private mail authority. +- A separate EarlyBird account/session domain. +- Three hours of registered Free listening per personal fixed seven-day cycle, + one-use signed invitations and canonical paid membership entitlements. +- A continuous, monitored stream from the approved long master. +- One unified transport: Beacon-only or a selected private ES/EN intro followed automatically by the live handoff; Stop controls the whole sequence. +- The Beacon fades in on every start/restart and stops over a short fade-out where the browser exposes media-element volume. +- Two-device lease enforcement; a third device evicts the oldest lease. +- Honest delivery state: ready, playing, reconnecting or unavailable, without + making a public claim about the stream origin. +- Cancellation/revocation reflected without relying on a front-end redirect. +- ES/EN copy, privacy/terms, basic accessibility and mobile-browser acceptance. +- Metrics sufficient to know whether the stream is reachable and audible. + +### Deferred + +- Facebook sign-in and cross-provider account linking. +- PWA installation and custom service worker. +- Root-route redirection. +- Post-event upsell inside the current session UI. +- Automated social posting or advertising spend. +- Harmonizer, vocoder or generative audio experiments. +- Modifying current event `AudioContext`, LiveKit token routes or crossfader. +- Reusing the stream in scheduled events; this is the post-weekend convergence + work described in section 14. + +## 6. Architecture + +```text +approved immutable master + | + v +offline reviewed derivative ----> 24/7 stream origin ----> cache/CDN boundary + | | + | v + | Listener browser + | + optional private intro + v + external canary + +Google/Apple OIDC --\ +email magic link ----> EarlyBird account/session ---> EarlyBird web routes + | + v +membership authority <--- Free invites / PayPal / MercadoPago / future stores +``` + +### 6.1 Code boundary + +Until final integration, new application code stays under explicit namespaces: + +- `src/app/early-birds/**` +- `src/app/api/early-birds/**` +- `src/lib/early-birds/**` +- `services/beacon-stream/**` +- additive EarlyBird data models and migrations only +- an isolated compose/preview definition, not edits that replace production + services + +The MVP does not modify: + +- `src/context/AudioContext.tsx`; +- `src/app/session/[id]/**`; +- `src/app/api/livekit/token/**`; +- core event `Principal` semantics; +- event playlist-bot behavior. + +### 6.2 Runtime isolation + +Development and team acceptance use an isolated preview: + +- its own app container and compose project name; +- its own preview PostgreSQL database; +- its own cookie name, signing secret and OAuth callback; +- its own stream-origin container and URL; +- synthetic accounts and provider sandbox data only; +- CPU/memory limits so it cannot starve event services; +- no automatic production migration or deploy from the `early-birds` branch. + +The preview address is `earlybirds-staging.harmonicbeacon.com`; the dedicated +media origin is `stream.harmonicbeacon.com`. Both need DNS/TLS before external +acceptance, but local and ZeroTier validation do not wait for DNS. + +For final production, the code may live in the main app after acceptance, but +the stream origin remains independently restartable and resource-bounded. Data +models are additive and a rollback can hide EarlyBird routes without rolling +back event data. + +## 7. Continuous stream contract + +The stream is a shared platform service, even though EarlyBirds is its first +consumer. + +### 7.1 Source and artifacts + +- The WAV master at + `/home/nicolas/Music/beacon/luz_de_manana_20260624-155633_2hs.wav` is immutable + and identified by a recorded SHA-256. +- Conversion never overwrites the master. +- A reproducible command creates a versioned delivery artifact. +- The derivative records codec, bitrate, sample rate, channels, loudness/peak + measurements, encoder version and checksum. +- Nico approves the derivative by A/B listening before it becomes a candidate. +- Every intro master follows the same provenance and explicit listening-approval process separately. + +### 7.2 Delivery shape + +The first technical spike will use a buffered HTTP streaming protocol rather +than WebRTC for this one-way, long-running source. The working default is HLS: +it is buffer-friendly, cacheable, scales independently of the event SFU and can +later be consumed by both Listener and event clients. + +The current Listener delivery format was selected through the audio ladder and +explicitly approved by Nico for this isolated product: AAC-LC 320 kbps, 48 kHz, +stereo. The approved immutable Beacon and ES/EN intro artifacts and their +checksums are recorded in the media-provenance runbook. Any future codec, +bitrate, sample-rate, channel, gain or dynamics change remains an audio-touching +decision requiring the same comparison and explicit approval. + +Encoding is deliberately excluded until Nico approves a candidate. Once an +artifact is approved, the steady state is: + +1. encode the approved master once; +2. generate immutable six-second segments once; +3. derive the apparent live edge from a fixed UTC epoch, without a continuously + advancing publisher process or another lossy encode; +4. serve a short manifest whose media sequence follows that deterministic edge; +5. expose health, current source, media sequence and last-output timestamp. + +All listeners hear approximately the same wall-clock position in the 24/7 +Beacon stream. Intro progress remains private and local. Origin restart +must preserve the same epoch and live edge; a new epoch is a versioned artifact +promotion, never an accidental restart side effect. + +### 7.3 Access and truthfulness + +- The public page does not expose a durable unrestricted media URL. +- The private player obtains a short-lived signed stream authorization after a + current membership check. +- The browser keeps one stable direct-origin media-grant URL. Listener checks + session, membership, quota and device lease only at acquisition/heartbeat, + then renews the opaque grant privately without replacing the media source. +- Manifest and segment fetches never consult Listener or PostgreSQL. The origin + stores only a token hash and lease-bounded expiry; bearer URLs are never + logged and fail closed after the last successful renewal. +- Expiry and refresh do not interrupt healthy playback unnecessarily. +- The UI says "continuous Beacon stream" and does not claim whether the source + is an instrument, a file or another origin. +- Delivery state comes from the same origin state that drives playback. + +### 7.4 Reliability acceptance + +- One immutable artifact version and UTC epoch are active. +- The master loops without an audible speed change, channel collapse or + duplicate overlap. +- Restart and reconnect recover without manual browser reload. +- A canary fetches manifests and decodes actual audio, not only HTTP 200. +- A 60-minute human listen on desktop and physical iOS/Android devices has no + unexplained gaps, speed shifts or route changes. +- Stream failure cannot consume resources needed by an event and has a + one-command stop/rollback. +- At 450 kbit/s budgeted egress per listener, 3,000 concurrent listeners are + the committed envelope with 40% network headroom; 4,000 triggers expansion + and 5,000 is critical. Actual NIC throughput, packet loss/retransmits, origin + latency/errors, CPU, memory, disk, manifest age and decoded-audio canary state + are the scaling truth. + +The current `mona` planning baseline is OVH VPS-4: 8 vCPU, 24 GB RAM, 200 GB +storage and up to 3 Gbit/s network. That headline rate is not a guarantee, so +promotion depends on measured soak evidence. Bunny CDN is preconfigured but +stays out of the delivery path until the network expansion threshold or an +origin-quality trigger is reached. + +Prometheus scrapes node-exporter, cAdvisor, the private stream metrics listener +and an external canary. Alertmanager sends only operational metadata to the +private `Harmonic Beacon · Ops` Telegram group: warnings are grouped and repeat +hourly; critical alerts send immediately and repeat every 15 minutes; recovery +notifications are mandatory. Public health is minimal and `/metrics` is never +exposed on the public listener origin. + +## 8. Listener player contract + +The player starts from the simplest path shown to reproduce clean audio in prior +testing: native HLS on Safari and `hls.js` where Media Source Extensions are +required. Web Audio, realtime mixing and a crossfader are outside this milestone. + +- Playback begins only after an explicit user gesture. +- First use defaults to the matching-language intro and remembers the person's + last local choice; Beacon-only remains available if an intro fails. +- The Beacon source and lease are prepared before intro controls are enabled; + the click starts both media elements inside the same user gesture for iOS. +- Starting, pausing, seeking, restarting or finishing an intro does not + reconnect, pause, seek or restart the Beacon stream. +- Intros expose familiar play, pause, timeline/seek and restart controls. +- Intro position is private to the Listener and is never synchronized with + another Listener. +- Each intro is an offline reviewed render. Its controls never move the 24/7 + Beacon timeline, which remains muted underneath; pausing produces silence. +- A genuine natural end reveals the already-playing Beacon. Browsers with + writable element volume use an equal-power three-second fade that follows + live volume changes. iOS uses a non-overlapping native unmute because its + media-element volume is not script-writable; no Web Audio path is introduced. +- Starting or stopping an intro cannot reconnect or replace the underlying Beacon stream. +- A hidden or locked phone behaves honestly; the UI does not claim playback + while the browser has suspended it. +- No camera, microphone, chat, hands, tapestry or event presence is created. +- A Listener connection does not count as an event LiveKit participant. + +The media test ladder is mandatory and intentionally incremental: + +1. master file in a standard player; +2. approved derivative in a standard player; +3. stream in a standard browser player; +4. stream in the EarlyBird player; +5. approved ES intro and handoff to the current stream edge; +6. approved EN intro and handoff to the current stream edge. + +A failure at one level is fixed there before testing the next. + +### 8.1 Listener presentation isolation + +Listener components never consume the event visual primitives +(`event-shell`/`event-button`/`event-alert`/`event-field`); they use additive +`.listener-*` mirrors in `src/app/globals.css` so future event UI changes +cannot restyle Listener surfaces (issues #213, #198). The `.event-*` rules +remain untouched for event pages. Automated evidence: + +- `src/components/early-birds/__tests__/listener-visual-isolation.test.tsx` — + representative public/access Listener branches render `.listener-*` + classes, error states use the styled + `listener-alert--danger`/`listener-alert--error` variants, and the access + card keeps one contextual primary action. Source inspection confirms no + borrowed event visual primitive remains in Listener components. +- `e2e/tests/early-birds-responsive.spec.ts` — Spanish and explicit English + browser-language paths, ≥ 44 px touch targets on real Listener controls, + the reduced-motion path without nonessential looping animation, exactly one + enabled primary transport action in the ready state, and no media elements + or artifact requests on the public pre-access surface. + +## 9. Identity and session contract + +EarlyBird identity is not staff identity and not an event ticket identity. + +Proposed additive concepts: + +- `EarlyBirdAccount`: internal opaque account identifier and lifecycle state; +- `EarlyBirdIdentity`: provider, provider subject, verified email and linkage; +- `EarlyBirdSession`: hashed opaque session, expiry, revocation and last use; +- `EarlyBirdOfferGrant`: the offer terms acquired by the account; +- `EarlyBirdEntitlementSnapshot`: last canonical membership state and source + revision, if a local cache is necessary. + +The browser uses a separate `hb_earlybird_session` cookie. An EarlyBird session +cannot grant staff capabilities, event publication or event admission. + +Google and Apple use Authorization Code with PKCE, state and nonce. Email uses +a verifier-only, short-lived, single-use link delivered by the existing mail +authority. No provider +access/refresh token is stored unless a later feature proves it necessary. +Provider subject is the primary external identity; verified email is contact +evidence, not a mutable authorization key. Cross-provider account linking is +disabled for the milestone. + +Better Auth is pinned exactly to `1.6.26` and uses separate +EarlyBird models, routes and cookie. Its session never upgrades into an event or +staff principal. The retired NextAuth beta is not reintroduced. + +## 10. Membership and commerce contract + +`proyecciones-mito` remains the canonical membership/commerce authority. The +web app does not infer access from a provider success page and does not create a +parallel webhook truth. Free, PayPal, MercadoPago and future app-store grants +all project into the same provider-neutral contract. + +The EarlyBirds contract must provide, at minimum: + +- opaque account/customer correlation without leaking provider secrets; +- offer code and immutable offer revision; +- provider subscription identifier kept server-side; +- canonical state: pending, active, grace, paused, cancelled, expired, refunded + or revoked (final vocabulary agreed with commerce); +- effective and expiry/grace timestamps; +- monotonic revision or source-event ordering key; +- idempotent delivery and reconciliation endpoint; +- plan, currency and amount validation at the commerce boundary; +- cancellation, failed-renewal, refund, dispute and manual-revoke behavior; +- a safe test/sandbox mode with synthetic identities. + +Free invitations are single-use signed grants, scoped to EarlyBirds, auditable, +revocable and valid indefinitely until consumed or revoked. They work in +staging and production. Upgrading Free to paid consumes the free grant so two +independent memberships cannot remain active. + +"Founder price locked for life" means for the uninterrupted lifetime of the +Founder service, not for the lifetime of the account. It is a versioned USD +5/month continuity state recording amount/currency, canonical activation and +the current paid/grace boundary. A pending voluntary cancellation preserves +access through paid-through time and may be reversed before that boundary +without losing Founder status. Once service ends, eligibility ends permanently +and a later signup uses the current public offer. Involuntary payment failure +receives 14 days of grace. Terminal failure, refund, chargeback, dispute, fraud +or administrative termination removes access and Founder status; the browser +cannot invent or erase that commercial evidence. + +PayPal and MercadoPago both implement the same contract. MercadoPago charges an +ARS equivalent derived from the BCRA A3500 reference rate, locks the renewal +amount 72 hours before collection, displays both USD 5 and the locked ARS +amount, and retains the previous valid amount when the rate source is +unavailable. No provider is enabled for real EarlyBird charges until Nico +approves the exact offer and its sandbox lifecycle passes end to end. + +Activation is intentionally sequenced. The first usable EarlyBirds release is +Free-only and must pass human acceptance, revocation and reconciliation before +either paid provider is enabled. PayPal and MercadoPago may be implemented and +tested behind disabled provider flags, but no paid checkout is exposed merely +because its adapter exists. Google Play and Apple App Store distribution and +billing are deferred beyond this MVP; a future store adapter must project into +this same authority instead of creating app-specific membership truth. + +The product is for all audiences. The service requests only the account and +payment information required for the selected access path and does not create +age-specific profiles. + +### 10.1 Vendored contract status + +The webapp vendors byte-exact copies of the canonical backend contracts under +`contracts/` and verifies them with `npm run contract:early-birds:verify`: + +- `contracts/early-bird-authority/v3` and `contracts/early-bird-membership/v2`: + the current atomic membership plus Founder-continuity read/push contracts. +- `contracts/early-bird-authority/v1`, `contracts/early-bird-authority/v2` and + `contracts/early-bird-membership/v1`: historical artifacts only; runtime does + not dual-read or dual-write the retired positive-only semantics. +- `contracts/early-bird-checkout/v2`: the Mercado Pago checkout command for + `POST /api/internal/v2/early-bird-checkouts`, vendored byte-exact from + backend implementation `e5e638a78d5e835bfb3cfa7be69740f0003ffb01`, merged + as `82b4b58a661a9aa7b8979e0f2fb88a07af31b394` + (SairaAsua/proyecciones-mito#57). Its opaque account ID now matches the + runtime authority contract instead of requiring an unused prefix. The + provider remains TEST-only and disabled by default; the checkout surface is + exact-host staging-only and absent from the public Listener edge. + The canonical Founder price and all shared contract bytes advanced to USD 5 + in backend PR #59 / `cad4eded2e08ee46da49e54ee94e1ab8601d9495`. + Mercado Pago TEST has since passed real browser checkout, canonical activation, + pause/reactivation and fresh reconciliation without enabling Live credentials. +- `contracts/listener-checkout/v1`: the production-only, provider-neutral + checkout command/result. It exposes no provider subscription ID, fixes + `environment=live`, keeps payer email transient and uses a separate new-sales + gate from provider lifecycle. The deployed authority runtime + `4e5b208e902969285c8f68067f7fd13b7e2eb68d` is CI-green, includes canonical + cancellation/reactivation, paid-lifecycle metrics, reviewed Mercado Pago adverse-event + hardening and bounded missing-PayPal-approval recovery, and is the minimum authority binary after + any new Live checkout attempt. The Listener Live + surface and exact webhook ingress remain disabled by default; + see `docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md`. + This authority release also provides a read-only, redacted Live-provider + preflight for exact PayPal catalog/webhook and Mercado Pago merchant checks; + productive credentials are installed root-only and verified read-only; public + checkout and global new sales remain OFF. + +## 11. Fast Forward development lane + +The purpose of isolation is to make development fast, not to reproduce the +production release process for every edit. + +### 11.1 Branch flow + +```text +main (weekend production) + \ + early-birds (shared integration and preview) + |-- early-birds/stream-origin + |-- early-birds/listener-shell + |-- early-birds/google-identity + `-- early-birds/membership-contract +``` + +- Short slices merge into `early-birds`, not `main`. +- The shared branch is never rebased after others consume it. +- `main` is merged into `early-birds` at controlled checkpoints after `main` is + green; weekend fixes never wait for EarlyBirds. +- Final convergence is one reviewed PR from `early-birds` to current `main`. + +### 11.2 Three verification speeds + +**Fast loop — every small change, target under five minutes** + +- formatting/lint only for changed files; +- focused unit or component tests related to the slice; +- schema/contract validation when those files changed; +- local smoke of the route or service being edited; +- no full build, browser matrix, load test or production probe by default. + +**Integration checkpoint — when a coherent slice enters `early-birds`** + +- TypeScript and full lint once; +- relevant package/unit suites; +- preview database migration from empty and from previous preview revision; +- one focused browser happy path; +- container health and rollback smoke for changed services. + +**Release checkpoint — only for an EarlyBird candidate to merge or launch** + +- full existing CI/build and EarlyBird integration/E2E suite; +- auth and commerce adversarial matrix; +- physical-browser audio acceptance; +- sustained stream/canary/restart test; +- security/privacy review, migration/backup and rollback rehearsal; +- conflict/regression audit against current `main`; +- human acceptance by Nico/team. + +Nightly or manual CI may run heavier checks without blocking each commit. Load +tests use separate inexpensive clients/VPSs and are never generated from the +same production host being measured. + +### 11.3 Risk overrides + +The fast lane does not waive boundary-specific gates: + +- audio changes require the audio ladder and Nico's approval; +- identity/session changes require negative authorization tests; +- commerce changes require contract/idempotency/reconciliation tests; +- migrations require forward compatibility and a proven rollback strategy; +- production infrastructure still requires health verification and rollback. + +Everything else should favor a coherent batch and a useful preview over repeated +ceremony. + +## 12. Delivery batches + +### Batch 0 — freeze the contract + +Deliver this agreed document, the missing auth/media/offer ADRs, milestone and +dependency graph. No application behavior changes. + +Exit: all decisions in section 15 are accepted or deliberately deferred. + +### Batch A — contracts, isolated preview and deterministic origin + +- add the isolated compose/runtime boundary; +- inventory and checksum the master; +- inventory the master and define (but do not select) the reproducible artifact pipeline; +- implement deterministic manifests, signed immutable-segment delivery and two-plane health/metrics; +- install Prometheus, node-exporter, cAdvisor, Alertmanager and an external decoded-audio canary; +- route grouped/repeated warning, critical and recovery notices to the dedicated + private Telegram group `Harmonic Beacon · Ops` once its bot credentials exist; +- add a bare private test player and canary; +- execute the audio test ladder through streamed standard playback. + +Exit: the 24/7 source survives restart and a 60-minute cross-device +listen, with no event service or current audio file changed. + +### Batch B — Listener vertical slice with synthetic Free entitlement + +- create isolated EarlyBird data models and session cookie; +- build bilingual public page and private home; +- use a development-only synthetic entitlement fixture; +- add Beacon-only player, intro selection, standard controls and live-edge handoff; +- enforce two active device leases and oldest-lease eviction; +- prove that no event connection/capability is created. + +Exit: the team can use the complete listening experience in preview without a +payment provider. + +### Batch C — identity and provider-neutral membership + +- approve the identity ADR; +- implement Google and Apple sign-in plus optional email magic-link request, + callback, session/revocation and logout; +- keep account linking disabled and test duplicate-email isolation; +- implement signed one-use Free invitations and the canonical membership projection; +- run positive and negative auth tests in preview. + +Exit: a returning test Listener reaches the same isolated account and cannot +cross into event/staff privileges. + +### Batch D — Free release candidate, then disabled paid-provider readiness + +- agree the versioned commerce contract with Mariano/Sai; +- extend the commerce sandbox for the EarlyBird offer; +- consume canonical membership state in the app; +- complete Free-only human acceptance before exposing any paid checkout; +- test create, duplicate webhook, out-of-order event, retry, renewal failure, + grace, cancellation, refund and revoke; +- reconcile stale/missing delivery. +- implement MercadoPago/BCRA rate lock and failure semantics through the same contract; +- approve terms, privacy, all-ages offer copy and source wording; +- expose the public no-login **BOTÓN DE ARREPENTIMIENTO**, return an opaque + receipt immediately and process its private durable queue within 24 hours; +- complete accessibility/mobile/audio/security acceptance; +- run sustained origin/canary test and failure rehearsal; +- verify backups, observability, stop switch and rollback; +- merge current `main` into `early-birds` and resolve conflicts; +- run the release checkpoint once. + +Exit: a documented Free-only go/no-go decision and a separate paid-provider +readiness decision. Production and every paid provider remain off until each is +explicitly approved. + +## 13. Definition of done for the EarlyBirds milestone + +- A Listener can sign in, obtain a canonical sandbox membership and listen. +- The initial source is continuously delivered while public copy remains + source-neutral. +- Beacon-only and every published intro language pass physical-device listening. +- No Listener gains event/staff capabilities or creates event media connections. +- Duplicate/reordered commerce events cannot duplicate or incorrectly preserve + access. +- Revocation becomes effective within the agreed propagation window. +- Origin, app and commerce dependencies have useful health/alert signals. +- A dedicated Telegram operations group receives warning, critical and recovery + notifications without PII or secrets. +- The isolated load/soak evidence supports the 3,000-listener committed envelope + or records a lower measured limit before launch; 4,000/5,000 thresholds and + the Bunny CDN expansion switch are rehearsed. +- The entire EarlyBird feature can be disabled without rolling back weekend + event code or data. +- Current event tests remain green at final convergence. +- Runbook includes launch, pause, source replacement, incident and rollback. +- Public sales remain closed until the consumer-withdrawal and service-cancellation routes, dedicated + secret, queue migration and operator runbook are deployed and smoke-tested. + +## 14. Post-weekend convergence card + +Create one card outside the immediate EarlyBirds milestone, blocked by both a +successful EarlyBird stream acceptance and completion of the next weekend's +events: + +**Evaluate and adopt the 24/7 stream as the shared Beacon source for events.** + +It must: + +- compare the approved HTTP stream against the current LiveKit playlist source + using the established file → standard player → browser → app ladder; +- preserve simultaneous Stage and Beacon playback and the event crossfader; +- define source-of-truth, fallback and source-state behavior; +- test Chrome, Safari/iOS, Android, reconnection and long listening; +- measure latency, dropouts, channel count, sample rate, speed and gain; +- retain a one-switch rollback to the current event bed; +- avoid changing the event path before the weekend; +- require Nico's explicit audio approval before merge or deploy. + +The expected benefit is one continuously proven, buffer-friendly Beacon source +for both products. It is an experiment until the comparison demonstrates that +event sound and reliability are at least as good as the current path. + +## 15. Frozen decisions + +The earlier USD 2 value was an unreleased experiment. There are no real subscribers to migrate or +grandfather, so the USD 5 migration replaces it rather than introducing a second offer. After that +forward-only migration is applied, operational rollback is provider kill-switch plus roll-forward; +an older USD 2 binary is not a valid rollback target. + +| ID | Accepted decision | +|---|---| +| D1 | `EarlyBirds` remains the implementation branch/milestone; public Listener is `listen.harmonicbeacon.com/`, staging migrates to `listen-staging.harmonicbeacon.com`, legacy `/early-birds` paths redirect during cutover, and origin remains `stream.harmonicbeacon.com`. | +| D2 | USD 5/month founder offer while service remains uninterrupted; pending cancellation retains access and price only through paid-through and can be reversed before service ends; once service ends, later signup uses the then-current public price; 14-day involuntary grace; terminal failure/refund/chargeback/dispute/fraud/admin termination removes access and Founder status. | +| D3 | Google and Apple through exact stable Better Auth, plus an optional passwordless email magic-link fallback through the existing private mail authority; no Facebook and no implicit account linking. | +| D4 | Provider-neutral Free, PayPal and MercadoPago grants; Free is single-use, signed, auditable, revocable and consumed by paid upgrade. | +| D5 | Source-neutral “continuous Beacon stream” wording; never claim whether the source is an instrument, a file or another origin. | +| D6 | Each authored Amara Sol offline mix is immutable and separately approved; the English intro is the currently approved and published default. | +| D7 | Deterministic UTC HLS, immutable six-second segments, signed paths, native Safari and `hls.js`; current approved delivery is AAC-LC 320 kbps, 48 kHz stereo and any later encoding change requires explicit audio approval. | +| D8 | Two device leases; third device evicts oldest. | +| D9 | Main app after final convergence; independently bounded stream origin; additive models and kill switch. | +| D10 | One shared wall-clock Beacon timeline; every intro has private play/pause/seek/restart controls and hands off to the current live edge. | +| D11 | Capacity targets 3k committed, 4k expansion and 5k critical at a 450 kbit/s planning budget with 40% headroom. | +| D12 | All-audiences experience: an adult owns account/payment; no minor profile or minor data. | +| D13 | Release sequence is Free acceptance first, then separately approved PayPal/MercadoPago activation; Google Play/App Store wrappers and billing are post-MVP. | +| D14 | Ordinary Free requires Listener registration and grants three hours per personal fixed seven-day cycle. The cycle begins at the first real authorized Free playback, has no base rollover, is calculated from server time, and meters the union of the account's active listening leases once. Intros and Beacon count; Stop, disconnect and lease expiry bound consumption. Active canonical membership/invitation and the Free for All override are unlimited and non-metered. | + +## 16. Card map + +Create milestone `EarlyBirds` and use these non-overlapping delivery cards: + +1. EB-00 — freeze product, identity, membership, media and Fast Forward ADRs. +2. EB-01 — immutable media inventory, reproducible candidate pipeline and deterministic HLS origin. +3. EB-02 — resource isolation, observability, Telegram alerts, capacity model, canary and stop switch. +4. EB-03 — Google/Apple/email identity and isolated Listener sessions. +5. EB-04 — provider-neutral membership and one-use Free invitations. +6. EB-05 — bilingual Listener UX, two-device leases, private ES/EN intros and live-edge handoff. +7. EB-06 — PayPal sandbox lifecycle and reconciliation, disabled until Free acceptance and explicit activation approval. +8. EB-07 — MercadoPago/BCRA pricing, lock and failure lifecycle, disabled until Free acceptance and explicit activation approval. +9. EB-08 — staging, cross-device/audio acceptance, isolated load/soak and release/rollback rehearsal. +10. EB-09 — event-stream convergence investigation after the milestone (tracked + separately and never implemented before explicit audio approval). + +Track Google Play/App Store packaging and billing in a separate post-MVP card; +it must not block the Free release or silently replace EB-06/EB-07. + +Create a separate post-milestone issue for section 14. Do not hide it inside an +audio or player issue, because it changes the event sound architecture and needs +its own explicit approval. + +## 17. Rollback and operational invariants + +- `main` and the event release branch do not depend on `early-birds`. +- The preview can be stopped by stopping its compose project; no production + container is removed or replaced. +- The stream origin can be stopped independently of the event playlist-bot. +- EarlyBird public entry has a kill switch that returns a truthful unavailable + page without affecting event login. +- Membership denial fails closed when canonical commerce state is missing or + invalid; existing healthy playback gets only the explicitly agreed grace. +- The weekly-Free cutover is forward-only after its additive migration: an + incident response stops Listener/uses the kill switch and rolls forward a + repair. It never restores the retired daily-schedule or welcome-access + authorization rules. +- After any new Live checkout attempt, payment-authority rollback is also forward-only: + `4e5b208` is the minimum supported binary. Stop new sales with flags, keep provider lifecycle + ingestion and the current database, reconcile, and roll forward. Never use a pre-cutover database + restore as routine rollback. +- No secret, provider token, raw webhook payload with PII or customer record is + committed or logged publicly. +- No synthetic test writes to real participant or payment data. +- Final migrations are additive; rollback disables readers/writers before any + later cleanup migration. +- Audio artifacts are immutable and reversible by version pointer, never by + overwriting the approved previous file. diff --git a/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md b/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md new file mode 100644 index 00000000..cae28666 --- /dev/null +++ b/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md @@ -0,0 +1,98 @@ +# EarlyBirds Listener experience + +Status: accepted presentation baseline for the EarlyBirds milestone. This plan +changes presentation and interaction hierarchy only. It must not alter codec, +gain, timing, routing, leases, membership rules or event/LiveKit audio. + +## Product intent + +The Listener should feel like entering a calm, living acoustic place—not an +operations demo. A new person must understand the product within one screen: +who they are, whether access is active, what will play, how to begin and what is +playing now. Technical truth stays available without dominating the experience. + +The visual character is quiet, luminous and spatial. Motion should suggest a +shared continuous signal, never pretend to be a real analyser when it is not. +The interface should reward listening by becoming simpler after playback starts. + +## Information architecture + +1. **Compact identity rail**: Harmonic Beacon, language and an account menu. + Membership provenance and staging diagnostics stay inside progressive + disclosure, outside the primary listening experience. +2. **Beacon stage**: one dominant visual and one sentence explaining the shared + live point. This surface owns every playback state. +3. **Transport dock**: one obvious primary action plus a secondary mode choice. + Stop belongs to the same control family and location; volume is always + reachable. Mobile uses a thumb-friendly bottom dock. +4. **Intro choice**: a small pre-play preference, not a second competing player. + While the intro plays, show title, elapsed/remaining time and the explicit + promise “Beacon follows automatically.” +5. **Membership/account details**: progressive disclosure below the listening + experience. Private content metadata should not duplicate the active + transport. + +## State model shown to the listener + +- **Ready**: selected mode and a single unmistakable Listen action. +- **Preparing**: short truthful preparation state; controls do not appear dead. +- **Intro playing**: intro identity, progress and “Beacon follows.” +- **Transitioning**: brief handoff state without showing two active sources. +- **Beacon playing**: shared-point visual and Stop only. The Beacon cannot be + paused or sought; a later Listen always rejoins the configured live edge. + Public copy never claims whether the source is an instrument, a file or + another origin. +- **Intro paused / stopped**: preserve the chosen mode and make restart obvious. +- **Reconnecting**: keep intent visible, explain automatic recovery and expose a + manual retry only after recovery is exhausted. +- **Access/device error**: plain-language cause and one appropriate next action. + +## Visual and interaction principles + +- One primary accent per state; avoid several equally loud calls to action. +- Large type and negative space carry atmosphere; labels remain concise. +- Use state-driven light, depth and restrained motion. Honour `prefers-reduced-motion`. +- Minimum 48 px targets, keyboard-visible focus, AA contrast and semantic status + announcements. +- Avoid layout shifts when media metadata arrives. First useful paint must not + wait for a stream lease. +- Keep ES/EN copy equivalent and test 320, 390, 768, 1024 and 1440 px widths. + +## Delivery slices + +### UX-1 — coherent transport + +- Put the intro preference, Beacon-only preference and one contextual + Listen/Stop action in one responsive control system. +- Give Stop the same dimensions, typography and affordance as the other actions. +- Remove duplicate or inert controls and make disabled/loading states explicit. + +### UX-2 — listening stage + +- Recompose the first viewport around a single Beacon stage and transport dock. +- Add distinct ready, intro, transition, live and reconnecting visual states. +- Move account/membership diagnostics out of the primary visual hierarchy. + +### UX-3 — intro and content model + +- Collapse the duplicated content card into the active intro choice. +- Add content details through a drawer/sheet when more than one intro exists. +- Preserve standard seek/progress semantics for drop-ins; the shared Beacon has + no fake seek timeline or Pause control. + +### UX-4 — polish and acceptance + +- Responsive and physical mobile review, keyboard/screen-reader pass, reduced + motion, slow-network and reconnect states. +- Screenshot review at the target widths and a human listening walkthrough on + Chrome, Safari/iOS, Firefox and Android. +- Performance budget: no new blocking font/media request and no decorative + animation that competes with audio stability. + +## Acceptance signal + +A first-time listener can enter and begin the intended mode in under ten +seconds without explanation; during playback they can always name what is +playing and stop it; no duplicate player or technical status competes with the +experience; every error offers one understandable recovery action; the design +feels intentional on both a phone and a large screen. diff --git a/docs/security/BEACON_SUBDOMAIN_INVENTORY.md b/docs/security/BEACON_SUBDOMAIN_INVENTORY.md new file mode 100644 index 00000000..1f5055be --- /dev/null +++ b/docs/security/BEACON_SUBDOMAIN_INVENTORY.md @@ -0,0 +1,106 @@ +# Harmonic Beacon subdomain and takeover inventory + +Status: reviewed snapshot; the Ticket Tailor tenant-claim gate below remains +open and therefore this document does not yet clear Account production. + +Last read-only verification: 2026-08-18 UTC. No DNS record, DNSExit setting, +provider configuration or runtime was changed while producing this inventory. + +## Why this is an identity boundary + +Every sibling origin is treated as potentially compromised. Harmonic Beacon +therefore never uses a cookie with `Domain=.harmonicbeacon.com`; Account and +each relying party use independent host-only sessions. OAuth clients have exact +redirect and front-channel URLs, Account browser mutations require its exact +origin, and the shared navigation receives neither PII nor bearer material. + +A dangling DNS record is still important even with that isolation: an attacker +controlling a sibling origin could imitate the brand, target users, or exercise +browser same-site behavior. Every external CNAME must therefore remain claimed +by a known provider account for as long as the DNS record exists. + +## Authoritative snapshot + +The apex is delegated to `ns1`–`ns4.dnsexit.com`. Random labels below both +`harmonicbeacon.com` and `live.harmonicbeacon.com` returned no A, AAAA or CNAME, +so there is no wildcard DNS fallback. + +| Name | DNS target on 2026-08-18 | Owner / serving boundary | State and takeover decision | +| --- | --- | --- | --- | +| `harmonicbeacon.com` | GitHub Pages A records | [`AlterMundi/harmonicbeacon.com`](https://github.com/AlterMundi/harmonicbeacon.com) | Active, HTTPS 200. GitHub Pages API reports the exact apex custom domain. | +| `www.harmonicbeacon.com` | CNAME `altermundi.github.io` | Same canonical site | Active, HTTPS redirects to the apex. The organization and repository remain controlled. | +| `account.harmonicbeacon.com` | no record | Future production Account authority | Reserved but absent. This is not a dangling delegation; production must not create it before the Account rollout gate. | +| `account-staging.harmonicbeacon.com` | Mona A + AAAA | Account staging Nginx/runtime | Active, exact Account readiness HTTPS 200. Authentication-critical. | +| `listen.harmonicbeacon.com` | Mona A + AAAA | Listener production Nginx/runtime | Active, health HTTPS 200. | +| `earlybirds-staging.harmonicbeacon.com` | Mona A | Isolated Listener identity staging | Active, health HTTPS 200. | +| `listen-staging.harmonicbeacon.com` | no record | Historical/reserved name | Absent and not delegated. The canonical Listener staging host is `earlybirds-staging`. | +| `live.harmonicbeacon.com` | Mona A + AAAA | Live production Nginx/runtime | Active, health HTTPS 200. | +| `live-staging.harmonicbeacon.com` | no record | Isolated Live staging; Nginx prepared on Mona | Reserved and intentionally private/loopback. Public SSO acceptance requires a human-managed DNS/TLS cutover. | +| `stream.harmonicbeacon.com` | Mona A | Stream edge | Host is controlled by Mona and currently returns HTTPS 404 at `/`; no external provider delegation. | +| `bot.harmonicbeacon.com` | Mona A | Bot edge | Host is controlled by Mona and currently returns HTTPS 404 at `/`; no external provider delegation. | +| `tickets.harmonicbeacon.com` | CNAME `custom.tickettailor.com` | Ticket Tailor custom domain | DNS resolves, but the public endpoint returns a Cloudflare 403 and does **not** prove tenant ownership. An authenticated human must confirm this exact custom domain is still claimed by the controlled Ticket Tailor tenant before production. Gate open. | +| `proyecciondelmito.harmonicbeacon.com` | CNAME `altermundi.github.io` | [`AlterMundi/proyeccionDelMito`](https://github.com/AlterMundi/proyeccionDelMito) | Active, HTTPS 200. GitHub Pages API reports this exact custom domain. | +| `psicopompo.harmonicbeacon.com` | CNAME `sairaasua.github.io` | [`SairaAsua/psicopompoweb`](https://github.com/SairaAsua/psicopompoweb) | Active, HTTPS 200. GitHub Pages API reports this exact custom domain. | + +Repository references to `app`, `contracts` and `status` do not currently have +public A, AAAA or CNAME records. They are not treated as deployed origins. +Certificate Transparency enumeration additionally found only the active or +reserved names listed above; it is an observation aid, not the authority for +DNS ownership. Historical source references to `proyecciones`, `send`, +`_dmarc` and `resend._domainkey` have no current public records and are not +deployed origins. + +## Findings + +- No wildcard record was observed. No GitHub Pages CNAME is dangling. The + Ticket Tailor CNAME cannot be declared safe until its authenticated tenant + claim is confirmed; Account production remains gated on that evidence. +- The two GitHub Pages subdomains have an exact repository/custom-domain claim, + rather than merely a resolving shared Pages target. +- Ticket Tailor is the only current third-party SaaS CNAME outside GitHub + Pages. It is an event dependency, never an Account identity authority. Record + only the tenant/account owner, exact domain, UTC verification time and a + redacted screenshot or provider export; never copy provider credentials into + this repository or an issue. +- Direct Mona records do not delegate control to a claimable external tenant. + Their risk is instead the ordinary Nginx/runtime and server-access boundary. +- `account.harmonicbeacon.com` and `live-staging.harmonicbeacon.com` are absent, + not dangling. Their eventual creation is a manual infrastructure action; + automation in this repository must not mutate DNSExit. + +## Required lifecycle + +Run this inventory read-only before enabling a new Account RP, before every +production identity cutover, quarterly, and whenever a hosting/provider account +is retired. + +For an external provider decommission: + +1. disable application links and new traffic; +2. remove the DNS record through an authorized human/operator change; +3. wait at least the published DNS TTL and prove A/AAAA/CNAME are absent from + multiple resolvers; +4. only then release the custom-domain claim or delete the provider project; +5. repeat Certificate Transparency and HTTP checks and record the evidence. + +Never delete the provider-side project first while its CNAME remains. Never add +a parent-domain authentication cookie as compensation for SSO availability. + +## Reproducible read-only checks + +The snapshot was assembled from authoritative DNS queries, HTTPS probes, Mona's +effective Nginx `server_name` inventory, GitHub Pages API custom-domain records, +and public Certificate Transparency data. A reviewer can repeat the DNS portion +without credentials: + +```sh +dig +short NS harmonicbeacon.com +dig +short A account-staging.harmonicbeacon.com +dig +short AAAA account-staging.harmonicbeacon.com +dig +short CNAME tickets.harmonicbeacon.com +dig +short A probe-identity-audit.harmonicbeacon.com +dig +short A probe-identity-audit.live.harmonicbeacon.com +``` + +Do not place cookies, OAuth codes, action tokens, email addresses or provider +secrets in these probes or in the recorded evidence. diff --git a/e2e/README.md b/e2e/README.md index 5b0d948e..aa937e18 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,6 +13,7 @@ deterministic fixtures, no production credentials or participant data. | `tests/responsive.spec.ts` | nothing | layout geometry at 1440/1024/390/320 px | | `tests/visual.spec.ts` | stack | screenshot baselines at the same four widths | | `tests/media-continuity.spec.ts` | stack + LiveKit | the four media invariants in desktop Chromium, Android/Chrome emulation and iPhone/WebKit emulation | +| `tests/listener-network-resilience.spec.ts` | ffmpeg fixture | achieved forward buffer, 5/15/30/60-second media outages, refill, same lease and privacy-safe diagnostics in Chromium, Firefox, Android emulation and WebKit | | `tests/stage-invitation.spec.ts` | stack + LiveKit | two-browser hand → decline/invite → fresh connection stays pending → accept → return journey | | `tests/whole-system.spec.ts` | stack + LiveKit | two consecutive ES → EN waiting → doors → hand → invite → decline/accept → return → terminate lifecycles, selected-event health, plus one-identity `FACILITATOR_OP` admission/reconciliation | | `src/app/session/[id]/__tests__/media-continuity.test.tsx` | nothing | same invariants in Vitest/jsdom (`npm test`) | diff --git a/e2e/fixtures/listener-account-switch.ts b/e2e/fixtures/listener-account-switch.ts new file mode 100644 index 00000000..148c37d9 --- /dev/null +++ b/e2e/fixtures/listener-account-switch.ts @@ -0,0 +1,209 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +import type { BrowserContext, TestInfo } from '@playwright/test'; +import pg from 'pg'; + +import { requireDirectDb } from './db'; + +export const LISTENER_ACCOUNT_COOKIE = '__Host-hb_listener_account'; +const ACCOUNT_ISSUER = 'https://account.harmonicbeacon.com'; + +export type ListenerAccountSwitchFixture = { + accountId: string; + token: string; +}; + +export type ListenerAccountSwitchPair = { + founder: ListenerAccountSwitchFixture; + free: ListenerAccountSwitchFixture; + accountIds: readonly [string, string]; +}; + +export type ListenerAccountSwitchSessionState = { + present: boolean; + accountId: string | null; + issuer: string | null; + expired: boolean | null; + remainingSeconds: number | null; + synthetic: boolean | null; +}; + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Seed two non-synthetic RP sessions directly into the throwaway E2E database. + * No public login hook is added: the fixture can run only with the local + * direct-database guard already shared by the browser suite. + */ +export async function createListenerAccountSwitchPair( + testInfo: TestInfo, +): Promise { + const databaseUrl = requireDirectDb(testInfo); + const client = new pg.Client({ connectionString: databaseUrl }); + const nonce = `${Date.now()}-${randomUUID()}`; + const founderAccountId = `account-switch-founder-${nonce}`; + const freeAccountId = `account-switch-free-${nonce}`; + const founderSubject = `account-switch-founder-sub-${nonce}`; + const freeSubject = `account-switch-free-sub-${nonce}`; + const founderToken = randomBytes(32).toString('base64url'); + const freeToken = randomBytes(32).toString('base64url'); + await client.connect(); + try { + await client.query('BEGIN'); + for (const [accountId, subject, token, name] of [ + [founderAccountId, founderSubject, founderToken, 'Founder A'], + [freeAccountId, freeSubject, freeToken, 'Free B'], + ] as const) { + await client.query( + `insert into early_bird_users + (id, name, email, email_verified, security_revision, created_at, updated_at) + values ($1, $2, $3, true, 1, now(), now())`, + [accountId, name, `${digest(accountId)}@e2e.invalid`], + ); + await client.query( + `insert into beacon_profiles + (account_id, display_name, revision, created_at, updated_at) + values ($1, $2, 1, now(), now()) + on conflict (account_id) do update set + display_name = excluded.display_name, + revision = greatest(beacon_profiles.revision, excluded.revision), + updated_at = excluded.updated_at`, + [accountId, name], + ); + await client.query( + `insert into listener_account_subjects + (account_id, issuer, subject, created_at) + values ($1, $2, $3, now())`, + [accountId, ACCOUNT_ISSUER, subject], + ); + await client.query( + `insert into listener_account_sessions + (id, token_digest, account_id, issuer, subject, sid, expires_at, + last_checked_at, synthetic, created_at) + values ($1, $2, $3, $4, $5, $6, + now() + interval '1 hour', now(), false, now())`, + [ + randomUUID(), digest(token), accountId, ACCOUNT_ISSUER, subject, + `account-switch-sid-${randomUUID()}`, + ], + ); + } + + await client.query( + `insert into early_bird_membership_projections ( + id, account_id, revision, command_hash, state, source, offer_code, + offer_revision, effective_at, paid_through, provider, amount_minor, + currency, reason_code, synthetic, founder_continuity_episode_id, + founder_continuity_revision, founder_continuity_state, + founder_continuity_offer_code, founder_continuity_offer_revision, + founder_continuity_currency, founder_continuity_amount_minor, + founder_continuity_billing_period, founder_continuity_activated_at, + founder_continuity_service_through, created_at, updated_at + ) values ( + $1, $2, 1, $3, 'ACTIVE', 'PAYPAL', 'EARLY_BIRDS_FOUNDERS_V1', + 1, now(), now() + interval '31 days', 'paypal', 500, 'USD', + 'SUBSCRIPTION_ACTIVATED', false, + $4, 1, 'ACTIVE', 'EARLY_BIRDS_FOUNDERS_V1', 1, 'USD', 500, + 'MONTHLY', now(), now() + interval '31 days', now(), now() + )`, + [randomUUID(), founderAccountId, digest(`command-${nonce}`), randomUUID()], + ); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + await client.end(); + } + + return { + founder: { accountId: founderAccountId, token: founderToken }, + free: { accountId: freeAccountId, token: freeToken }, + accountIds: [founderAccountId, freeAccountId], + }; +} + +export async function useListenerAccount( + context: BrowserContext, + baseURL: string, + fixture: ListenerAccountSwitchFixture, +): Promise { + const host = new URL(baseURL).hostname; + // The production cookie is Secure+__Host and browsers correctly refuse to + // store it from an http response. Seed the exact cookie against the secure + // localhost origin; Chromium sends Secure cookies to trustworthy localhost + // while every request still reaches Playwright's plain-http local server. + await context.clearCookies(); + await context.addCookies([{ + name: LISTENER_ACCOUNT_COOKIE, + value: fixture.token, + url: `https://${host}`, + expires: Math.floor(Date.now() / 1_000) + 3_600, + httpOnly: true, + secure: true, + sameSite: 'Lax', + }]); +} + +export async function listenerAccountSwitchSessionState( + testInfo: TestInfo, + fixture: ListenerAccountSwitchFixture, +): Promise { + const databaseUrl = requireDirectDb(testInfo); + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + const result = await client.query<{ + account_id: string; + issuer: string; + expired: boolean; + remaining_seconds: string; + synthetic: boolean; + }>( + `select account_id, issuer, expires_at <= now() as expired, synthetic, + extract(epoch from (expires_at - now()))::text as remaining_seconds + from listener_account_sessions + where token_digest = $1`, + [digest(fixture.token)], + ); + const row = result.rows[0]; + return row ? { + present: true, + accountId: row.account_id, + issuer: row.issuer, + expired: row.expired, + remainingSeconds: Math.round(Number(row.remaining_seconds)), + synthetic: row.synthetic, + } : { + present: false, + accountId: null, + issuer: null, + expired: null, + remainingSeconds: null, + synthetic: null, + }; + } finally { + await client.end(); + } +} + +export async function deleteListenerAccountSwitchPair( + testInfo: TestInfo, + accountIds: readonly string[], +): Promise { + const databaseUrl = requireDirectDb(testInfo); + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + for (const accountId of accountIds) { + if (!/^account-switch-(founder|free)-/.test(accountId)) { + throw new Error('refusing to delete a non-fixture Listener account'); + } + await client.query('delete from early_bird_users where id = $1', [accountId]); + } + } finally { + await client.end(); + } +} diff --git a/e2e/fixtures/listener-boundary.ts b/e2e/fixtures/listener-boundary.ts new file mode 100644 index 00000000..e2391bc4 --- /dev/null +++ b/e2e/fixtures/listener-boundary.ts @@ -0,0 +1,112 @@ +import { randomUUID } from 'node:crypto'; + +import type { APIRequestContext, TestInfo } from '@playwright/test'; +import pg from 'pg'; + +import { requireDirectDb } from './db'; + +export type SyntheticListener = { + accountId: string; + email: string; +}; + +function localStartMinute(date: Date): number { + return date.getUTCHours() * 60 + date.getUTCMinutes(); +} + +export function minuteOffset(date: Date, offset: number): number { + return (localStartMinute(date) + offset + 1_440) % 1_440; +} + +/** + * Create an auth-only Listener session through the production-shaped, + * local-E2E-only seam. The credential is read from process env and is never + * returned, logged or stored in browser storage by this helper. + */ +export async function signInSyntheticListener( + request: APIRequestContext, + email: string, +): Promise { + // This value is the committed local fixture credential already used by + // playwright.config.ts. It authorizes no deployed environment and is + // never written to logs, browser storage or test attachments. + const secret = process.env.EARLY_BIRDS_TEST_LOGIN_SECRET + ?? 'early-birds-e2e-login-secret-not-for-production'; + const response = await request.post('/api/early-birds/test-login', { + headers: { + authorization: `Bearer ${secret}`, + 'x-forwarded-proto': 'https', + }, + data: { email, name: 'Boundary Listener', authOnly: true }, + }); + if (!response.ok()) throw new Error(`synthetic Listener login failed with ${response.status()}`); +} + +export async function syntheticListenerByEmail( + testInfo: TestInfo, + email: string, +): Promise { + const databaseUrl = requireDirectDb(testInfo); + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + const result = await client.query<{ id: string }>( + 'select id from early_bird_users where email = $1', + [email], + ); + if (result.rows.length !== 1) throw new Error('synthetic Listener account was not created'); + return { accountId: result.rows[0].id, email }; + } finally { + await client.end(); + } +} + +export async function putSyntheticFreeSchedule( + testInfo: TestInfo, + listener: SyntheticListener, + startMinute: number, +): Promise { + const databaseUrl = requireDirectDb(testInfo); + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + const now = new Date(); + await client.query( + `insert into early_bird_free_schedules ( + account_id, time_zone, local_start_minute, selected_at, + change_allowed_at, selection_request_id, revision, updated_at + ) values ($1, 'UTC', $2, $3, $3, $4, 1, $3) + on conflict (account_id) do update set + local_start_minute = excluded.local_start_minute, + selection_request_id = excluded.selection_request_id, + revision = early_bird_free_schedules.revision + 1, + updated_at = excluded.updated_at`, + [listener.accountId, startMinute, now, randomUUID()], + ); + } finally { + await client.end(); + } +} + +/** Delete only the unique @e2e.invalid identities created by this test. */ +export async function deleteSyntheticListenerEmails( + testInfo: TestInfo, + emails: readonly string[], +): Promise { + const databaseUrl = requireDirectDb(testInfo); + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + for (const email of emails) { + if (!email.endsWith('@e2e.invalid')) { + throw new Error('refusing to delete a non-synthetic Listener'); + } + await client.query( + 'delete from early_bird_users where email = $1', + [email], + ); + } + } finally { + await client.end(); + } +} diff --git a/e2e/tests/early-birds-boundary.spec.ts b/e2e/tests/early-birds-boundary.spec.ts new file mode 100644 index 00000000..a31348fa --- /dev/null +++ b/e2e/tests/early-birds-boundary.spec.ts @@ -0,0 +1,152 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +import { + deleteSyntheticListenerEmails, + minuteOffset, + putSyntheticFreeSchedule, + signInSyntheticListener, + syntheticListenerByEmail, +} from '../fixtures/listener-boundary'; +import { requireDirectDb } from '../fixtures/db'; + +async function documentEpoch(page: Page): Promise { + return page.evaluate(() => Number(sessionStorage.getItem('listener-boundary-document-epoch'))); +} + +async function installDocumentEpoch(context: BrowserContext): Promise { + await context.addInitScript(() => { + const key = 'listener-boundary-document-epoch'; + const next = Number(sessionStorage.getItem(key) ?? 0) + 1; + sessionStorage.setItem(key, String(next)); + }); +} + +// A failed run must not persist the ephemeral Listener cookie or the local +// synthetic-login request headers in a Playwright trace artifact. +test.use({ trace: 'off' }); + +test.describe('Listener scheduled Free browser boundary', () => { + test('enters and leaves without document reload, polling or client authorization', async ({ browser }, testInfo) => { + test.setTimeout(90_000); + // Refuse before creating a session or browser context unless both the + // app and database are the dedicated local fixture stack. + requireDirectDb(testInfo); + const baseURL = new URL(String(testInfo.project.use.baseURL)); + if (!['localhost', '127.0.0.1', '[::1]'].includes(baseURL.hostname)) { + throw new Error('refusing to run Listener boundary E2E against a non-local application'); + } + const nonce = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const enteringEmail = `boundary-enter-${nonce}@e2e.invalid`; + const leavingEmail = `boundary-leave-${nonce}@e2e.invalid`; + const contexts: BrowserContext[] = []; + + try { + const enteringContext = await browser.newContext(); + const leavingContext = await browser.newContext(); + contexts.push(enteringContext, leavingContext); + await Promise.all([installDocumentEpoch(enteringContext), installDocumentEpoch(leavingContext)]); + + await Promise.all([ + signInSyntheticListener(enteringContext.request, enteringEmail), + signInSyntheticListener(leavingContext.request, leavingEmail), + ]); + const entering = await syntheticListenerByEmail(testInfo, enteringEmail); + const leaving = await syntheticListenerByEmail(testInfo, leavingEmail); + + const now = new Date(); + // Entering starts at the next UTC wall minute. Leaving starts now, + // so its truthful boundary is two hours away in browser time. + await Promise.all([ + putSyntheticFreeSchedule(testInfo, entering, minuteOffset(now, 1)), + putSyntheticFreeSchedule(testInfo, leaving, minuteOffset(now, 0)), + ]); + + const enteringPage = await enteringContext.newPage(); + const leavingPage = await leavingContext.newPage(); + await Promise.all([enteringPage.clock.install(), leavingPage.clock.install()]); + + let enteringStateRequests = 0; + let leavingStateRequests = 0; + let enteringLeaseRequests = 0; + let failEnteringOnce = true; + await enteringPage.route('**/api/listener/access-state', async (route) => { + enteringStateRequests += 1; + if (failEnteringOnce) { + failEnteringOnce = false; + await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"synthetic outage"}' }); + return; + } + await route.continue(); + }); + await enteringPage.route('**/api/early-birds/stream/lease', async (route) => { + enteringLeaseRequests += 1; + await route.continue(); + }); + await leavingPage.route('**/api/listener/access-state', async (route) => { + leavingStateRequests += 1; + await route.continue(); + }); + + await Promise.all([ + enteringPage.goto('/listener'), + leavingPage.goto('/listener'), + ]); + await expect(enteringPage.locator('.listener-shell--public')).toBeVisible(); + await expect(enteringPage.locator('.listener-experience')).toHaveCount(0); + await expect(leavingPage.locator('.listener-experience')).toBeVisible(); + + const enteringEpoch = await documentEpoch(enteringPage); + const leavingEpoch = await documentEpoch(leavingPage); + const enteringState = await enteringPage.request.get('/api/listener/access-state'); + const leavingState = await leavingPage.request.get('/api/listener/access-state'); + const enteringPayload = await enteringState.json() as { serverNow: string; freeWindow: { nextStart: string } }; + const leavingPayload = await leavingState.json() as { serverNow: string; access: { allowedUntil: string } }; + + // Move only synthetic database truth across both boundaries. The + // browser clock then runs the real setTimeout path immediately; + // no test-only public clock or authorization endpoint is needed. + const transitionNow = new Date(); + await Promise.all([ + putSyntheticFreeSchedule(testInfo, entering, minuteOffset(transitionNow, 0)), + putSyntheticFreeSchedule(testInfo, leaving, minuteOffset(transitionNow, -121)), + ]); + + const enterDelay = new Date(enteringPayload.freeWindow.nextStart).getTime() + - new Date(enteringPayload.serverNow).getTime() + 1_000; + const leaveDelay = new Date(leavingPayload.access.allowedUntil).getTime() + - new Date(leavingPayload.serverNow).getTime() + 1_000; + await Promise.all([ + enteringPage.clock.fastForward(Math.max(1_000, enterDelay)), + leavingPage.clock.fastForward(Math.max(1_000, leaveDelay)), + ]); + + // The failed start revalidation cannot grant UI or media access. + await expect.poll(() => enteringStateRequests).toBe(1); + await expect(enteringPage.locator('.listener-shell--public')).toBeVisible(); + expect(enteringLeaseRequests).toBe(0); + + // A resume/visibility signal supplies one bounded retry. + await enteringPage.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))); + await expect.poll(() => enteringStateRequests).toBe(2); + await expect(enteringPage.locator('.listener-experience')).toBeVisible(); + await expect(leavingPage.locator('.listener-experience')).toHaveCount(0); + await expect(leavingPage.locator('.listener-shell--public')).toBeVisible(); + + expect(await documentEpoch(enteringPage)).toBe(enteringEpoch); + expect(await documentEpoch(leavingPage)).toBe(leavingEpoch); + expect(leavingStateRequests).toBe(1); + + // Once the refreshed server tree is stable, a full minute of + // browser time produces no extra access-state polling. + await Promise.all([ + enteringPage.clock.fastForward(60_000), + leavingPage.clock.fastForward(60_000), + ]); + expect(enteringStateRequests).toBe(2); + expect(leavingStateRequests).toBe(1); + } finally { + await Promise.all(contexts.map((context) => context.close())); + await deleteSyntheticListenerEmails(testInfo, [enteringEmail, leavingEmail]); + } + }); +}); diff --git a/e2e/tests/early-birds-responsive.spec.ts b/e2e/tests/early-birds-responsive.spec.ts new file mode 100644 index 00000000..7b656030 --- /dev/null +++ b/e2e/tests/early-birds-responsive.spec.ts @@ -0,0 +1,167 @@ +import { AxeBuilder } from '@axe-core/playwright'; +import { expect, test, type Locator, type Page, type TestInfo } from '@playwright/test'; + +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; +const MEDIA_PATH = /\/api\/(?:early-birds\/(?:stream|drop-ins)|listener\/(?:stream|drop-ins))|\.(?:m3u8|m4s|m4a|aac|mp3|ogg|wav)(?:[?#]|$)/i; +const PROJECT_IP: Record = { + w1440: '198.51.100.10', + w1024: '198.51.100.11', + w390: '198.51.100.12', + w320: '198.51.100.13', +}; + +async function expectNoHorizontalScroll(page: Page): Promise { + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow, 'Listener page has horizontal overflow').toBeLessThanOrEqual(1); +} + +async function expectAccessible(page: Page, testInfo: TestInfo, surface: string): Promise { + const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze(); + const blocking = results.violations.filter( + (violation) => violation.impact === 'critical' || violation.impact === 'serious', + ); + await testInfo.attach(`${surface}-axe`, { + body: JSON.stringify(results.violations, null, 2), + contentType: 'application/json', + }); + expect(blocking, blocking.map((violation) => ( + `${violation.id}: ${violation.help} (${violation.nodes.length})` + )).join('\n')).toEqual([]); +} + +async function expectTouchTarget(target: Locator, name: string): Promise { + await expect(target).toBeVisible(); + const box = await target.boundingBox(); + expect(box, `${name} has no layout box`).not.toBeNull(); + expect(box!.height, `${name} is shorter than 44 CSS px`).toBeGreaterThanOrEqual(44); + expect(box!.width, `${name} is narrower than 44 CSS px`).toBeGreaterThanOrEqual(44); +} + +test.describe('Listener responsive and accessibility boundary', () => { + test.beforeEach(async ({ page }) => { + await page.setExtraHTTPHeaders({ 'x-forwarded-proto': 'https' }); + }); + + test('public entry is in bounds, accessible and requests no media before authorization', async ({ page }, testInfo) => { + const mediaRequests: string[] = []; + page.on('request', (request) => { + if (MEDIA_PATH.test(new URL(request.url()).pathname)) mediaRequests.push(request.url()); + }); + + await page.goto('/listener'); + await expect(page.getByRole('heading', { name: 'Recuerda tu centro armónico.' })).toBeVisible(); + await expectTouchTarget(page.getByRole('link', { name: 'Entrar al Beacon' }), 'Entrar al Beacon'); + await expectNoHorizontalScroll(page); + // One clear contextual primary action: the hero entry CTA, with no + // competing primary action inside the anonymous access card. + await expect(page.locator('.listener-public-hero__cta')).toHaveCount(1); + await expect(page.locator('.listener-access__card .listener-button--primary')).toHaveCount(0); + await expect(page.locator('audio, video')).toHaveCount(0); + expect(mediaRequests).toEqual([]); + await expectAccessible(page, testInfo, 'listener-public'); + }); + + test('public access controls keep the 44px touch floor', async ({ page }) => { + await page.goto('/listener'); + const stagingSubmit = page.getByRole('button', { name: /staging/i }); + test.skip( + await stagingSubmit.count() === 0, + 'staging team entry surface is not enabled in this stack', + ); + await expectTouchTarget(stagingSubmit, 'staging entry submit'); + await expectTouchTarget( + page.getByLabel(/Cuenta sintética|Synthetic account/i), + 'synthetic account input', + ); + }); + + test('reduced motion removes nonessential Listener animation', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/listener'); + await expect(page.getByRole('heading', { name: 'Recuerda tu centro armónico.' })).toBeVisible(); + + for (const selector of [ + '.listener-field__aurora', + '.listener-field__orbit--outer', + '.listener-field__orbit--inner', + '.listener-field__core', + '.listener-field__point', + ]) { + const animationName = await page.locator(selector).first().evaluate( + (element) => getComputedStyle(element).animationName, + ); + expect(animationName, `${selector} still animates under reduced motion`).toBe('none'); + } + + const looping = await page.evaluate(() => { + const names: string[] = []; + document.querySelectorAll('*').forEach((element) => { + const style = getComputedStyle(element); + if ( + style.animationName !== 'none' + && style.animationIterationCount === 'infinite' + && style.animationPlayState === 'running' + ) { + names.push(`${element.tagName}.${String(element.className)}`); + } + }); + return names; + }); + expect(looping, 'nonessential looping animation survives reduced motion').toEqual([]); + }); + + test.describe('explicit English browser language', () => { + test.use({ locale: 'en-US' }); + + test('renders the English Listener with no media before authorization', async ({ page }, testInfo) => { + const mediaRequests: string[] = []; + page.on('request', (request) => { + if (MEDIA_PATH.test(new URL(request.url()).pathname)) mediaRequests.push(request.url()); + }); + + await page.goto('/listener'); + await expect(page.getByRole('heading', { name: 'Remember your harmonic center.' })).toBeVisible(); + // is only browser-derived on the canonical listener + // host (root layout); the preview host keeps the event default, so + // the English evidence here is the rendered Listener copy itself. + await expectTouchTarget(page.getByRole('link', { name: 'Enter the Beacon' }), 'Enter the Beacon'); + await expectNoHorizontalScroll(page); + await expect(page.locator('audio, video')).toHaveCount(0); + expect(mediaRequests).toEqual([]); + await expectAccessible(page, testInfo, 'listener-public-en'); + }); + }); + + test('authorized one-action Listener stays in bounds with accessible touch targets', async ({ page }, testInfo) => { + const response = await page.request.post('/api/early-birds/test-login', { + headers: { + authorization: 'Bearer early-birds-e2e-login-secret-not-for-production', + 'x-forwarded-proto': 'https', + 'x-forwarded-for': PROJECT_IP[testInfo.project.name] ?? '198.51.100.20', + }, + data: { + email: `responsive-listener-${testInfo.project.name}@e2e.invalid`, + name: 'Responsive Listener', + }, + }); + expect(response.status()).toBe(200); + + await page.goto('/listener'); + await expect(page.getByRole('heading', { name: 'Beacon' })).toBeAttached(); + await expectNoHorizontalScroll(page); + const account = page.locator('.listener-account > summary'); + await expect(account).toHaveAttribute('aria-label', 'Cuenta'); + await expectTouchTarget(account, 'Cuenta'); + + // One clear contextual primary action in the ready state. + await expect(page.locator('.listener-experience')).toHaveAttribute('data-phase', 'ready'); + const primary = page.locator('.listener-transport__primary'); + await expect(primary).toHaveCount(1); + await expect(primary).toBeEnabled(); + await expect(primary).toHaveAccessibleName('Escuchar'); + await expectTouchTarget(primary, 'Escuchar'); + await expectAccessible(page, testInfo, 'listener-authorized'); + }); +}); diff --git a/e2e/tests/early-birds.spec.ts b/e2e/tests/early-birds.spec.ts new file mode 100644 index 00000000..d9945323 --- /dev/null +++ b/e2e/tests/early-birds.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Listener staging boundary', () => { + test.beforeEach(async ({ page }) => { + await page.setExtraHTTPHeaders({ 'x-forwarded-proto': 'https' }); + }); + + test('serves the current bilingual public journey with only configured entry methods', async ({ page }) => { + await page.goto('/listener'); + + await expect(page.getByRole('heading', { name: 'Recuerda tu centro armónico.' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Entrar al Beacon' })).toBeVisible(); + await expect(page.getByText('Acceso de equipo · staging')).toBeVisible(); + await expect(page.getByRole('button', { name: /Continuar con Google|Continuar con Apple/ })).toHaveCount(0); + await expect(page.getByLabel('Correo electrónico')).toHaveCount(0); + }); + + test('keeps the legacy private-home URL as a canonical compatibility redirect', async ({ page }) => { + await page.goto('/early-birds/home'); + + await expect(page).toHaveURL(/\/listener$/); + await expect(page.getByRole('heading', { name: 'Recuerda tu centro armónico.' })).toBeVisible(); + }); + + test('submits the staging team credential once and never persists it', async ({ page }) => { + const accessCode = 'browser-entered-staging-code-000000000001'; + let authorization = ''; + await page.route('**/api/early-birds/test-login', async (route) => { + authorization = route.request().headers().authorization ?? ''; + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'Synthetic login failed.' }), + }); + }); + + await page.goto('/listener'); + await page.getByLabel('Nombre de prueba').fill('Browser Team Listener'); + await page.getByLabel('Cuenta sintética').fill('browser.team@e2e.invalid'); + await page.getByLabel('Código de acceso temporal').fill(accessCode); + await page.getByRole('button', { name: 'Entrar a staging' }).click(); + + await expect(page.getByText('El acceso de prueba no está disponible o los datos no son válidos.')).toBeVisible(); + expect(authorization).toBe(`Bearer ${accessCode}`); + await expect(page.getByLabel('Código de acceso temporal')).toHaveValue(''); + expect(await page.evaluate(() => JSON.stringify({ + local: { ...localStorage }, + session: { ...sessionStorage }, + }))).not.toContain(accessCode); + }); + + test('creates an isolated synthetic session and reaches the current one-action Listener', async ({ page }) => { + const response = await page.request.post('/api/early-birds/test-login', { + headers: { + authorization: 'Bearer early-birds-e2e-login-secret-not-for-production', + 'x-forwarded-proto': 'https', + }, + data: { + email: 'listener@e2e.invalid', + name: 'Synthetic Listener', + }, + }); + expect(response.status()).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + landing: '/early-birds', + }); + + await page.goto('/listener'); + await expect(page.getByRole('heading', { name: 'Beacon' })).toBeAttached(); + await expect(page.getByRole('radio', { name: 'Con introducción' })).toBeVisible(); + await expect(page.getByRole('radio', { name: 'Solo Beacon' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Escuchar' })).toBeVisible(); + await page.locator('.listener-account > summary').click(); + await expect(page.getByText('Synthetic Listener')).toBeVisible(); + await expect(page.getByText('Acceso de prueba')).toBeVisible(); + }); +}); diff --git a/e2e/tests/listener-account-switch.spec.ts b/e2e/tests/listener-account-switch.spec.ts new file mode 100644 index 00000000..99af6d01 --- /dev/null +++ b/e2e/tests/listener-account-switch.spec.ts @@ -0,0 +1,146 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +import { requireDirectDb } from '../fixtures/db'; +import { + createListenerAccountSwitchPair, + deleteListenerAccountSwitchPair, + LISTENER_ACCOUNT_COOKIE, + listenerAccountSwitchSessionState, + useListenerAccount, +} from '../fixtures/listener-account-switch'; + +async function accessKind(page: Page): Promise { + try { + return await page.evaluate(async () => { + const response = await fetch('/api/listener/access-state', { + credentials: 'same-origin', cache: 'no-store', + }); + if (response.status !== 200) return `status-${response.status}`; + const payload = await response.json() as { access?: { kind?: unknown } }; + return String(payload.access?.kind ?? 'missing'); + }); + } catch (error) { + if (error instanceof Error && /execution context was destroyed|because of a navigation/i + .test(error.message)) return 'navigation-in-progress'; + throw error; + } +} + +async function expectFounder(page: Page): Promise { + await expect.poll(() => accessKind(page)).toBe('membership'); + await expect(page.locator('.listener-experience')).toBeVisible(); + await expect(page.locator('.listener-listening-status')).toHaveCount(0); +} + +async function expectFree(page: Page): Promise { + await expect.poll(() => accessKind(page)).toBe('free-quota'); + await expect(page.locator('.listener-experience')).toBeVisible(); + await expect(page.locator('.listener-listening-status')).toBeVisible(); +} + +// The fixture tokens and direct-database URL must never enter a retained trace. +test.use({ trace: 'off' }); + +test.describe('Listener Account A/B cache boundary', () => { + test.skip( + process.env.E2E_LISTENER_ACCOUNT_SWITCH_GATE !== '1', + 'requires the isolated Account-on E2E server', + ); + + test('never restores Founder presentation or authorization for Free B', async ({ browser }, testInfo) => { + test.setTimeout(90_000); + requireDirectDb(testInfo); + const baseURL = String(testInfo.project.use.baseURL); + const parsedBaseURL = new URL(baseURL); + if (!['localhost', '127.0.0.1', '[::1]'].includes(parsedBaseURL.hostname)) { + throw new Error('refusing to run the Account switch fixture against a non-local app'); + } + + const pair = await createListenerAccountSwitchPair(testInfo); + const contexts: BrowserContext[] = []; + try { + const context = await browser.newContext(); + contexts.push(context); + await useListenerAccount(context, baseURL, pair.founder); + const founderState = await listenerAccountSwitchSessionState(testInfo, pair.founder); + expect(founderState).toEqual({ + present: true, + accountId: pair.founder.accountId, + issuer: 'https://account.harmonicbeacon.com', + expired: expect.any(Boolean), + remainingSeconds: expect.any(Number), + synthetic: false, + }); + expect(founderState.remainingSeconds).toBeGreaterThan(3_000); + expect(founderState.expired).toBe(false); + expect((await context.cookies(baseURL)).some((cookie) => + cookie.name === LISTENER_ACCOUNT_COOKIE)).toBe(true); + const page = await context.newPage(); + await page.addInitScript(() => { + window.addEventListener('pageshow', (event) => { + (window as typeof window & { __hbPageShowPersisted?: boolean }) + .__hbPageShowPersisted = event.persisted; + }); + }); + + const founderResponse = await page.goto('/listener'); + expect(founderResponse?.headers()['cache-control']).toContain('no-store'); + expect((await context.cookies(page.url())).some((cookie) => + cookie.name === LISTENER_ACCOUNT_COOKIE)).toBe(true); + const accessRequest = page.waitForRequest('**/api/listener/access-state'); + const initialKind = await accessKind(page); + const initialHeaders = await (await accessRequest).allHeaders(); + expect(Boolean(initialHeaders.cookie?.startsWith( + `${LISTENER_ACCOUNT_COOKIE}=`, + ))).toBe(true); + await expect(listenerAccountSwitchSessionState(testInfo, pair.founder)).resolves.toEqual({ + present: true, + accountId: pair.founder.accountId, + issuer: 'https://account.harmonicbeacon.com', + expired: false, + remainingSeconds: expect.any(Number), + synthetic: false, + }); + expect(initialKind).toBe('membership'); + await expectFounder(page); + + // Leave A in browser history, switch the host-only RP cookie to B, + // and go back. A bfcache restoration must not revive A's Founder UI. + await page.goto('/listener/privacy'); + await useListenerAccount(context, baseURL, pair.free); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expect(page).toHaveURL(/\/listener$/); + await expectFree(page); + // The Account-derived response is deliberately not bfcacheable in + // current engines, so this history traversal must fetch B anew. + // Assert this only after B's server-derived presentation is stable: + // Chromium can destroy the first DOMContentLoaded execution context + // while completing a history traversal. + expect(await page.evaluate(() => Boolean( + (window as typeof window & { __hbPageShowPersisted?: boolean }) + .__hbPageShowPersisted, + ))).toBe(false); + + await page.goForward({ waitUntil: 'domcontentloaded' }); + await expect(page).toHaveURL(/\/listener\/privacy$/); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectFree(page); + + // Reload and a second tab must both derive only B's server session. + await page.reload({ waitUntil: 'domcontentloaded' }); + await expectFree(page); + const duplicate = await context.newPage(); + await duplicate.goto('/listener'); + await expectFree(duplicate); + + // Switching back to A restores Founder only after A's cookie is + // authoritative again; B's page cannot grant it by itself. + await useListenerAccount(context, baseURL, pair.founder); + await duplicate.reload({ waitUntil: 'domcontentloaded' }); + await expectFounder(duplicate); + } finally { + await Promise.allSettled(contexts.map((context) => context.close())); + await deleteListenerAccountSwitchPair(testInfo, pair.accountIds); + } + }); +}); diff --git a/e2e/tests/listener-namespace-compat.spec.ts b/e2e/tests/listener-namespace-compat.spec.ts new file mode 100644 index 00000000..4d6a209b --- /dev/null +++ b/e2e/tests/listener-namespace-compat.spec.ts @@ -0,0 +1,129 @@ +import { expect, test } from '@playwright/test'; + +import { + EARLY_BIRD_INVITATION_COOKIE, + LISTENER_INVITATION_COOKIE, +} from '../../src/lib/early-birds/invitation-cookie'; +import { + deleteSyntheticListenerEmails, + signInSyntheticListener, +} from '../fixtures/listener-boundary'; +import { requireDirectDb } from '../fixtures/db'; + +const INVITATION = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)}`; + +// The fixture bearer and cookies must never enter a retained browser trace. +test.use({ trace: 'off' }); + +test.describe('Listener namespace compatibility', () => { + test('keeps a public invitation through a same-browser identity round trip', async ({ browser, request }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium', 'one browser proves the provider-independent cookie contract'); + requireDirectDb(testInfo); + const baseURL = new URL(String(testInfo.project.use.baseURL)); + if (!['localhost', '127.0.0.1', '[::1]'].includes(baseURL.hostname)) { + throw new Error('refusing to run Listener namespace E2E against a non-local application'); + } + + const email = `invitation-roundtrip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@e2e.invalid`; + const context = await browser.newContext(); + try { + // Middleware owns the public query-to-cookie exchange. The browser + // exercise starts immediately after that edge boundary and proves + // the host-only bearer survives identity session creation without + // ever becoming script-readable. Google and magic link use this + // same callback cookie contract; their exact callback is locked by + // the landing/auth route unit suites. + await context.addCookies([{ + name: LISTENER_INVITATION_COOKIE, + value: INVITATION, + domain: baseURL.hostname, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + }]); + // A provider callback adds its session cookie to the returning + // browser; it does not forward the pre-existing invitation to the + // provider. Mint the synthetic session out of band, then apply its + // Set-Cookie result to model that exact boundary. + await signInSyntheticListener(request, email); + const identityState = await request.storageState(); + await context.addCookies(identityState.cookies.filter((cookie) => cookie.name.includes('session'))); + + const cookiesAfterIdentity = await context.cookies(); + expect(cookiesAfterIdentity.find((cookie) => cookie.name === LISTENER_INVITATION_COOKIE)) + .toMatchObject({ value: INVITATION, httpOnly: true, secure: true, sameSite: 'Lax' }); + expect(cookiesAfterIdentity.some((cookie) => cookie.name.includes('session'))).toBe(true); + + const page = await context.newPage(); + await page.goto('/listener/redeem'); + await expect(page.getByRole('button', { name: /Activar invitación|Activate invitation/ })) + .toBeVisible(); + } finally { + await context.close(); + await deleteSyntheticListenerEmails(testInfo, [email]); + } + }); + + test('keeps a legacy invitation cookie and Listener session through canonical refresh and redemption', async ({ browser }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium', 'one browser proves the namespace/session contract'); + requireDirectDb(testInfo); + const baseURL = new URL(String(testInfo.project.use.baseURL)); + if (!['localhost', '127.0.0.1', '[::1]'].includes(baseURL.hostname)) { + throw new Error('refusing to run Listener namespace E2E against a non-local application'); + } + + const email = `namespace-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@e2e.invalid`; + const context = await browser.newContext(); + try { + await signInSyntheticListener(context.request, email); + const sessionCookie = (await context.cookies()).find((cookie) => ( + cookie.name !== EARLY_BIRD_INVITATION_COOKIE + && cookie.name.includes('session') + )); + expect(sessionCookie).toBeDefined(); + + await context.addCookies([{ + name: EARLY_BIRD_INVITATION_COOKIE, + value: INVITATION, + domain: baseURL.hostname, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Lax', + }]); + + const page = await context.newPage(); + await page.goto('/listener'); + await page.getByRole('link', { name: /Activar mi invitación|Activate my invitation/ }).click(); + await expect(page).toHaveURL(/\/listener\/redeem$/); + await page.reload(); + await expect(page.getByRole('button', { name: /Activar invitación|Activate invitation/ })).toBeVisible(); + + let canonicalRedemption = 0; + await page.route('**/api/listener/free/redeem', async (route) => { + canonicalRedemption += 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { + 'set-cookie': `${EARLY_BIRD_INVITATION_COOKIE}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax`, + }, + body: JSON.stringify({ ok: true, landing: '/listener', replayed: false }), + }); + }); + await page.getByRole('button', { name: /Activar invitación|Activate invitation/ }).click(); + + await expect(page).toHaveURL(/\/listener$/); + await expect(page.getByRole('button', { name: /Cerrar sesión|Sign out/ })).toBeVisible(); + expect(canonicalRedemption).toBe(1); + const cookies = await context.cookies(); + expect(cookies.find((cookie) => cookie.name === EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + expect(cookies.find((cookie) => cookie.name === sessionCookie!.name)?.value) + .toBe(sessionCookie!.value); + } finally { + await context.close(); + await deleteSyntheticListenerEmails(testInfo, [email]); + } + }); +}); diff --git a/e2e/tests/listener-network-resilience.spec.ts b/e2e/tests/listener-network-resilience.spec.ts new file mode 100644 index 00000000..682bdbbd --- /dev/null +++ b/e2e/tests/listener-network-resilience.spec.ts @@ -0,0 +1,605 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { expect, test } from '@playwright/test'; + +const execFileAsync = promisify(execFile); +const STREAM_ORIGIN = 'https://stream.e2e.invalid'; +const MEDIA_SEGMENT_SECONDS = 6; +const SOURCE_FIXTURE_SEGMENTS = 240; +const SOURCE_WINDOW_SEGMENTS = 50; +const SOURCE_PROGRAM_EPOCH_MS = Date.parse('2026-08-20T00:00:00.000Z'); +// Browsers may retain a sub-frame tail in TimeRanges after the decoder clock +// has genuinely stalled. Half a second is still two orders of magnitude below +// the promised reservoir and prevents pretending that millisecond rounding is +// audible continuity. +const EXHAUSTED_MEDIA_TOLERANCE_SECONDS = 0.5; + +type HlsFixture = { + root: string; + initialization: string; + segments: string[]; +}; + +async function createHlsFixture(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'hb-listener-network-')); + await execFileAsync('ffmpeg', [ + '-hide_banner', + '-loglevel', 'error', + '-f', 'lavfi', + '-i', 'sine=frequency=440:sample_rate=48000', + '-t', String(MEDIA_SEGMENT_SECONDS * SOURCE_FIXTURE_SEGMENTS), + '-c:a', 'aac', + '-b:a', '64k', + '-f', 'hls', + '-hls_time', String(MEDIA_SEGMENT_SECONDS), + '-hls_list_size', '0', + '-hls_segment_type', 'fmp4', + '-hls_fmp4_init_filename', 'init.mp4', + '-hls_segment_filename', path.join(root, '%05d.m4s'), + path.join(root, 'source.m3u8'), + ]); + const manifest = await readFile(path.join(root, 'source.m3u8'), 'utf8'); + const initialization = /#EXT-X-MAP:URI="([^"]+)"/.exec(manifest)?.[1]; + const generatedSegments = manifest.split('\n').filter((line) => /^\d{5}\.m4s$/.test(line)); + // ffmpeg may emit one final encoder-drain fragment after the requested + // boundary. Keep a monotonic 24-minute source behind the rolling 5-minute + // manifest so a slower CI runner never wraps media timestamps mid-outage. + const segments = generatedSegments.slice(0, SOURCE_FIXTURE_SEGMENTS); + if (!initialization || segments.length !== SOURCE_FIXTURE_SEGMENTS) { + throw new Error(`unexpected HLS fixture inventory: ${generatedSegments.length}`); + } + return { root, initialization, segments }; +} + +function renderLiveManifest(fixture: HlsFixture, edgeSequence: number): string { + const firstSequence = Math.max(0, edgeSequence - (SOURCE_WINDOW_SEGMENTS - 1)); + const lines = [ + '#EXTM3U', + '#EXT-X-VERSION:7', + `#EXT-X-TARGETDURATION:${MEDIA_SEGMENT_SECONDS}`, + '#EXT-X-DISCONTINUITY-SEQUENCE:0', + `#EXT-X-MEDIA-SEQUENCE:${firstSequence}`, + '#EXT-X-INDEPENDENT-SEGMENTS', + `#EXT-X-MAP:URI="${fixture.initialization}"`, + ]; + for (let sequence = firstSequence; sequence <= edgeSequence; sequence += 1) { + const index = sequence; + if (!fixture.segments[index]) break; + lines.push(`#EXT-X-PROGRAM-DATE-TIME:${new Date( + SOURCE_PROGRAM_EPOCH_MS + sequence * MEDIA_SEGMENT_SECONDS * 1_000, + ).toISOString()}`); + lines.push(`#EXTINF:${MEDIA_SEGMENT_SECONDS.toFixed(6)},`); + lines.push(fixture.segments[index]); + } + return `${lines.join('\n')}\n`; +} + +async function mediaState(page: import('@playwright/test').Page) { + return page.locator('audio[aria-label="Beacon"]').evaluate((media: HTMLAudioElement) => { + let bufferedAheadSeconds = 0; + for (let index = 0; index < media.buffered.length; index += 1) { + const start = media.buffered.start(index); + const end = media.buffered.end(index); + if (media.currentTime >= start - 0.25 && media.currentTime <= end + 0.25) { + bufferedAheadSeconds = Math.max(0, end - media.currentTime); + break; + } + } + return { + currentTime: media.currentTime, + bufferedAheadSeconds, + paused: media.paused, + ended: media.ended, + readyState: media.readyState, + errorCode: media.error?.code ?? null, + playbackRate: media.playbackRate, + }; + }); +} + +async function retainedReservoirSeconds(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const diagnostics = (window as typeof window & { __hbNetworkDiagnostics?: unknown[] }) + .__hbNetworkDiagnostics ?? []; + let retainedSeconds = 0; + for (const diagnostic of diagnostics) { + if (!diagnostic || typeof diagnostic !== 'object') continue; + const value = (diagnostic as { reservoirAheadSeconds?: unknown }).reservoirAheadSeconds; + if (typeof value === 'number' && Number.isFinite(value)) { + retainedSeconds = value; + } + } + return retainedSeconds; + }); +} + +async function availablePlaybackSeconds(page: import('@playwright/test').Page): Promise { + const [media, retained] = await Promise.all([ + mediaState(page), + retainedReservoirSeconds(page), + ]); + return media.bufferedAheadSeconds + retained; +} + +async function waitForAvailablePlaybackSeconds( + page: import('@playwright/test').Page, + minimumSeconds: number, + timeout: number, + message: string, +): Promise { + try { + await page.waitForFunction((minimum) => { + const media = document.querySelector('audio[aria-label="Beacon"]'); + if (!media) return false; + let bufferedAheadSeconds = 0; + for (let index = 0; index < media.buffered.length; index += 1) { + const start = media.buffered.start(index); + const end = media.buffered.end(index); + if (media.currentTime >= start - 0.25 && media.currentTime <= end + 0.25) { + bufferedAheadSeconds = Math.max(0, end - media.currentTime); + break; + } + } + const diagnostics = (window as typeof window & { __hbNetworkDiagnostics?: unknown[] }) + .__hbNetworkDiagnostics ?? []; + let retainedSeconds = 0; + for (const diagnostic of diagnostics) { + if (!diagnostic || typeof diagnostic !== 'object') continue; + const value = (diagnostic as { reservoirAheadSeconds?: unknown }) + .reservoirAheadSeconds; + if (typeof value === 'number' && Number.isFinite(value)) retainedSeconds = value; + } + return bufferedAheadSeconds + retainedSeconds >= minimum; + }, minimumSeconds, { timeout, polling: 250 }); + } catch (error) { + const evidence = await page.evaluate(() => { + const media = document.querySelector('audio[aria-label="Beacon"]'); + const diagnostics = (window as typeof window & { __hbNetworkDiagnostics?: unknown[] }) + .__hbNetworkDiagnostics ?? []; + return { + currentTime: media?.currentTime ?? null, + readyState: media?.readyState ?? null, + errorCode: media?.error?.code ?? null, + retainedSeconds: diagnostics.reduce((latest, diagnostic) => { + if (!diagnostic || typeof diagnostic !== 'object') return latest; + const value = (diagnostic as { reservoirAheadSeconds?: unknown }) + .reservoirAheadSeconds; + return typeof value === 'number' && Number.isFinite(value) ? value : latest; + }, 0), + recent: diagnostics.slice(-8).map((diagnostic) => { + if (!diagnostic || typeof diagnostic !== 'object') return null; + const record = diagnostic as { + reason?: unknown; + action?: unknown; + reservoirAheadSeconds?: unknown; + media?: { bufferedAheadSeconds?: unknown }; + hls?: { type?: unknown; details?: unknown; fatal?: unknown }; + }; + return { + reason: record.reason, + action: record.action, + reservoirAheadSeconds: record.reservoirAheadSeconds, + bufferedAheadSeconds: record.media?.bufferedAheadSeconds, + hlsType: record.hls?.type, + hlsDetails: record.hls?.details, + hlsFatal: record.hls?.fatal, + }; + }), + }; + }); + throw new Error(`${message}: ${JSON.stringify(evidence)}`, { cause: error }); + } +} + +async function reservoirSnapshotCount(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => ( + ((window as typeof window & { __hbNetworkDiagnostics?: Array<{ reason?: unknown }> }) + .__hbNetworkDiagnostics ?? []) + .filter((diagnostic) => diagnostic?.reason === 'reservoir-ready').length + )); +} + +async function lastRecoveryBufferedAheadSeconds( + page: import('@playwright/test').Page, +): Promise { + return page.evaluate(() => { + const diagnostics = (window as typeof window & { __hbNetworkDiagnostics?: unknown[] }) + .__hbNetworkDiagnostics ?? []; + for (let index = diagnostics.length - 1; index >= 0; index -= 1) { + const diagnostic = diagnostics[index]; + if (!diagnostic || typeof diagnostic !== 'object') continue; + const record = diagnostic as { + reason?: unknown; + media?: { bufferedAheadSeconds?: unknown }; + }; + if (![ + 'media-clock-stalled', + 'paused-unexpectedly', + 'ended-unexpectedly', + 'media-error', + ].includes(String(record.reason))) continue; + const value = record.media?.bufferedAheadSeconds; + return typeof value === 'number' && Number.isFinite(value) ? value : null; + } + return null; + }); +} + +test.describe('Listener network resilience', () => { + test.skip(process.env.E2E_LISTENER_NETWORK_GATE !== '1', 'focused network gate is opt-in'); + test.slow(); + + let fixture: HlsFixture; + + test.beforeAll(async () => { + fixture = await createHlsFixture(); + }); + + test.afterAll(async () => { + if (fixture?.root) await rm(fixture.root, { recursive: true, force: true }); + }); + + test('preserves the filled buffer through outages and refills without a new lease', async ({ + page, + browserName, + }, testInfo) => { + test.setTimeout(720_000); + let sourceElapsedMediaSeconds = 0; + let sourceClockUpdatedAt = Date.now(); + let sourcePlaybackRate = 1; + // Keep the synthetic live edge aligned with the listener clock. A + // permanently 4x origin makes a slow decoder fall out of the rolling + // window before the test has even enabled accelerated playback. + const currentSourceElapsedMediaSeconds = () => ( + sourceElapsedMediaSeconds + + (Date.now() - sourceClockUpdatedAt) / 1_000 * sourcePlaybackRate + ); + const setSourcePlaybackRate = (nextRate: number) => { + sourceElapsedMediaSeconds = currentSourceElapsedMediaSeconds(); + sourceClockUpdatedAt = Date.now(); + sourcePlaybackRate = nextRate; + }; + let originOnline = true; + let originDelayMs = 0; + let failEveryMediaRequest = 0; + let leaseRequests = 0; + let heartbeatRequests = 0; + let manifestRequests = 0; + let mediaRequests = 0; + const diagnosticEvents: unknown[] = []; + + await page.addInitScript(() => { + (window as typeof window & { __hbNetworkDiagnostics?: unknown[] }) + .__hbNetworkDiagnostics = []; + window.addEventListener('listener:playback-diagnostic', (event) => { + (window as typeof window & { __hbNetworkDiagnostics: unknown[] }) + .__hbNetworkDiagnostics.push((event as CustomEvent).detail); + const diagnostics = (window as typeof window & { __hbNetworkDiagnostics: unknown[] }) + .__hbNetworkDiagnostics; + if (diagnostics.length > 256) diagnostics.splice(0, diagnostics.length - 256); + }); + }); + await page.route(`${STREAM_ORIGIN}/**`, async (route) => { + if (!originOnline) { + await route.abort('internetdisconnected'); + return; + } + const url = new URL(route.request().url()); + const file = path.basename(url.pathname); + if (file === 'live.m3u8') { + manifestRequests += 1; + const elapsedMediaSeconds = currentSourceElapsedMediaSeconds(); + const edgeSequence = Math.min( + SOURCE_FIXTURE_SEGMENTS - 1, + SOURCE_WINDOW_SEGMENTS - 1 + + Math.floor(elapsedMediaSeconds / MEDIA_SEGMENT_SECONDS), + ); + await route.fulfill({ + status: 200, + contentType: 'application/vnd.apple.mpegurl', + headers: { + 'access-control-allow-origin': '*', + 'cache-control': 'no-store', + }, + body: renderLiveManifest(fixture, edgeSequence), + }); + return; + } + const safeFile = file === fixture.initialization || fixture.segments.includes(file); + if (!safeFile) { + await route.fulfill({ status: 404, body: '' }); + return; + } + mediaRequests += 1; + if (failEveryMediaRequest > 0 && mediaRequests % failEveryMediaRequest === 0) { + await route.abort('connectionreset'); + return; + } + if (originDelayMs > 0) { + const deterministicJitterMs = (mediaRequests % 5) * 75; + await new Promise((resolve) => setTimeout(resolve, originDelayMs + deterministicJitterMs)); + } + await route.fulfill({ + status: 200, + contentType: file.endsWith('.mp4') ? 'video/mp4' : 'video/iso.segment', + headers: { + 'access-control-allow-origin': '*', + 'cache-control': 'public, max-age=31536000, immutable', + }, + body: await readFile(path.join(fixture.root, file)), + }); + }); + + const lease = { + leaseId: '00000000-0000-4000-8000-000000000419', + leaseGeneration: 1, + presenceSequence: 0, + leaseExpiresAt: '2099-08-20T23:59:00.000Z', + stream: { + manifestUrl: `${STREAM_ORIGIN}/approved/live.m3u8?grantId=${'a'.repeat(64)}&grant=${'b'.repeat(43)}`, + expiresAt: '2099-08-20T23:59:00.000Z', + }, + }; + await page.route('**/api/early-birds/stream/lease', async (route) => { + leaseRequests += 1; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(lease) }); + }); + await page.route('**/api/early-birds/stream/heartbeat', async (route) => { + heartbeatRequests += 1; + const body = route.request().postDataJSON() as { presenceSequence?: number } | null; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ...lease, + presenceSequence: body?.presenceSequence ?? 0, + }), + }); + }); + + await page.goto('/early-birds'); + const codecSupport = await page.evaluate(() => ({ + mediaSource: typeof MediaSource !== 'undefined' + && MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"'), + audio: document.createElement('audio') + .canPlayType('audio/mp4; codecs="mp4a.40.2"'), + })); + expect(codecSupport.mediaSource, `${browserName} lacks the approved AAC/fMP4 MSE codec`) + .toBe(true); + expect(codecSupport.audio, `${browserName} cannot decode the approved AAC-LC stream`) + .not.toBe(''); + const listen = page.getByRole('button', { name: /Listen|Escuchar/ }); + await expect(listen).toBeEnabled({ timeout: 20_000 }); + await listen.click(); + await expect(page.getByRole('button', { name: /Stop|Detener/ })).toBeVisible(); + + await waitForAvailablePlaybackSeconds( + page, + 180, + 90_000, + `${browserName} did not fill the promised Listener buffer`, + ); + await expect.poll(async () => (await mediaState(page)).bufferedAheadSeconds, { + timeout: 20_000, + message: `${browserName} did not make retained audio immediately playable`, + }).toBeGreaterThan(5); + const achievedBufferSeconds = await availablePlaybackSeconds(page); + await page.locator('audio[aria-label="Beacon"]').evaluate((media: HTMLAudioElement) => { + media.playbackRate = 4; + }); + setSourcePlaybackRate(4); + + const outageDurations = [5, 15, 30, 60]; + for (const outageMediaSeconds of outageDurations) { + const before = await mediaState(page); + originOnline = false; + await expect.poll(async () => (await mediaState(page)).currentTime - before.currentTime, { + timeout: Math.ceil(outageMediaSeconds / 4 * 1_000) + 15_000, + message: `${browserName} stopped inside a ${outageMediaSeconds}s buffered outage`, + }).toBeGreaterThanOrEqual(outageMediaSeconds); + const during = await mediaState(page); + expect(during.paused).toBe(false); + expect(during.ended).toBe(false); + expect(during.errorCode).toBeNull(); + await expect(page.getByRole('button', { name: /Stop|Detener/ })).toBeVisible(); + + originOnline = true; + const snapshotsBeforeRefill = await reservoirSnapshotCount(page); + const manifestsBeforeRefill = manifestRequests; + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect.poll(() => manifestRequests, { + timeout: 20_000, + message: `${browserName} did not refresh its manifest after reconnecting`, + }).toBeGreaterThan(manifestsBeforeRefill); + await expect.poll(async () => reservoirSnapshotCount(page), { + timeout: 30_000, + message: `${browserName} did not complete a reservoir refill`, + }).toBeGreaterThan(snapshotsBeforeRefill); + await waitForAvailablePlaybackSeconds( + page, + 180, + 45_000, + `${browserName} did not refill after a ${outageMediaSeconds}s outage`, + ); + await expect(page.locator('.listener-experience[data-phase="beacon"]')) + .toBeVisible({ timeout: 20_000 }); + } + + // Latency, deterministic jitter and intermittent segment loss must + // consume/refill the same buffer without replacing the lease. + await page.locator('audio[aria-label="Beacon"]').evaluate((media: HTMLAudioElement) => { + media.playbackRate = 1; + }); + setSourcePlaybackRate(1); + originDelayMs = 250; + failEveryMediaRequest = 9; + const degradedStarted = (await mediaState(page)).currentTime; + await expect.poll(async () => (await mediaState(page)).currentTime - degradedStarted, { + timeout: 35_000, + message: `${browserName} stopped under latency, jitter and intermittent loss`, + }).toBeGreaterThanOrEqual(15); + expect((await mediaState(page)).paused).toBe(false); + originDelayMs = 0; + failEveryMediaRequest = 0; + const degradedSnapshots = await reservoirSnapshotCount(page); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect.poll(async () => reservoirSnapshotCount(page), { timeout: 30_000 }) + .toBeGreaterThan(degradedSnapshots); + await page.locator('audio[aria-label="Beacon"]').evaluate((media: HTMLAudioElement) => { + media.playbackRate = 4; + }); + setSourcePlaybackRate(4); + await waitForAvailablePlaybackSeconds( + page, + 180, + 45_000, + `${browserName} did not refill after degraded connectivity`, + ); + + // Exercise an outage ten seconds below the promised three-minute + // target. The MediaSource and reservoir counters are intentionally + // separate and may briefly overstate what the decoder can consume, so + // never turn their sum into a larger product promise here. + const nearLimit = await mediaState(page); + expect(nearLimit.paused).toBe(false); + const availableNearLimitSeconds = await availablePlaybackSeconds(page); + const nearLimitOutageSeconds = Math.max( + 60, + Math.min(170, Math.floor(availableNearLimitSeconds - 10)), + ); + originOnline = false; + await expect.poll(async () => (await mediaState(page)).currentTime - nearLimit.currentTime, { + // An accelerated browser decoder can be heavily throttled after + // its origin disappears. Assert media-clock progress, but give it + // a fixed wall-clock budget independent of a short, optimistic + // playback-rate probe. + timeout: 240_000, + message: `${browserName} did not preserve playback near its measured buffer limit`, + }).toBeGreaterThanOrEqual(nearLimitOutageSeconds); + expect((await mediaState(page)).paused).toBe(false); + originOnline = true; + const nearLimitSnapshots = await reservoirSnapshotCount(page); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect.poll(async () => reservoirSnapshotCount(page), { timeout: 30_000 }) + .toBeGreaterThan(nearLimitSnapshots); + await waitForAvailablePlaybackSeconds( + page, + 180, + 60_000, + `${browserName} did not refill after its near-limit outage`, + ); + await expect(page.locator('.listener-experience[data-phase="beacon"]')) + .toBeVisible({ timeout: 20_000 }); + + // Once the measured buffer is genuinely exhausted the UI may switch + // to reconnecting, but it must keep trying and resume without a new + // lease or a manual reload when the origin returns. + const beforeExhaustion = await mediaState(page); + const retainedBeforeExhaustion = await retainedReservoirSeconds(page); + const rateProbeStartedAt = Date.now(); + await page.waitForTimeout(2_000); + const rateProbeEnded = await mediaState(page); + expect(rateProbeEnded.paused).toBe(false); + expect(rateProbeEnded.currentTime - beforeExhaustion.currentTime).toBeGreaterThan(0.5); + const effectivePlaybackRate = Math.max( + 0.5, + Math.min( + 4, + (rateProbeEnded.currentTime - beforeExhaustion.currentTime) + / ((Date.now() - rateProbeStartedAt) / 1_000), + ), + ); + originOnline = false; + const exhaustionDeadline = Date.now() + // MediaSource and the memory reservoir can hold partially distinct + // fragments. Their sum is a conservative upper bound; using only + // the larger value races WebKit while it is still consuming valid + // bytes from the other layer. + + Math.ceil(( + beforeExhaustion.bufferedAheadSeconds + + retainedBeforeExhaustion + ) / effectivePlaybackRate * 1_000) + + 60_000; + let maxCurrentTime = rateProbeEnded.currentTime; + let exhaustedState: Awaited> | null = null; + while (Date.now() < exhaustionDeadline) { + const state = await mediaState(page); + maxCurrentTime = Math.max(maxCurrentTime, state.currentTime); + const phase = await page.locator('.listener-experience').getAttribute('data-phase'); + if (phase === 'reconnecting') { + exhaustedState = state; + break; + } + await page.waitForTimeout(250); + } + expect(exhaustedState, `${browserName} did not enter reconnecting after exhausting retained audio`) + .not.toBeNull(); + expect(await lastRecoveryBufferedAheadSeconds(page)) + .toBeLessThanOrEqual(EXHAUSTED_MEDIA_TOLERANCE_SECONDS); + expect(maxCurrentTime - beforeExhaustion.currentTime) + .toBeGreaterThanOrEqual(Math.max(0, beforeExhaustion.bufferedAheadSeconds - 5)); + await expect(page.locator('.listener-experience[data-phase="reconnecting"]')) + .toBeVisible(); + const reconnectingObservedAt = Date.now(); + await expect(page.getByText(/unavailable right now|no está disponible/i)).toHaveCount(0); + + // An OS/browser `online` event is only advisory: captive portals and + // partial network recovery can emit it while the stream origin remains + // unreachable. It must not rebuild MediaSource, reset currentTime or + // mint a replacement lease before the bounded manifest probe succeeds. + const beforeAdvisoryOnline = await mediaState(page); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await page.waitForTimeout(2_500); + await expect(page.locator('.listener-experience[data-phase="reconnecting"]')) + .toBeVisible(); + const afterAdvisoryOnline = await mediaState(page); + expect(afterAdvisoryOnline.currentTime) + .toBeGreaterThanOrEqual(beforeAdvisoryOnline.currentTime - 1); + expect(leaseRequests).toBe(1); + + originOnline = true; + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect(page.locator('.listener-experience[data-phase="beacon"]')) + .toBeVisible({ timeout: 45_000 }); + await expect(page.getByRole('button', { name: /Stop|Detener/ })).toBeVisible(); + const resumedAt = (await mediaState(page)).currentTime; + await expect.poll(async () => (await mediaState(page)).currentTime, { + timeout: 20_000, + message: `${browserName} did not resume its media clock after exhaustion`, + }).toBeGreaterThan(resumedAt + 2); + const recoveredAt = Date.now(); + + expect(leaseRequests).toBe(1); + expect(heartbeatRequests).toBeGreaterThan(0); + expect(manifestRequests).toBeGreaterThan(1); + expect(mediaRequests).toBeGreaterThanOrEqual(30); + diagnosticEvents.push(...await page.evaluate(() => ( + (window as typeof window & { __hbNetworkDiagnostics?: unknown[] }) + .__hbNetworkDiagnostics ?? [] + ))); + expect(diagnosticEvents.length).toBeGreaterThan(0); + expect(JSON.stringify(diagnosticEvents)).not.toMatch(/account|cookie|email|leaseId|token|url/i); + await testInfo.attach('listener-network-evidence.json', { + contentType: 'application/json', + body: Buffer.from(JSON.stringify({ + schemaVersion: 1, + browserName, + bufferTargetSeconds: 180, + achievedBufferSeconds, + effectivePlaybackRate, + outageMediaSeconds: outageDurations, + nearLimitOutageSeconds, + exhaustionRecoveryMs: Math.max(0, recoveredAt - reconnectingObservedAt), + leaseRequests, + heartbeatRequests, + manifestRequests, + mediaRequests, + diagnosticCount: diagnosticEvents.length, + })), + }); + }); +}); diff --git a/middleware.test.ts b/middleware.test.ts index f4a70c23..bd0e8353 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -1,12 +1,19 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { NextRequest, NextResponse } from 'next/server'; import { SESSION_COOKIE_NAME } from './src/lib/session-auth'; +import { + EARLY_BIRD_INVITATION_COOKIE, + EARLY_BIRD_INVITATION_MAX_AGE_SECONDS, + LISTENER_INVITATION_COOKIE, +} from './src/lib/early-birds/invitation-cookie'; // `src/middleware.ts`, not the repository root: Next only loads the middleware // convention from inside `src` when the app lives there, and a root-level file is // silently ignored — which is what it had been doing. import middleware, { config } from './src/middleware'; +const INVITATION = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)}`; + /** * Middleware is navigation convenience, not the authorization boundary. * @@ -18,12 +25,18 @@ import middleware, { config } from './src/middleware'; * one that should make that obvious. */ -function request(pathname: string, cookie?: string): NextRequest { +function request( + pathname: string, + cookie?: string, + hostname = 'live.harmonicbeacon.com', + extraHeaders: Record = {}, +): NextRequest { const headers = new Headers(); if (cookie) { headers.set('cookie', cookie); } - return new NextRequest(new URL(pathname, 'https://live.harmonicbeacon.com'), { headers }); + for (const [name, value] of Object.entries(extraHeaders)) headers.set(name, value); + return new NextRequest(new URL(pathname, `https://${hostname}`), { headers }); } function location(response: NextResponse): URL { @@ -31,6 +44,255 @@ function location(response: NextResponse): URL { } describe('middleware', () => { + afterEach(() => vi.unstubAllEnvs()); + + describe('dedicated Account runtime boundary', () => { + function accountRequest(pathname: string, hostname = 'account.harmonicbeacon.com') { + vi.stubEnv('BEACON_ACCOUNT_RUNTIME', '1'); + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com'); + return middleware(request(pathname, undefined, hostname)); + } + + it.each([ + '/', '/account', '/verify-email', '/reset-password', + '/assets/hb-global-nav.js', + '/.well-known/openid-configuration', '/api/account/health/ready', + '/_next/static/chunk.js', + ])('allows only the exact Account route inventory: %s', (pathname) => { + expect(accountRequest(pathname).status).toBe(200); + }); + + it.each([ + '/listener', '/early-birds', '/session/active', '/ops', + '/nav-slot', + '/assets/other.js', '/assets/hb-global-nav.js/extra', + '/api/early-birds/stream', '/api/livekit/token', + '/api/founding-listeners/checkout', '/api/auth/ticket', + ])('404s product/event/media route %s in the Account container', (pathname) => { + expect(accountRequest(pathname).status).toBe(404); + }); + + it.each(['account-production', '127.0.0.1', 'account-staging.harmonicbeacon.com']) + ('404s every direct or wrong-environment Host: %s', (hostname) => { + expect(accountRequest('/api/account/health/ready', hostname).status).toBe(404); + }); + + it('trusts the exact HTTP Host behind the loopback proxy, not the internal request URL', () => { + vi.stubEnv('BEACON_ACCOUNT_RUNTIME', '1'); + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com'); + const proxied = new NextRequest('http://127.0.0.1:3000/api/account/health/ready', { + headers: { host: 'account.harmonicbeacon.com' }, + }); + expect(middleware(proxied).status).toBe(200); + const direct = new NextRequest('http://127.0.0.1:3000/api/account/health/ready', { + headers: { + host: 'account-production:3000', + 'x-forwarded-host': 'account.harmonicbeacon.com', + }, + }); + expect(middleware(direct).status).toBe(404); + }); + + it('does not expose authority UI/actions from a product runtime', () => { + vi.stubEnv('BEACON_ACCOUNT_RUNTIME', '0'); + for (const pathname of ['/account', '/nav-slot', '/.well-known/openid-configuration', + '/api/account/profile', '/api/account/session-status']) { + expect(middleware(request(pathname, undefined, 'listen.harmonicbeacon.com')).status) + .toBe(404); + } + for (const pathname of ['/api/account/login', '/api/account/callback', + '/api/account/frontchannel-logout']) { + expect(middleware(request(pathname, undefined, 'listen.harmonicbeacon.com')).status) + .toBe(200); + } + }); + + it.each([ + [{}, 403], + [{ origin: 'https://listen.harmonicbeacon.com', 'sec-fetch-site': 'same-site', 'content-type': 'application/json' }, 403], + [{ origin: 'https://account.harmonicbeacon.com', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, 200], + ])('enforces exact same-origin JSON on Account browser mutations', (headers, expected) => { + vi.stubEnv('BEACON_ACCOUNT_RUNTIME', '1'); + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com'); + const mutation = new NextRequest('https://account.harmonicbeacon.com/api/account/profile', { + method: 'POST', headers, + }); + expect(middleware(mutation).status).toBe(expected); + }); + + it('uses the canonical Account origin for proxied same-origin mutations', () => { + vi.stubEnv('BEACON_ACCOUNT_RUNTIME', '1'); + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com'); + const mutation = new NextRequest('http://127.0.0.1:3000/api/account/profile', { + method: 'POST', + headers: { + host: 'account.harmonicbeacon.com', + origin: 'https://account.harmonicbeacon.com', + 'sec-fetch-site': 'same-origin', + 'content-type': 'application/json', + }, + }); + expect(middleware(mutation).status).toBe(200); + }); + + it('forwards one allowlisted explicit Account locale to the document boundary', () => { + vi.stubEnv('BEACON_ACCOUNT_RUNTIME', '1'); + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com'); + const response = middleware(new NextRequest( + 'https://account.harmonicbeacon.com/account?lang=en', + { headers: { 'accept-language': 'es-AR,es;q=0.9' } }, + )); + expect(response.headers.get('x-middleware-request-x-hb-account-locale')).toBe('en'); + }); + + it('serves the local navigation on Account pages without an iframe slot seam', () => { + const response = accountRequest('/account'); + expect(response.headers.get('x-middleware-request-x-hb-account-nav-slot')).toBeNull(); + expect(response.headers.get('content-security-policy')).toContain("frame-src 'self'"); + expect(accountRequest('/nav-slot').status).toBe(404); + }); + }); + describe('EarlyBird invitation URL scrubbing', () => { + it.each([ + ['/listener', 'invite'], + ['/listener/redeem', 'token'], + ['/early-birds', 'invite'], + ['/early-birds/redeem', 'token'], + ])('forwards staging %s bearer once to the canonical redeem host', (pathname, queryName) => { + const response = middleware(request( + `${pathname}?${queryName}=${INVITATION}&locale=en`, + undefined, + 'earlybirds-staging.harmonicbeacon.com', + )); + + expect(response.status).toBe(307); + const target = location(response); + expect(target.origin).toBe('https://listen.harmonicbeacon.com'); + expect(target.pathname).toBe('/listener/redeem'); + expect(target.searchParams.get('token')).toBe(INVITATION); + expect(target.searchParams.has('invite')).toBe(false); + expect(target.searchParams.has('locale')).toBe(false); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + expect(response.cookies.get(LISTENER_INVITATION_COOKIE)).toBeUndefined(); + }); + + it.each([ + ['listen.harmonicbeacon.com', '/listener', 'invite'], + ['listen.harmonicbeacon.com', '/listener/redeem', 'token'], + ['listen.harmonicbeacon.com', '/early-birds', 'invite'], + ['listen.harmonicbeacon.com', '/early-birds/redeem', 'token'], + ])('moves a canonical %s%s query into the host-only handoff cookie', (hostname, pathname, queryName) => { + const response = middleware(request( + `${pathname}?${queryName}=${INVITATION}&locale=en`, + undefined, + hostname, + )); + + expect(response.status).toBe(307); + expect(location(response).searchParams.has(queryName)).toBe(false); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toMatchObject({ + value: INVITATION, + httpOnly: true, + secure: true, + sameSite: 'lax', + }); + expect(response.cookies.get(LISTENER_INVITATION_COOKIE)).toMatchObject({ + value: INVITATION, + httpOnly: true, + secure: true, + sameSite: 'lax', + }); + }); + + it('completes the real staging-to-canonical scrub topology without a staging cookie', () => { + const staging = middleware(request( + `/listener?invite=${INVITATION}`, + undefined, + 'earlybirds-staging.harmonicbeacon.com', + )); + expect(staging.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + expect(staging.cookies.get(LISTENER_INVITATION_COOKIE)).toBeUndefined(); + + const canonicalURL = location(staging); + const canonical = middleware(request( + `${canonicalURL.pathname}${canonicalURL.search}`, + undefined, + canonicalURL.hostname, + )); + expect(location(canonical).toString()).toBe( + 'https://listen.harmonicbeacon.com/listener/redeem', + ); + expect(canonical.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toMatchObject({ + value: INVITATION, + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: EARLY_BIRD_INVITATION_MAX_AGE_SECONDS, + }); + expect(canonical.cookies.get(LISTENER_INVITATION_COOKIE)).toMatchObject({ + value: INVITATION, + path: '/', + maxAge: EARLY_BIRD_INVITATION_MAX_AGE_SECONDS, + }); + }); + + it.each([ + ['live.harmonicbeacon.com', '/early-birds', 'invite'], + ['listen.harmonicbeacon.com.attacker.invalid', '/listener', 'invite'], + ])('scrubs but never persists %s%s invitation queries', (hostname, pathname, queryName) => { + const response = middleware(request( + `${pathname}?${queryName}=${INVITATION}&locale=en`, + undefined, + hostname, + )); + + expect(response.status).toBe(307); + expect(location(response).searchParams.has(queryName)).toBe(false); + expect(location(response).searchParams.get('locale')).toBe('en'); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + expect(response.cookies.get(LISTENER_INVITATION_COOKIE)).toBeUndefined(); + }); + + it('does not trust a forwarded Listener host on an off-surface URL', () => { + const response = middleware(request( + `/early-birds?invite=${INVITATION}`, + undefined, + 'live.harmonicbeacon.com', + { 'x-forwarded-host': 'listen.harmonicbeacon.com' }, + )); + + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + expect(response.cookies.get(LISTENER_INVITATION_COOKIE)).toBeUndefined(); + }); + + it('scrubs malformed or ambiguous query values without persisting them', () => { + for (const pathname of [ + '/early-birds?invite=not-canonical', + `/early-birds?invite=${INVITATION}&invite=${INVITATION}`, + ]) { + const response = middleware(request(pathname)); + expect(response.status).toBe(307); + expect(location(response).searchParams.has('invite')).toBe(false); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + } + }); + + it('sends malformed staging input to the canonical clean entry without carrying it', () => { + const response = middleware(request( + '/listener?invite=not-canonical', + undefined, + 'earlybirds-staging.harmonicbeacon.com', + )); + expect(location(response).toString()).toBe('https://listen.harmonicbeacon.com/listener'); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + }); + }); + it('recognizes exactly the cookie the session contract issues', () => { // Drift guard for the literal in `middleware.ts`, which cannot import // `@/lib/session-auth` because the edge runtime has no `node:crypto`. @@ -100,8 +362,8 @@ describe('middleware', () => { }); describe('matcher', () => { - it('runs only on the two protected surfaces', () => { - expect(config.matcher).toEqual(['/session/:path*', '/ops/:path*']); + it('runs on the full host so the Account container can deny by default', () => { + expect(config.matcher).toBe('/:path*'); }); }); }); diff --git a/next.config.ts b/next.config.ts index 6023a870..ec179018 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,10 @@ import path from "path"; const nextConfig: NextConfig = { output: 'standalone', + // The disposable UI workbench terminates TLS at Mona's nginx before + // forwarding to this development server. Production builds ignore this + // development-only origin allowance. + allowedDevOrigins: ['earlybirds-staging.harmonicbeacon.com'], turbopack: { root: path.resolve(__dirname), }, diff --git a/ops/beacon-account/README.md b/ops/beacon-account/README.md new file mode 100644 index 00000000..9a19cb7a --- /dev/null +++ b/ops/beacon-account/README.md @@ -0,0 +1,63 @@ +# Beacon Account deployment + +The Account authority uses one immutable application image with separate, +root-owned production and staging configuration. The production lifecycle is a +deliberate maintenance boundary: the first migration installs the Account +authority in the protected Listener database and revokes legacy Listener auth +sessions. It never changes DNS. + +## Production preparation + +Before requesting DNS, all of these gates must be green: + +1. the exact reviewed SHA is checked out cleanly on the host; +2. `harmonic-beacon/account:` and + `harmonic-beacon/earlybirds-preview-listener:` have baked provenance + matching that SHA; +3. `ops/beacon-account/validate.mjs` accepts the five root-owned env files in a + networkless candidate container; +4. `scripts/listener-account-production/prepare.sh ` produces the dormant + two-key Listener RP bundle while the active Listener remains Account-off; +5. `account_check_production_migrations before` reports exactly the reviewed + pending migration list; +6. staging email/password, provider, profile, switching and RP acceptance gates + required for the release are recorded. + +## DNS and certificate boundary + +DNSExit is a human-operated critical system. Repository scripts must not change +it. Only after the operator is explicitly asked, create exact A/AAAA records for +`account.harmonicbeacon.com` pointing at the reviewed Mona addresses. Do not add +a CNAME or wildcard. + +While the certificate is absent, install only +`nginx/account-acme-bootstrap.conf.template`. It serves the ACME webroot and +returns 503 everywhere else; it contains no proxy or TLS listener. Run +`nginx -t`, reload, obtain the certificate through the existing certbot webroot, +and verify the certificate SAN is exactly `account.harmonicbeacon.com`. + +Then install `nginx/account.harmonicbeacon.com.conf.template`, run `nginx -t` +and reload. Until the application starts, upstream failures are normalized to +503. The new hostname therefore never routes to another product and never +becomes a partially working sign-in authority. + +## Coordinated activation + +1. Capture protected runtime fingerprints and fresh, verified database/env/nginx + backups. +2. Run `scripts/beacon-account/start.sh production /secure/deploy.env`. It + checks migrations, creates and verifies the encrypted backup, migrates, + provisions the least-privilege database role and static clients, then starts + Account and its mail worker with rollback on failure. +3. Require Account readiness, exact issuer/discovery/JWKS, Basic-only clients, + mail-sidecar readiness, navigation asset hash and negative-route smokes. +4. Run `scripts/listener-account-production/preflight.sh ` against the + public Account authority. +5. Activate only the production Listener RP through its reviewed lifecycle. + Do not re-enable direct Listener Google, Apple or magic-link identity. +6. Run human Account-to-Listener acceptance before exposing the production + Account control in the shared navigation or enabling Live/Ops consumers. + +Rollback never downgrades the database. Restore the previous application/env +state, leave the Account edge at a truthful 503 if the authority is unavailable, +and keep all RP feature flags off until readiness and acceptance pass again. diff --git a/ops/beacon-account/account-mail-worker.production.env.example b/ops/beacon-account/account-mail-worker.production.env.example new file mode 100644 index 00000000..525c398b --- /dev/null +++ b/ops/beacon-account/account-mail-worker.production.env.example @@ -0,0 +1,6 @@ +# Copy to /etc/harmonic-beacon/account-mail-worker.production.env as root:root 0600. +# This file deliberately excludes browser auth, OAuth provider and RP secrets. +DATABASE_URL=postgresql://account_prod:replace-production-database-password-32-random@earlybirds-preview-postgres:5432/earlybirds_preview?schema=public +BEACON_ACCOUNT_BASE_URL=https://account.harmonicbeacon.com +BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN=replace-production-private-mail-token-32-random +BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceProdOutboxKeyAAAAAAAAAAAAAAAAAAAAAAA diff --git a/ops/beacon-account/account-mail-worker.staging.env.example b/ops/beacon-account/account-mail-worker.staging.env.example new file mode 100644 index 00000000..27323a2d --- /dev/null +++ b/ops/beacon-account/account-mail-worker.staging.env.example @@ -0,0 +1,6 @@ +# Copy to /etc/harmonic-beacon/account-mail-worker.staging.env as root:root 0600. +# This file deliberately excludes browser auth, OAuth provider and RP secrets. +DATABASE_URL=postgresql://beacon_account_staging:replace-staging-database-password-32-random@account-staging-postgres:5432/beacon_account_staging?schema=public +BEACON_ACCOUNT_BASE_URL=https://account-staging.harmonicbeacon.com +BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN=replace-staging-private-mail-token-32-random +BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceStagingOutboxKeyAAAAAAAAAAAAAAAAAAAA diff --git a/ops/beacon-account/account.production.env.example b/ops/beacon-account/account.production.env.example new file mode 100644 index 00000000..4e8b68ec --- /dev/null +++ b/ops/beacon-account/account.production.env.example @@ -0,0 +1,22 @@ +# Copy to /etc/harmonic-beacon/account.production.env as root:root 0600. +# Never reuse any value from the staging file. +DATABASE_URL=postgresql://account_prod:replace-production-database-password-32-random@earlybirds-preview-postgres:5432/earlybirds_preview?schema=public +BEACON_ACCOUNT_BASE_URL=https://account.harmonicbeacon.com +BEACON_ACCOUNT_RUNTIME=1 +BEACON_ACCOUNT_PROVISION_CONFIRM_ISSUER=https://account.harmonicbeacon.com +BEACON_ACCOUNT_AUTH_SECRET=replace-production-auth-secret-32-random-characters +BEACON_ACCOUNT_RATE_SECRET=replace-production-rate-secret-32-random-characters +BEACON_ACCOUNT_TRUSTED_ORIGINS=https://account.harmonicbeacon.com +BEACON_ACCOUNT_GOOGLE_ENABLED=0 +BEACON_ACCOUNT_GOOGLE_CLIENT_ID= +BEACON_ACCOUNT_GOOGLE_CLIENT_SECRET= +BEACON_ACCOUNT_APPLE_ENABLED=0 +BEACON_ACCOUNT_APPLE_CLIENT_ID= +BEACON_ACCOUNT_APPLE_CLIENT_SECRET= +BEACON_ACCOUNT_MAIL_DELIVERY_URL=http://listener-mail-api:8765/api/internal/v1/listener-account-mail/deliver +BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN=replace-production-private-mail-token-32-random +BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceProdOutboxKeyAAAAAAAAAAAAAAAAAAAAAAA +BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER=replace-production-listener-client-secret-32-random +BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER_STAGING= +BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE=replace-production-live-client-secret-32-random +BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE_STAGING= diff --git a/ops/beacon-account/account.staging.env.example b/ops/beacon-account/account.staging.env.example new file mode 100644 index 00000000..f331fabe --- /dev/null +++ b/ops/beacon-account/account.staging.env.example @@ -0,0 +1,22 @@ +# Copy to /etc/harmonic-beacon/account.staging.env as root:root 0600. +# The schema/database MUST be physically distinct from production. +DATABASE_URL=postgresql://beacon_account_staging:replace-staging-database-password-32-random@account-staging-postgres:5432/beacon_account_staging?schema=public +BEACON_ACCOUNT_BASE_URL=https://account-staging.harmonicbeacon.com +BEACON_ACCOUNT_RUNTIME=1 +BEACON_ACCOUNT_PROVISION_CONFIRM_ISSUER=https://account-staging.harmonicbeacon.com +BEACON_ACCOUNT_AUTH_SECRET=replace-staging-auth-secret-32-random-characters +BEACON_ACCOUNT_RATE_SECRET=replace-staging-rate-secret-32-random-characters +BEACON_ACCOUNT_TRUSTED_ORIGINS=https://account-staging.harmonicbeacon.com +BEACON_ACCOUNT_GOOGLE_ENABLED=0 +BEACON_ACCOUNT_GOOGLE_CLIENT_ID= +BEACON_ACCOUNT_GOOGLE_CLIENT_SECRET= +BEACON_ACCOUNT_APPLE_ENABLED=0 +BEACON_ACCOUNT_APPLE_CLIENT_ID= +BEACON_ACCOUNT_APPLE_CLIENT_SECRET= +BEACON_ACCOUNT_MAIL_DELIVERY_URL=http://listener-mail-api:8765/api/internal/v1/listener-account-mail/deliver +BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN=replace-staging-private-mail-token-32-random +BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceStagingOutboxKeyAAAAAAAAAAAAAAAAAAAA +BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER= +BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER_STAGING=replace-staging-listener-client-secret-32-random +BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE= +BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE_STAGING=replace-staging-live-client-secret-32-random diff --git a/ops/beacon-account/compose.yml b/ops/beacon-account/compose.yml new file mode 100644 index 00000000..8b9c1c46 --- /dev/null +++ b/ops/beacon-account/compose.yml @@ -0,0 +1,217 @@ +# Harmonic Beacon Account authority. This project is isolated from the event +# runtime and publishes only two loopback HTTP ports for host nginx. Production +# and staging use different DATABASE_URL values and MUST resolve to different +# PostgreSQL databases or schemas. +services: + account-staging-postgres: + image: postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + restart: unless-stopped + env_file: + - ${BEACON_ACCOUNT_STAGING_DB_ENV_FILE:?set_root_owned_staging_db_env_file} + volumes: + - beacon-account-staging-postgres:/var/lib/postgresql/data + networks: [account_staging_db] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 3s + retries: 12 + deploy: + resources: + limits: { cpus: "0.75", memory: 768M } + reservations: { cpus: "0.1", memory: 128M } + logging: &account_logging + driver: json-file + options: { max-size: 10m, max-file: "3" } + + migrate-production: + image: harmonic-beacon/account:${BEACON_ACCOUNT_IMAGE_TAG:?set_exact_account_image_tag} + restart: "no" + command: ["npx", "prisma", "migrate", "deploy"] + env_file: + - ${BEACON_ACCOUNT_PRODUCTION_ENV_FILE:?set_root_owned_production_env_file} + networks: [account_production_db] + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + + migrate-staging: + image: harmonic-beacon/account:${BEACON_ACCOUNT_IMAGE_TAG:?set_exact_account_image_tag} + restart: "no" + command: ["npx", "prisma", "migrate", "deploy"] + env_file: + - ${BEACON_ACCOUNT_STAGING_ENV_FILE:?set_root_owned_staging_env_file} + networks: [account_staging_db] + depends_on: + account-staging-postgres: { condition: service_healthy } + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + + provision-production: + image: harmonic-beacon/account:${BEACON_ACCOUNT_IMAGE_TAG:?set_exact_account_image_tag} + restart: "no" + command: ["npm", "run", "account:provision"] + env_file: + - ${BEACON_ACCOUNT_PRODUCTION_ENV_FILE:?set_root_owned_production_env_file} + networks: [account_production_db, account_mail_production, account_egress_production] + depends_on: + migrate-production: { condition: service_completed_successfully } + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + + provision-staging: + image: harmonic-beacon/account:${BEACON_ACCOUNT_IMAGE_TAG:?set_exact_account_image_tag} + restart: "no" + command: ["npm", "run", "account:provision"] + env_file: + - ${BEACON_ACCOUNT_STAGING_ENV_FILE:?set_root_owned_staging_env_file} + networks: [account_staging_db, account_mail_staging, account_egress_staging] + depends_on: + migrate-staging: { condition: service_completed_successfully } + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + + account-mail-worker-production: + image: harmonic-beacon/account:${BEACON_ACCOUNT_IMAGE_TAG:?set_exact_account_image_tag} + restart: unless-stopped + init: true + command: ["npm", "run", "account:mail-worker"] + env_file: + - ${BEACON_ACCOUNT_MAIL_WORKER_PRODUCTION_ENV_FILE:?set_root_owned_production_mail_worker_env_file} + environment: + NODE_ENV: production + BEACON_GIT_SHA: ${BEACON_ACCOUNT_GIT_SHA:?set_exact_account_git_sha} + BEACON_BUILD_TIME: ${BEACON_ACCOUNT_BUILD_TIME:?set_account_build_time} + BEACON_DATABASE_SCHEMA_VERSION: ${BEACON_ACCOUNT_SCHEMA_VERSION:?set_account_schema_version} + BEACON_ACCOUNT_MAIL_WORKER_HEARTBEAT_FILE: /tmp/beacon-account-mail-worker-heartbeat + networks: [account_production_db, account_mail_production] + depends_on: + provision-production: { condition: service_completed_successfully } + healthcheck: &mail_worker_health + test: + - CMD-SHELL + - >- + test -s /tmp/beacon-account-mail-worker-heartbeat && + node -e "const fs=require('node:fs');const p='/tmp/beacon-account-mail-worker-heartbeat';const h=JSON.parse(fs.readFileSync(p,'utf8'));if(h.status!=='ok'||h.gitSha!==process.env.BEACON_GIT_SHA||Date.now()-Date.parse(h.at)>120000||!Number.isInteger(h.pendingCount)||h.pendingCount<0||h.oldestPendingSeconds>300||h.consecutiveErrors!==0)process.exit(1)" + interval: 15s + timeout: 5s + retries: 8 + start_period: 15s + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + reservations: { cpus: "0.05", memory: 96M } + logging: *account_logging + + account-mail-worker-staging: + image: harmonic-beacon/account:${BEACON_ACCOUNT_IMAGE_TAG:?set_exact_account_image_tag} + restart: unless-stopped + init: true + command: ["npm", "run", "account:mail-worker"] + env_file: + - ${BEACON_ACCOUNT_MAIL_WORKER_STAGING_ENV_FILE:?set_root_owned_staging_mail_worker_env_file} + environment: + NODE_ENV: production + BEACON_GIT_SHA: ${BEACON_ACCOUNT_GIT_SHA:?set_exact_account_git_sha} + BEACON_BUILD_TIME: ${BEACON_ACCOUNT_BUILD_TIME:?set_account_build_time} + BEACON_DATABASE_SCHEMA_VERSION: ${BEACON_ACCOUNT_SCHEMA_VERSION:?set_account_schema_version} + BEACON_ACCOUNT_MAIL_WORKER_HEARTBEAT_FILE: /tmp/beacon-account-mail-worker-heartbeat + networks: [account_staging_db, account_mail_staging] + depends_on: + provision-staging: { condition: service_completed_successfully } + healthcheck: *mail_worker_health + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + reservations: { cpus: "0.05", memory: 96M } + logging: *account_logging + + account-production: + image: harmonic-beacon/account:${BEACON_ACCOUNT_IMAGE_TAG:?set_exact_account_image_tag} + build: + context: ../.. + target: runner + args: + NEXT_PUBLIC_LIVEKIT_URL: https://livekit.example.invalid + BEACON_GIT_SHA: ${BEACON_ACCOUNT_GIT_SHA:?set_exact_account_git_sha} + BEACON_BUILD_TIME: ${BEACON_ACCOUNT_BUILD_TIME:?set_account_build_time} + BEACON_DATABASE_SCHEMA_VERSION: ${BEACON_ACCOUNT_SCHEMA_VERSION:?set_account_schema_version} + restart: unless-stopped + init: true + env_file: + - ${BEACON_ACCOUNT_PRODUCTION_ENV_FILE:?set_root_owned_production_env_file} + environment: + NODE_ENV: production + BEACON_GIT_SHA: ${BEACON_ACCOUNT_GIT_SHA:?set_exact_account_git_sha} + BEACON_BUILD_TIME: ${BEACON_ACCOUNT_BUILD_TIME:?set_account_build_time} + BEACON_DATABASE_SCHEMA_VERSION: ${BEACON_ACCOUNT_SCHEMA_VERSION:?set_account_schema_version} + ports: + - "127.0.0.1:${BEACON_ACCOUNT_PRODUCTION_PORT:-13002}:3000" + networks: [account_production_db, account_mail_production, account_egress_production] + depends_on: + provision-production: { condition: service_completed_successfully } + account-mail-worker-production: { condition: service_healthy } + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "-H", "Host: account.harmonicbeacon.com", "http://127.0.0.1:3000/api/account/health/ready"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 10s + deploy: + resources: + limits: { cpus: "1.0", memory: 1G } + reservations: { cpus: "0.25", memory: 256M } + logging: *account_logging + + account-staging: + image: harmonic-beacon/account:${BEACON_ACCOUNT_IMAGE_TAG:?set_exact_account_image_tag} + restart: unless-stopped + init: true + env_file: + - ${BEACON_ACCOUNT_STAGING_ENV_FILE:?set_root_owned_staging_env_file} + environment: + NODE_ENV: production + BEACON_GIT_SHA: ${BEACON_ACCOUNT_GIT_SHA:?set_exact_account_git_sha} + BEACON_BUILD_TIME: ${BEACON_ACCOUNT_BUILD_TIME:?set_account_build_time} + BEACON_DATABASE_SCHEMA_VERSION: ${BEACON_ACCOUNT_SCHEMA_VERSION:?set_account_schema_version} + ports: + - "127.0.0.1:${BEACON_ACCOUNT_STAGING_PORT:-13003}:3000" + networks: [account_staging_db, account_mail_staging, account_egress_staging] + depends_on: + provision-staging: { condition: service_completed_successfully } + account-mail-worker-staging: { condition: service_healthy } + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "-H", "Host: account-staging.harmonicbeacon.com", "http://127.0.0.1:3000/api/account/health/ready"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 10s + deploy: + resources: + limits: { cpus: "1.0", memory: 1G } + reservations: { cpus: "0.25", memory: 256M } + logging: *account_logging + +networks: + account_production_db: + external: true + name: earlybirds_preview_db_internal + account_staging_db: + internal: true + account_mail_production: + external: true + name: beacon_account_mail_production + account_mail_staging: + external: true + name: beacon_account_mail_staging + account_egress_production: + name: beacon_account_production_egress + account_egress_staging: + name: beacon_account_staging_egress + +volumes: + beacon-account-staging-postgres: + name: beacon-account-staging-postgres diff --git a/ops/beacon-account/database.staging.env.example b/ops/beacon-account/database.staging.env.example new file mode 100644 index 00000000..f2bfcfde --- /dev/null +++ b/ops/beacon-account/database.staging.env.example @@ -0,0 +1,5 @@ +# Copy to /etc/harmonic-beacon/account.staging-database.env as root:root 0600. +# This file is injected only into the dedicated staging PostgreSQL container. +POSTGRES_USER=beacon_account_staging +POSTGRES_PASSWORD=replace-staging-database-password-32-random +POSTGRES_DB=beacon_account_staging diff --git a/ops/beacon-account/deploy.env.synthetic.example b/ops/beacon-account/deploy.env.synthetic.example new file mode 100644 index 00000000..f5ad1a94 --- /dev/null +++ b/ops/beacon-account/deploy.env.synthetic.example @@ -0,0 +1,17 @@ +# Non-secret deployment coordinates. Production uses a root-owned copy with an +# exact reviewed SHA and immutable environment-file paths. +COMPOSE_PROJECT_NAME=beacon-account +BEACON_ACCOUNT_IMAGE_TAG=0000000000000000000000000000000000000000 +BEACON_ACCOUNT_GIT_SHA=0000000000000000000000000000000000000000 +BEACON_ACCOUNT_BUILD_TIME=2026-08-17T00:00:00Z +BEACON_ACCOUNT_SCHEMA_VERSION=20260818010000_beacon_account_authority +BEACON_ACCOUNT_EXPECTED_PENDING_MIGRATIONS=20260818010000_beacon_account_authority +BEACON_ACCOUNT_PRODUCTION_ENV_FILE=/etc/harmonic-beacon/account.production.env +BEACON_ACCOUNT_STAGING_ENV_FILE=/etc/harmonic-beacon/account.staging.env +BEACON_ACCOUNT_MAIL_WORKER_PRODUCTION_ENV_FILE=/etc/harmonic-beacon/account-mail-worker.production.env +BEACON_ACCOUNT_MAIL_WORKER_STAGING_ENV_FILE=/etc/harmonic-beacon/account-mail-worker.staging.env +BEACON_ACCOUNT_STAGING_DB_ENV_FILE=/etc/harmonic-beacon/account.staging-database.env +BEACON_ACCOUNT_BACKUP_DIR=/mnt/beacon-data/backups/account +BEACON_ACCOUNT_BACKUP_KEY_FILE=/etc/harmonic-beacon/account-backup.key +BEACON_ACCOUNT_PRODUCTION_PORT=13002 +BEACON_ACCOUNT_STAGING_PORT=13003 diff --git a/ops/beacon-account/nginx/account-acme-bootstrap.conf.template b/ops/beacon-account/nginx/account-acme-bootstrap.conf.template new file mode 100644 index 00000000..4ca72291 --- /dev/null +++ b/ops/beacon-account/nginx/account-acme-bootstrap.conf.template @@ -0,0 +1,16 @@ +# Temporary HTTP-only bootstrap for the Account production certificate. +# Install this file only while the certificate is absent. It never proxies +# Account, Listener, Live, event, media or internal application traffic. +server { + listen 80; + listen [::]:80; + server_name account.harmonicbeacon.com; + access_log off; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + default_type text/plain; + } + + location / { return 503; } +} diff --git a/ops/beacon-account/nginx/account-staging-acme-bootstrap.conf.template b/ops/beacon-account/nginx/account-staging-acme-bootstrap.conf.template new file mode 100644 index 00000000..735953c9 --- /dev/null +++ b/ops/beacon-account/nginx/account-staging-acme-bootstrap.conf.template @@ -0,0 +1,16 @@ +# Temporary HTTP-only bootstrap for the Account staging certificate. +# Install this file only while the certificate is absent. It never proxies +# Account, Listener, Live, event, media or internal application traffic. +server { + listen 80; + listen [::]:80; + server_name account-staging.harmonicbeacon.com; + access_log off; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + default_type text/plain; + } + + location / { return 503; } +} diff --git a/ops/beacon-account/nginx/account-staging.harmonicbeacon.com.conf.template b/ops/beacon-account/nginx/account-staging.harmonicbeacon.com.conf.template new file mode 100644 index 00000000..3a0a34f6 --- /dev/null +++ b/ops/beacon-account/nginx/account-staging.harmonicbeacon.com.conf.template @@ -0,0 +1,143 @@ +limit_req_zone $binary_remote_addr zone=beacon_account_staging_auth:10m rate=30r/m; + +server { + listen 80; + listen [::]:80; + server_name account-staging.harmonicbeacon.com; + access_log off; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + default_type text/plain; + } + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name account-staging.harmonicbeacon.com; + + ssl_certificate /etc/letsencrypt/live/account-staging.harmonicbeacon.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/account-staging.harmonicbeacon.com/privkey.pem; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + + proxy_hide_header X-Powered-By; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_http_version 1.1; + + location = /.well-known/openid-configuration { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "public, max-age=300" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /.well-known/jwks.json { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "public, max-age=300" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location ^~ /_next/ { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + } + + location = /assets/hb-global-nav.js { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "public, max-age=300" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = / { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /account { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location ^~ /account/ { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /verify-email { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /reset-password { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /api/account/health/ready { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location ^~ /api/account/ { + if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; } + access_log off; + limit_req zone=beacon_account_staging_auth burst=10 nodelay; + client_max_body_size 16k; + proxy_pass http://127.0.0.1:13003; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location / { return 404; } +} diff --git a/ops/beacon-account/nginx/account.harmonicbeacon.com.conf.template b/ops/beacon-account/nginx/account.harmonicbeacon.com.conf.template new file mode 100644 index 00000000..3d96c57a --- /dev/null +++ b/ops/beacon-account/nginx/account.harmonicbeacon.com.conf.template @@ -0,0 +1,154 @@ +limit_req_zone $binary_remote_addr zone=beacon_account_prod_auth:10m rate=30r/m; + +server { + listen 80; + listen [::]:80; + server_name account.harmonicbeacon.com; + access_log off; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + default_type text/plain; + } + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name account.harmonicbeacon.com; + + ssl_certificate /etc/letsencrypt/live/account.harmonicbeacon.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/account.harmonicbeacon.com/privkey.pem; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + + proxy_hide_header X-Powered-By; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_http_version 1.1; + proxy_intercept_errors on; + error_page 502 503 504 = @account_unavailable; + + location @account_unavailable { + access_log off; + add_header Cache-Control "no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + return 503; + } + + location = /.well-known/openid-configuration { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "public, max-age=300" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /.well-known/jwks.json { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "public, max-age=300" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location ^~ /_next/ { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + } + + location = /assets/hb-global-nav.js { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "public, max-age=300" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = / { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /account { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location ^~ /account/ { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /verify-email { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /reset-password { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location = /api/account/health/ready { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location ^~ /api/account/ { + if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; } + access_log off; + limit_req zone=beacon_account_prod_auth burst=10 nodelay; + client_max_body_size 16k; + proxy_pass http://127.0.0.1:13002; + add_header Cache-Control "private, no-store" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + } + + location / { return 404; } +} diff --git a/ops/beacon-account/test/contract.test.mjs b/ops/beacon-account/test/contract.test.mjs new file mode 100644 index 00000000..3b891d06 --- /dev/null +++ b/ops/beacon-account/test/contract.test.mjs @@ -0,0 +1,613 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { parseEnvFile, validatePair } from '../validate.mjs'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const PROD = path.join(ROOT, 'account.production.env.example'); +const STAGING = path.join(ROOT, 'account.staging.env.example'); +const STAGING_DB = path.join(ROOT, 'database.staging.env.example'); +const PROD_WORKER = path.join(ROOT, 'account-mail-worker.production.env.example'); +const STAGING_WORKER = path.join(ROOT, 'account-mail-worker.staging.env.example'); +const HEALTH_JSON_VERIFY = path.resolve(ROOT, '../../scripts/beacon-account/verify-health-json.sh'); +const ACCOUNT_LIFECYCLE_LIB = path.resolve(ROOT, '../../scripts/beacon-account/lib.sh'); +const PRODUCTION_PG_DUMP = path.resolve(ROOT, '../../scripts/beacon-account/production-pg-dump.sh'); +const HEALTH_ISSUER = 'https://account-staging.harmonicbeacon.com'; +const HEALTH_SHA = 'a'.repeat(40); +const HEALTH_SCHEMA = '20260818010000_beacon_account_authority'; + +function mutate(source, from, to) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'beacon-account-contract-')); + const target = path.join(directory, path.basename(source)); + const content = fs.readFileSync(source, 'utf8'); + assert.ok(content.includes(from), `fixture does not include ${from}`); + fs.writeFileSync(target, content.replace(from, to)); + return target; +} + +function composeService(source, name) { + const marker = `\n ${name}:\n`; + const start = source.indexOf(marker); + assert.ok(start >= 0, `missing Compose service ${name}`); + const remainder = source.slice(start + marker.length); + const next = remainder.search(/\n [a-z0-9][a-z0-9-]*:\n/); + return next < 0 ? remainder : remainder.slice(0, next); +} + +function runProductionPgDump(databaseUrl) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'beacon-account-pg-dump-')); + const pgDump = path.join(directory, 'pg_dump'); + fs.writeFileSync(pgDump, `#!/bin/sh +printf '%s\\n' "$@" +`); + fs.chmodSync(pgDump, 0o755); + const result = spawnSync(PRODUCTION_PG_DUMP, [], { + encoding: 'utf8', + env: { PATH: directory, DATABASE_URL: databaseUrl }, + }); + fs.rmSync(directory, { recursive: true, force: true }); + return result; +} + +function verifyHealthFixture({ jwks, ready = {}, discovery = {} }) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'beacon-account-health-')); + const write = (name, value) => fs.writeFileSync( + path.join(directory, `${name}.json`), JSON.stringify(value), + ); + write('ready', { + status: 'ok', gitSha: HEALTH_SHA, schemaVersion: HEALTH_SCHEMA, + checks: { + database: 'ok', mail: 'ok', issuer: 'ok', jwks: 'ok', clients: 'ok', providers: 'ok', + }, + ...ready, + }); + write('discovery', { + issuer: HEALTH_ISSUER, + jwks_uri: `${HEALTH_ISSUER}/.well-known/jwks.json`, + authorization_endpoint: `${HEALTH_ISSUER}/api/account/auth/oauth2/authorize`, + token_endpoint: `${HEALTH_ISSUER}/api/account/auth/oauth2/token`, + userinfo_endpoint: `${HEALTH_ISSUER}/api/account/auth/oauth2/userinfo`, + introspection_endpoint: `${HEALTH_ISSUER}/api/account/auth/oauth2/introspect`, + revocation_endpoint: `${HEALTH_ISSUER}/api/account/auth/oauth2/revoke`, + end_session_endpoint: `${HEALTH_ISSUER}/api/account/auth/oauth2/end-session`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + ...discovery, + }); + write('jwks', jwks); + const result = spawnSync('sh', [ + HEALTH_JSON_VERIFY, directory, HEALTH_ISSUER, HEALTH_SHA, HEALTH_SCHEMA, + ], { encoding: 'utf8' }); + fs.rmSync(directory, { recursive: true, force: true }); + return result; +} + +function verifyRunningFixture({ expectedWorkerPresent, actualWorkerPresent }) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'beacon-account-runtime-')); + const docker = path.join(directory, 'docker'); + fs.writeFileSync(docker, `#!/bin/sh +set -eu +test "$1" = inspect +target=$2 +format=$4 +is_worker=0 +case "$target" in *mail-worker*) is_worker=1 ;; esac +case "$format" in + *State.Health*) + if [ "$is_worker" -eq 1 ] && [ "$MOCK_WORKER_PRESENT" -eq 0 ]; then + echo exited + else + echo healthy + fi + ;; + *State.Status*) + if [ "$is_worker" -eq 1 ] && [ "$MOCK_WORKER_PRESENT" -eq 0 ]; then + exit 1 + fi + echo running + ;; + *Config.Image*) echo "harmonic-beacon/account:$MOCK_SHA" ;; + *Config.Env*) echo "BEACON_GIT_SHA=$MOCK_SHA" ;; + *HostConfig.PortBindings*) + if [ "$is_worker" -eq 1 ]; then + echo null + else + echo '{"3000/tcp":[{"HostIp":"127.0.0.1","HostPort":"13003"}]}' + fi + ;; + *) echo "unexpected docker inspect format: $format" >&2; exit 2 ;; +esac +`); + fs.chmodSync(docker, 0o755); + const sha = 'c'.repeat(40); + const result = spawnSync('sh', ['-c', ` + . "$ACCOUNT_LIFECYCLE_LIB" + BEACON_ACCOUNT_GIT_SHA="$MOCK_SHA" + BEACON_ACCOUNT_IMAGE_TAG="$MOCK_SHA" + account_verify_running staging "$MOCK_SHA" "$MOCK_SHA" "$EXPECTED_WORKER_PRESENT" + `], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + ACCOUNT_LIFECYCLE_LIB, + EXPECTED_WORKER_PRESENT: String(expectedWorkerPresent), + MOCK_WORKER_PRESENT: String(actualWorkerPresent), + MOCK_SHA: sha, + }, + }); + fs.rmSync(directory, { recursive: true, force: true }); + return result; +} + +function navigationAssetCapabilityFixture(present) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'beacon-account-nav-capability-')); + const docker = path.join(directory, 'docker'); + fs.writeFileSync(docker, `#!/bin/sh +set -eu +test "$1" = image +test "$2" = inspect +if [ "$MOCK_NAV_ASSET_PRESENT" -eq 1 ]; then + echo BEACON_ACCOUNT_NAV_ASSET=1 +else + echo NODE_ENV=production +fi +`); + fs.chmodSync(docker, 0o755); + const result = spawnSync('sh', ['-c', ` + . "$ACCOUNT_LIFECYCLE_LIB" + actual=0 + account_image_supports_navigation_asset "${'d'.repeat(40)}" && actual=1 + test "$actual" = "$MOCK_NAV_ASSET_PRESENT" + `], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + ACCOUNT_LIFECYCLE_LIB, + MOCK_NAV_ASSET_PRESENT: present ? '1' : '0', + }, + }); + fs.rmSync(directory, { recursive: true, force: true }); + return result; +} + +const publicEd25519Key = { + kid: 'staging-ed25519-key', kty: 'OKP', alg: 'EdDSA', crv: 'Ed25519', x: 'A'.repeat(43), +}; + +test('production and staging examples are isolated and fail closed', () => { + validatePair(PROD, STAGING, STAGING_DB, true); + assert.equal(parseEnvFile(PROD).get('BEACON_ACCOUNT_APPLE_ENABLED'), '0'); + assert.equal(parseEnvFile(STAGING).get('BEACON_ACCOUNT_APPLE_ENABLED'), '0'); +}); + +test('validator rejects a shared staging database schema', () => { + const bad = mutate(STAGING, 'account-staging-postgres:5432/beacon_account_staging', 'earlybirds-preview-postgres:5432/earlybirds_preview'); + assert.throws(() => validatePair(PROD, bad, STAGING_DB, true), /isolated account-staging-postgres/); +}); + +test('validator pins the production runtime role and bounded database password shape', () => { + const wrongRole = mutate(PROD, 'postgresql://account_prod:', 'postgresql://earlybirds_preview:'); + assert.throws(() => validatePair(wrongRole, STAGING, STAGING_DB, true), /dedicated account_prod role/); + const unsafePassword = mutate( + PROD, + 'account_prod:replace-production-database-password-32-random@earlybirds-preview-postgres', + 'account_prod:short@earlybirds-preview-postgres', + ); + assert.throws(() => validatePair(unsafePassword, STAGING, STAGING_DB, true), /32-128 base64url/); +}); + +test('validator rejects a production client secret in staging', () => { + const bad = mutate(STAGING, 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER=', 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER=wrong-boundary'); + assert.throws(() => validatePair(PROD, bad, STAGING_DB, true), /must be empty outside its issuer/); +}); + +test('validator rejects reused active RP secrets across issuers', () => { + const bad = mutate( + STAGING, + 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER_STAGING=replace-staging-listener-client-secret-32-random', + 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER_STAGING=replace-production-listener-client-secret-32-random', + ); + assert.throws(() => validatePair(PROD, bad, STAGING_DB, true), /must differ (?:from production|between issuers)/); +}); + +test('validator rejects a reused mail outbox encryption key across issuers', () => { + const badApplication = mutate( + STAGING, + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceStagingOutboxKeyAAAAAAAAAAAAAAAAAAAA', + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceProdOutboxKeyAAAAAAAAAAAAAAAAAAAAAAA', + ); + const badWorker = mutate( + STAGING_WORKER, + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceStagingOutboxKeyAAAAAAAAAAAAAAAAAAAA', + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceProdOutboxKeyAAAAAAAAAAAAAAAAAAAAAAA', + ); + assert.throws( + () => validatePair(PROD, badApplication, STAGING_DB, true, PROD_WORKER, badWorker), + /must differ (?:from production|between issuers)/, + ); +}); + +test('validator rejects a mail outbox key that is not canonical base64url32', () => { + const bad = mutate( + STAGING, + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY=replaceStagingOutboxKeyAAAAAAAAAAAAAAAAAAAA', + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.', + ); + assert.throws(() => validatePair(PROD, bad, STAGING_DB, true), /base64url for exactly 32 bytes/); +}); + +test('mail worker env rejects browser and OAuth secrets', () => { + const badWorker = mutate( + STAGING_WORKER, + '# This file deliberately excludes browser auth, OAuth provider and RP secrets.', + 'BEACON_ACCOUNT_AUTH_SECRET=forbidden-worker-auth-secret-32-random', + ); + assert.throws( + () => validatePair(PROD, STAGING, STAGING_DB, true, PROD_WORKER, badWorker), + /mail worker contains forbidden key BEACON_ACCOUNT_AUTH_SECRET/, + ); +}); + +test('runtime validation rejects copied placeholder secrets', () => { + assert.throws(() => validatePair(PROD, STAGING, STAGING_DB), /still a placeholder/); +}); + +test('CLI does not trust copied files merely because they end in .example', () => { + const copiedProduction = mutate(PROD, '# Copy', '# Copied'); + const copiedStaging = mutate(STAGING, '# Copy', '# Copied'); + const copiedDatabase = mutate(STAGING_DB, '# Copy', '# Copied'); + for (const [source, target] of [[copiedProduction, `${copiedProduction}.example`], [copiedStaging, `${copiedStaging}.example`], [copiedDatabase, `${copiedDatabase}.example`]]) { + fs.renameSync(source, target); + } + assert.throws(() => validatePair(`${copiedProduction}.example`, `${copiedStaging}.example`, `${copiedDatabase}.example`), /still a placeholder/); +}); + +test('compose exposes only fixed loopback ports and keeps the DB external', () => { + const compose = fs.readFileSync(path.join(ROOT, 'compose.yml'), 'utf8'); + assert.match(compose, /127\.0\.0\.1:\$\{BEACON_ACCOUNT_PRODUCTION_PORT:-13002\}:3000/); + assert.match(compose, /127\.0\.0\.1:\$\{BEACON_ACCOUNT_STAGING_PORT:-13003\}:3000/); + assert.match(compose, /account_production_db:\s+external: true\s+name: earlybirds_preview_db_internal/s); + assert.match(compose, /account_staging_db:\s+internal: true/s); + assert.match(compose, /account_mail_production:\s+external: true\s+name: beacon_account_mail_production/s); + assert.match(compose, /account_mail_staging:\s+external: true\s+name: beacon_account_mail_staging/s); + assert.match(compose, /account_egress_production:\s+name: beacon_account_production_egress/s); + assert.match(compose, /account_egress_staging:\s+name: beacon_account_staging_egress/s); + assert.match(compose, /postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777/); + assert.match(compose, /beacon-account-staging-postgres:\s+name: beacon-account-staging-postgres/s); + assert.match(compose, /provision-production:[\s\S]*account:provision/); + assert.match(compose, /provision-staging:[\s\S]*account:provision/); + assert.match(compose, /account-mail-worker-production:[\s\S]*account:mail-worker/); + assert.match(compose, /account-mail-worker-staging:[\s\S]*account:mail-worker/); + assert.match(compose, /account-mail-worker-production:[\s\S]*networks: \[account_production_db, account_mail_production\]/); + assert.match(compose, /account-mail-worker-staging:[\s\S]*networks: \[account_staging_db, account_mail_staging\]/); + for (const worker of ['account-mail-worker-production', 'account-mail-worker-staging']) { + const block = composeService(compose, worker); + assert.doesNotMatch(block, /\n\s+ports:/); + assert.doesNotMatch(block, /account_egress/); + assert.match(block, /ACCOUNT_MAIL_WORKER_(?:PRODUCTION|STAGING)_ENV_FILE/); + assert.doesNotMatch(block, /ACCOUNT_(?:PRODUCTION|STAGING)_ENV_FILE/); + } + assert.match(compose, /BEACON_ACCOUNT_MAIL_WORKER_HEARTBEAT_FILE/); + assert.match(compose, /Date\.now\(\)-Date\.parse\(h\.at\)>120000/); + assert.match(compose, /h\.oldestPendingSeconds>300/); + assert.match(compose, /h\.consecutiveErrors!==0/); + const dockerfile = fs.readFileSync(path.resolve(ROOT, '../../Dockerfile'), 'utf8'); + assert.match(dockerfile, /scripts\/process-account-mail-outbox\.ts/); + assert.doesNotMatch(compose, /(?:^|\s)ports:\s*\n[^\n]*(?:postgres|5432)/m); +}); + +test('lifecycle verifies immutable provenance and does not downgrade schemas', () => { + const start = fs.readFileSync(path.resolve(ROOT, '../../scripts/beacon-account/start.sh'), 'utf8'); + const lib = fs.readFileSync(path.resolve(ROOT, '../../scripts/beacon-account/lib.sh'), 'utf8'); + const rollback = fs.readFileSync(path.resolve(ROOT, '../../scripts/beacon-account/rollback-app.sh'), 'utf8'); + const smoke = fs.readFileSync(path.resolve(ROOT, '../../scripts/beacon-account/health-smoke.sh'), 'utf8'); + assert.match(start, /git -C "\$root" rev-parse HEAD/); + assert.match(start, /docker image inspect "harmonic-beacon\/account:\$BEACON_ACCOUNT_IMAGE_TAG"/); + assert.match(start, /account_compose build account-production[\s\S]*account_validate/); + assert.match(start, /account_compose up -d --no-deps[\s\\]+account-mail-worker-production account-production/); + assert.match(start, /account_compose up -d account-mail-worker-staging account-staging/); + assert.match(start, /health-smoke\.sh"[\s\\]+"\$environment" "\$ACCOUNT_DEPLOY_FILE" "\$BEACON_ACCOUNT_GIT_SHA" 1 1/); + assert.ok(start.indexOf('health-smoke.sh') < start.lastIndexOf('cutover_started=0')); + assert.match(start, /account_check_production_migrations before/); + assert.match(start, /account_check_production_migrations after/); + assert.match(start, /account_migrate_production/); + assert.match(start, /account_provision_production_role/); + assert.match(start, /account_provision_production_authority/); + assert.ok(start.indexOf('account_backup_production') < start.indexOf('account_migrate_production')); + assert.ok(start.indexOf('account_migrate_production') < start.indexOf('account_provision_production_role')); + assert.ok(start.indexOf('account_provision_production_role') < start.indexOf('cutover_started=1', start.indexOf('if [ "$environment" = production ]'))); + assert.match(start, /flock -n 9/); + assert.match(start, /account_backup_production/); + assert.match(start, /account_restore_previous_runtime/); + assert.match(start, /account_capture_previous_worker/); + assert.match(lib, /running mail worker SHA mismatch/); + assert.match(lib, /Account mail worker must not publish ports/); + assert.match(lib, /account_wait_container=\$1/); + assert.match(lib, /expected_sha=\$\{2:-\$BEACON_ACCOUNT_GIT_SHA\}/); + assert.match(lib, /expected_image_tag=\$\{3:-\$BEACON_ACCOUNT_IMAGE_TAG\}/); + assert.match(lib, /expected_worker_present=\$\{4:-1\}/); + assert.doesNotMatch(lib, /account_wait_healthy\(\) \{\s+container=\$1/); + assert.match(lib, /account_image_supports_mail_worker/); + assert.match(lib, /scripts\/process-account-mail-outbox\.ts/); + assert.match(lib, /account_image_supports_navigation_asset/); + assert.match(rollback, /previous_worker_present=0/); + assert.match(rollback, /account_image_supports_mail_worker/); + assert.match(rollback, /previous_nav_asset_present=0/); + assert.match(rollback, /account_image_supports_navigation_asset/); + assert.match(rollback, /health-smoke\.sh"[\s\\]+"\$environment" "\$ACCOUNT_DEPLOY_FILE" "\$previous_sha" "\$previous_worker_present"[\s\\]+"\$previous_nav_asset_present"/); + assert.doesNotMatch(rollback, /account_restore_previous_runtime "\$environment" "\$previous_sha" 1/); + assert.match(start, /account_require_internal_mail_network "\$environment"/); + assert.match(lib, /docker network inspect "\$network"/); + assert.match(lib, /must be an exact internal bridge/); + assert.match(lib, /docker run --rm --network none --read-only --cap-drop ALL --user 0:0/); + assert.match(lib, /\/app\/ops\/beacon-account\/validate\.mjs/); + assert.doesNotMatch(lib, /\n\s*node "\$root\/ops\/beacon-account\/validate\.mjs"/); + assert.match(lib, /production-pg-dump\.sh,dst=\/usr\/local\/bin\/beacon-account-production-pg-dump,readonly/); + assert.match(lib, /\/usr\/local\/bin\/beacon-account-production-pg-dump/); + assert.match(lib, /openssl enc -aes-256-cbc -salt -pbkdf2/); + assert.match(lib, /openssl enc -d -aes-256-cbc/); + assert.match(lib, /BEACON_ACCOUNT_BACKUP_DIR" = \/mnt\/beacon-data\/backups\/account/); + assert.match(lib, /mountpoint -q \/mnt\/beacon-data/); + assert.match(lib, /findmnt -n -o TARGET --target \/mnt\/beacon-data/); + assert.doesNotMatch(lib, /> "\$backup_dir\/\$backup_name"\s*$/m); + assert.match(lib, /database was not downgraded|account_restore_previous_runtime/); + assert.match(lib, /account_write_production_admin_env/); + assert.match(lib, /--network none --read-only --cap-drop ALL --user 0:0/); + assert.match(lib, /--network earlybirds_preview_db_internal --read-only --tmpfs \/tmp/); + assert.match(lib, /provision-production-role\.mjs/); + assert.doesNotMatch(lib, /GRANT\s+earlybirds_preview\s+TO\s+account_prod/i); + assert.match(smoke, /verify-health-json\.sh/); + assert.match(smoke, /--connect-timeout 3 --max-time 8/); + assert.match(smoke, /\/assets\/hb-global-nav\.js\?v=\$expected_sha/); + assert.match(smoke, /docker exec "\$container" sha256sum \/app\/public\/assets\/hb-global-nav\.js/); + assert.match(smoke, /public navigation asset differs from running image/); + assert.equal((smoke.match(/--proto '=https'/g) ?? []).length, 3); + assert.doesNotMatch(smoke, /\bnode\b/); + const migrationGuard = fs.readFileSync(path.resolve(ROOT, '../../scripts/beacon-account/check-migrations.mjs'), 'utf8'); + assert.match(migrationGuard, /pending migrations differ from the reviewed Account-only list/); + assert.match(migrationGuard, /pending\.length === 0 && applied\.has\(target\)/); + assert.match(migrationGuard, /unresolved migration/); + assert.doesNotMatch(`${start}\n${lib}`, /migrate reset|migrate down|docker compose down|volume rm|prune/); + const dockerfile = fs.readFileSync(path.resolve(ROOT, '../../Dockerfile'), 'utf8'); + assert.match(dockerfile, /BEACON_ACCOUNT_NAV_ASSET=1/); + assert.match(dockerfile, /ops\/beacon-account\/validate\.mjs/); + assert.match(dockerfile, /scripts\/beacon-account\/provision-production-role\.mjs/); + for (const fixture of [ + 'account.production.env.example', + 'account.staging.env.example', + 'database.staging.env.example', + 'account-mail-worker.production.env.example', + 'account-mail-worker.staging.env.example', + ]) { + assert.match(dockerfile, new RegExp(`ops/beacon-account/${fixture.replaceAll('.', '\\\.')}`)); + } +}); + +test('production backup removes only the Prisma schema query before pg_dump', () => { + const valid = runProductionPgDump( + 'postgresql://account_prod:example-password@earlybirds-preview-postgres/earlybirds_preview?schema=public', + ); + assert.equal(valid.status, 0, valid.stderr); + assert.deepEqual(valid.stdout.trim().split('\n'), [ + '--format=custom', + '--no-owner', + '--no-acl', + 'postgresql://account_prod:example-password@earlybirds-preview-postgres/earlybirds_preview', + ]); + + for (const databaseUrl of [ + 'postgresql://account_prod:do-not-print@earlybirds-preview-postgres/earlybirds_preview', + 'postgresql://account_prod:do-not-print@earlybirds-preview-postgres/earlybirds_preview?sslmode=require', + 'postgresql://account_prod:do-not-print@earlybirds-preview-postgres/earlybirds_preview?schema=private', + ]) { + const invalid = runProductionPgDump(databaseUrl); + assert.equal(invalid.status, 2); + assert.match(invalid.stderr, /reviewed public schema parameter/); + assert.doesNotMatch(invalid.stderr, /do-not-print/); + assert.equal(invalid.stdout, ''); + } +}); + +test('production runtime role provisioning is explicit, non-owner and allowlisted', () => { + const source = fs.readFileSync( + path.resolve(ROOT, '../../scripts/beacon-account/provision-production-role.mjs'), + 'utf8', + ); + assert.match(source, /RUNTIME_ROLE = 'account_prod'/); + assert.match(source, /NOSUPERUSER NOCREATEDB NOCREATEROLE/); + assert.match(source, /NOREPLICATION NOBYPASSRLS/); + assert.match(source, /runtime database role must not inherit another role/); + assert.match(source, /REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM account_prod/); + assert.match(source, /GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE/); + assert.match(source, /beacon_account_mail_outbox/); + assert.match(source, /beacon_oauth_access_tokens/); + assert.doesNotMatch(source, /GRANT\s+earlybirds_preview\s+TO\s+account_prod/i); + assert.doesNotMatch(source, /GRANT\s+ALL\s+(?:PRIVILEGES\s+)?ON\s+ALL\s+TABLES/i); +}); + +test('runtime verification preserves the pre-worker rollback boundary', () => { + assert.equal(verifyRunningFixture({ expectedWorkerPresent: 0, actualWorkerPresent: 0 }).status, 0); + assert.equal(verifyRunningFixture({ expectedWorkerPresent: 1, actualWorkerPresent: 1 }).status, 0); + assert.notEqual( + verifyRunningFixture({ expectedWorkerPresent: 0, actualWorkerPresent: 1 }).status, + 0, + ); + assert.notEqual( + verifyRunningFixture({ expectedWorkerPresent: 1, actualWorkerPresent: 0 }).status, + 0, + ); +}); + +test('navigation asset smoke preserves the pre-asset rollback boundary', () => { + assert.equal(navigationAssetCapabilityFixture(false).status, 0); + assert.equal(navigationAssetCapabilityFixture(true).status, 0); +}); + +test('health verifier accepts the exact public Ed25519 contract without optional use', () => { + assert.equal(verifyHealthFixture({ jwks: { keys: [publicEd25519Key] } }).status, 0); + assert.equal(verifyHealthFixture({ + jwks: { keys: [{ ...publicEd25519Key, use: 'sig', key_ops: ['verify'] }] }, + }).status, 0); +}); + +test('health verifier rejects unusable, private or ambiguous JWKS material', () => { + const invalid = [ + ['empty set', { keys: [] }], + ['wrong use', { keys: [{ ...publicEd25519Key, use: 'enc' }] }], + ['wrong operations', { keys: [{ ...publicEd25519Key, key_ops: ['sign'] }] }], + ['wrong type', { keys: [{ ...publicEd25519Key, kty: 'EC' }] }], + ['wrong algorithm', { keys: [{ ...publicEd25519Key, alg: 'ES256' }] }], + ['wrong curve', { keys: [{ ...publicEd25519Key, crv: 'X25519' }] }], + ['missing public material', { keys: [{ ...publicEd25519Key, x: undefined }] }], + ['invalid public material', { keys: [{ ...publicEd25519Key, x: `${'A'.repeat(42)}=` }] }], + ['equivalent noncanonical public material', { keys: [{ ...publicEd25519Key, x: `${'A'.repeat(42)}B` }] }], + ['private material', { keys: [{ ...publicEd25519Key, d: 'B'.repeat(43) }] }], + ['missing key id', { keys: [{ ...publicEd25519Key, kid: '' }] }], + ['duplicate key id', { keys: [publicEd25519Key, { ...publicEd25519Key }] }], + ]; + for (const [label, jwks] of invalid) { + assert.notEqual(verifyHealthFixture({ jwks }).status, 0, label); + } +}); + +test('health verifier rejects readiness provenance and OIDC contract drift', () => { + assert.notEqual(verifyHealthFixture({ + jwks: { keys: [publicEd25519Key] }, ready: { gitSha: 'b'.repeat(40) }, + }).status, 0); + assert.notEqual(verifyHealthFixture({ + jwks: { keys: [publicEd25519Key] }, + discovery: { token_endpoint_auth_methods_supported: ['client_secret_post'] }, + }).status, 0); +}); + +test('nginx keeps Account hosts isolated and never logs token-bearing routes', () => { + for (const [name, port, zone] of [ + ['account.harmonicbeacon.com.conf.template', '13002', 'beacon_account_prod_auth'], + ['account-staging.harmonicbeacon.com.conf.template', '13003', 'beacon_account_staging_auth'], + ]) { + const nginx = fs.readFileSync(path.join(ROOT, 'nginx', name), 'utf8'); + assert.match(nginx, new RegExp(`proxy_pass http://127\\.0\\.0\\.1:${port}`)); + assert.match(nginx, new RegExp(`limit_req zone=${zone}`)); + for (const route of [ + '/verify-email', + '/reset-password', + '/assets/hb-global-nav.js', + ]) { + const start = nginx.indexOf(`location = ${route} {`); + assert.ok(start >= 0, `${route} must be exact`); + assert.match(nginx.slice(start, nginx.indexOf('\n }', start)), /access_log off;/); + assert.match(nginx.slice(start, nginx.indexOf('\n }', start)), /Referrer-Policy "no-referrer"/); + assert.match(nginx.slice(start, nginx.indexOf('\n }', start)), /Strict-Transport-Security/); + } + const redirectServer = nginx.slice(0, nginx.indexOf('server {', nginx.indexOf('server {') + 1)); + assert.match(redirectServer, /access_log off;/); + assert.doesNotMatch(nginx, /location \^~ \/assets\//); + assert.match(nginx, /location \/ \{ return 404; \}/); + assert.doesNotMatch(nginx, /proxy_pass http:\/\/127\.0\.0\.1:(?!13002|13003)/); + assert.doesNotMatch(nginx, /livekit|listener\/checkout|webhooks|events/); + } +}); + +test('Account ACME bootstraps expose only the exact certificate challenge', () => { + for (const [name, host] of [ + ['account-acme-bootstrap.conf.template', 'account.harmonicbeacon.com'], + ['account-staging-acme-bootstrap.conf.template', 'account-staging.harmonicbeacon.com'], + ]) { + const nginx = fs.readFileSync(path.join(ROOT, 'nginx', name), 'utf8'); + assert.match(nginx, new RegExp(`server_name ${host.replaceAll('.', '\\.')};`)); + assert.match(nginx, /access_log off;/); + assert.match(nginx, /location \^~ \/\.well-known\/acme-challenge\/ \{/); + assert.match(nginx, /root \/var\/www\/letsencrypt;/); + assert.match(nginx, /location \/ \{ return 503; \}/); + assert.doesNotMatch(nginx, /listen 443|ssl_certificate|proxy_pass|1300[0-9]/); + } +}); + +test('Account production edge stays truthful while its upstream is unavailable', () => { + const nginx = fs.readFileSync( + path.join(ROOT, 'nginx/account.harmonicbeacon.com.conf.template'), + 'utf8', + ); + assert.match(nginx, /proxy_intercept_errors on;/); + assert.match(nginx, /error_page 502 503 504 = @account_unavailable;/); + const unavailable = nginx.slice(nginx.indexOf('location @account_unavailable')); + assert.match(unavailable, /access_log off;/); + assert.match(unavailable, /Cache-Control "no-store" always;/); + assert.match(unavailable, /return 503;/); +}); + +test('social-provider runbook uses only central Account callbacks and default-off activation', () => { + const runbook = fs.readFileSync( + path.resolve(ROOT, '../../docs/operations/BEACON_ACCOUNT_SOCIAL_PROVIDERS.md'), + 'utf8', + ); + for (const environment of ['account-staging', 'account']) { + for (const provider of ['google', 'apple']) { + assert.match(runbook, new RegExp( + `https://${environment}\\.harmonicbeacon\\.com/api/account/auth/callback/${provider}`, + )); + } + } + for (const provider of ['GOOGLE', 'APPLE']) { + assert.match(runbook, new RegExp(`BEACON_ACCOUNT_${provider}_CLIENT_ID`)); + assert.match(runbook, new RegExp(`BEACON_ACCOUNT_${provider}_CLIENT_SECRET`)); + } + assert.match(runbook, /activate-social-provider\.sh/); + assert.match(runbook, /rollback-social-provider\.sh/); + assert.match(runbook, /There is no installed-but-disabled intermediate state/); + assert.doesNotMatch(runbook, /api\/early-birds\/auth\/callback\/(?:google|apple)/); + assert.match(runbook, /Matching email never links or merges accounts/); + + const legacy = fs.readFileSync( + path.resolve(ROOT, '../../docs/operations/LISTENER_APPLE_IDENTITY.md'), + 'utf8', + ); + assert.match(legacy, /Legacy cutover note/); + assert.match(legacy, /BEACON_ACCOUNT_SOCIAL_PROVIDERS\.md/); +}); + +test('social-provider activation is exact-image, offline, backed up and app-only', () => { + const activate = fs.readFileSync( + path.resolve(ROOT, '../../scripts/beacon-account/activate-social-provider.sh'), + 'utf8', + ); + const rollback = fs.readFileSync( + path.resolve(ROOT, '../../scripts/beacon-account/rollback-social-provider.sh'), + 'utf8', + ); + const transformer = fs.readFileSync( + path.resolve(ROOT, '../../scripts/beacon-account/social-provider-env.mjs'), + 'utf8', + ); + const dockerfile = fs.readFileSync(path.resolve(ROOT, '../../Dockerfile'), 'utf8'); + assert.match(activate, /account-provider-\$environment-\$provider\.env/); + assert.match(activate, /account_backup_(?:production|staging)/); + assert.match(activate, /--network none --read-only --user 0:0 --cap-drop ALL/); + assert.match(activate, /social-provider-env\.mjs/); + assert.match(activate, /validate\.mjs/); + assert.match(activate, /up -d --no-deps --force-recreate --no-build "account-\$environment"/); + assert.match(activate, /mail worker changed during provider activation/); + assert.doesNotMatch(activate, /account-mail-worker-\$environment"/); + assert.doesNotMatch(activate, /^state=/m); + assert.doesNotMatch(rollback, /^state=/m); + assert.match(rollback, /identities and sessions were retained/); + assert.match(transformer, /bundle must contain only the exact provider client ID and secret/); + assert.match(dockerfile, /scripts\/beacon-account\/social-provider-env\.mjs/); + for (const script of [ + 'activate-social-provider.sh', + 'rollback-social-provider.sh', + ]) { + const checked = spawnSync('sh', ['-n', path.resolve(ROOT, `../../scripts/beacon-account/${script}`)], { + encoding: 'utf8', + }); + assert.equal(checked.status, 0, checked.stderr); + } +}); diff --git a/ops/beacon-account/test/fixtures/google-provider.env b/ops/beacon-account/test/fixtures/google-provider.env new file mode 100644 index 00000000..cca348b8 --- /dev/null +++ b/ops/beacon-account/test/fixtures/google-provider.env @@ -0,0 +1,2 @@ +BEACON_ACCOUNT_GOOGLE_CLIENT_ID=staging-client.apps.googleusercontent.com +BEACON_ACCOUNT_GOOGLE_CLIENT_SECRET=synthetic_google_secret_1234567890 diff --git a/ops/beacon-account/test/social-provider-env.test.mjs b/ops/beacon-account/test/social-provider-env.test.mjs new file mode 100644 index 00000000..217d0ddd --- /dev/null +++ b/ops/beacon-account/test/social-provider-env.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { buildSocialProviderActivation } from '../../../scripts/beacon-account/social-provider-env.mjs'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const staging = fs.readFileSync(path.join(ROOT, 'account.staging.env.example'), 'utf8'); +const production = fs.readFileSync(path.join(ROOT, 'account.production.env.example'), 'utf8'); + +function jwt(payload = {}, header = {}) { + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'ES256', kid: 'KEY1234567', ...header })}.${encode({ + iss: 'TEAM123456', + sub: 'com.harmonicbeacon.account.staging', + aud: 'https://appleid.apple.com', + iat: 2_000_000_000, + exp: 2_000_086_400, + ...payload, + })}.${Buffer.alloc(64, 1).toString('base64url')}`; +} + +test('prepares an exact Google activation without changing unrelated Account values', () => { + const bundle = [ + 'BEACON_ACCOUNT_GOOGLE_CLIENT_ID=staging-client.apps.googleusercontent.com', + 'BEACON_ACCOUNT_GOOGLE_CLIENT_SECRET=google_secret_1234567890', + '', + ].join('\n'); + const result = buildSocialProviderActivation({ + accountContents: staging, + bundleContents: bundle, + environment: 'staging', + provider: 'google', + }); + assert.match(result, /^BEACON_ACCOUNT_GOOGLE_ENABLED=1$/m); + assert.match(result, /^BEACON_ACCOUNT_GOOGLE_CLIENT_ID=staging-client\.apps\.googleusercontent\.com$/m); + assert.match(result, /^BEACON_ACCOUNT_GOOGLE_CLIENT_SECRET=google_secret_1234567890$/m); + assert.match(result, /^BEACON_ACCOUNT_APPLE_ENABLED=0$/m); + assert.equal( + result.replace(/^BEACON_ACCOUNT_GOOGLE_(?:ENABLED|CLIENT_ID|CLIENT_SECRET)=.*$/gm, ''), + staging.replace(/^BEACON_ACCOUNT_GOOGLE_(?:ENABLED|CLIENT_ID|CLIENT_SECRET)=.*$/gm, ''), + ); +}); + +test('accepts a current bounded Apple JWT and preserves production isolation', () => { + const secret = jwt(); + const result = buildSocialProviderActivation({ + accountContents: production, + bundleContents: `BEACON_ACCOUNT_APPLE_CLIENT_ID=com.harmonicbeacon.account.staging\nBEACON_ACCOUNT_APPLE_CLIENT_SECRET=${secret}\n`, + environment: 'production', + provider: 'apple', + nowSeconds: 2_000_000_100, + }); + assert.match(result, /^BEACON_ACCOUNT_APPLE_ENABLED=1$/m); + assert.match(result, /^BEACON_ACCOUNT_GOOGLE_ENABLED=0$/m); +}); + +test('rejects issuer mismatch, extra keys, enabled targets and invalid provider material', () => { + const google = 'BEACON_ACCOUNT_GOOGLE_CLIENT_ID=x.apps.googleusercontent.com\nBEACON_ACCOUNT_GOOGLE_CLIENT_SECRET=google_secret_1234567890\n'; + const base = { accountContents: staging, environment: 'staging', provider: 'google' }; + assert.throws(() => buildSocialProviderActivation({ ...base, environment: 'production', bundleContents: google }), /issuer mismatch/); + assert.throws(() => buildSocialProviderActivation({ ...base, bundleContents: `${google}UNEXPECTED=1\n` }), /only the exact/); + assert.throws(() => buildSocialProviderActivation({ + ...base, + accountContents: staging.replace('BEACON_ACCOUNT_GOOGLE_ENABLED=0', 'BEACON_ACCOUNT_GOOGLE_ENABLED=1'), + bundleContents: google, + }), /fully disabled/); + assert.throws(() => buildSocialProviderActivation({ + ...base, + bundleContents: 'BEACON_ACCOUNT_GOOGLE_CLIENT_ID=not-google\nBEACON_ACCOUNT_GOOGLE_CLIENT_SECRET=google_secret_1234567890\n', + }), /client ID/); +}); + +test('rejects raw, expired, overlong, wrong-subject and noncanonical Apple secrets', () => { + const make = (secret) => `BEACON_ACCOUNT_APPLE_CLIENT_ID=com.harmonicbeacon.account.staging\nBEACON_ACCOUNT_APPLE_CLIENT_SECRET=${secret}\n`; + const base = { + accountContents: staging, + environment: 'staging', + provider: 'apple', + nowSeconds: 2_000_000_100, + }; + assert.throws(() => buildSocialProviderActivation({ ...base, bundleContents: make('-----BEGIN PRIVATE KEY-----') }), /invalid/); + assert.throws(() => buildSocialProviderActivation({ ...base, bundleContents: make(jwt({ exp: 2_000_000_099 })) }), /claims/); + assert.throws(() => buildSocialProviderActivation({ ...base, bundleContents: make(jwt({ exp: 2_020_000_000 })) }), /claims/); + assert.throws(() => buildSocialProviderActivation({ ...base, bundleContents: make(jwt({ sub: 'wrong' })) }), /claims/); + assert.throws(() => buildSocialProviderActivation({ ...base, bundleContents: make(`${jwt()}=`) }), /invalid/); +}); diff --git a/ops/beacon-account/validate.mjs b/ops/beacon-account/validate.mjs new file mode 100755 index 00000000..8b499a0e --- /dev/null +++ b/ops/beacon-account/validate.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const ROOT = path.dirname(new URL(import.meta.url).pathname); +const PROD_ORIGIN = 'https://account.harmonicbeacon.com'; +const STAGING_ORIGIN = 'https://account-staging.harmonicbeacon.com'; +const MAIL_URL = 'http://listener-mail-api:8765/api/internal/v1/listener-account-mail/deliver'; + +export function parseEnvFile(file) { + const result = new Map(); + for (const [index, source] of fs.readFileSync(file, 'utf8').split(/\r?\n/).entries()) { + const line = source.trim(); + if (!line || line.startsWith('#')) continue; + const equals = line.indexOf('='); + if (equals < 1) throw new Error(`${path.basename(file)}:${index + 1}: invalid assignment`); + const key = line.slice(0, equals); + if (!/^[A-Z][A-Z0-9_]*$/.test(key) || result.has(key)) { + throw new Error(`${path.basename(file)}:${index + 1}: duplicate or invalid key`); + } + result.set(key, line.slice(equals + 1)); + } + return result; +} + +function required(env, key, minimum = 1) { + const value = env.get(key) ?? ''; + if (value.length < minimum) throw new Error(`${key} is missing or too short`); + return value; +} + +function validateDatabase(raw, expected) { + const url = new URL(raw); + if (!['postgres:', 'postgresql:'].includes(url.protocol) || !url.hostname || !url.pathname.slice(1)) { + throw new Error('DATABASE_URL must identify PostgreSQL'); + } + if ((url.searchParams.get('schema') ?? 'public') !== expected.schema) { + throw new Error(`DATABASE_URL must use schema=${expected.schema}`); + } + if (url.hostname !== expected.host || url.pathname.slice(1) !== expected.database) { + throw new Error(`DATABASE_URL must use isolated ${expected.host}/${expected.database}`); + } + if (decodeURIComponent(url.username) !== expected.user) { + throw new Error(`DATABASE_URL must use the dedicated ${expected.user} role`); + } + const password = decodeURIComponent(url.password); + if (!/^[A-Za-z0-9_-]{32,128}$/.test(password)) { + throw new Error('DATABASE_URL password must be 32-128 base64url characters'); + } + return { + identity: `${url.hostname}:${url.port || '5432'}${url.pathname}?schema=${expected.schema}`, + url, + }; +} + +function assertRealSecret(value, key, allowPlaceholders) { + if (!allowPlaceholders && /^(?:replace|change|example|test|changeme)(?:-|_|$)/i.test(value)) { + throw new Error(`${key} is still a placeholder`); + } +} + +function assertBase64Url32(value, key) { + if (!/^[A-Za-z0-9_-]{43}$/.test(value)) { + throw new Error(`${key} must be unpadded base64url for exactly 32 bytes`); + } + const decoded = Buffer.from(value, 'base64url'); + if (decoded.length !== 32 || decoded.toString('base64url') !== value) { + throw new Error(`${key} must be canonical unpadded base64url for exactly 32 bytes`); + } +} + +function validateEnvironment(env, kind, allowPlaceholders, stagingDatabaseEnv) { + const production = kind === 'production'; + const origin = production ? PROD_ORIGIN : STAGING_ORIGIN; + if (required(env, 'BEACON_ACCOUNT_RUNTIME') !== '1') throw new Error('Account runtime gate must be 1'); + if (required(env, 'BEACON_ACCOUNT_BASE_URL') !== origin) throw new Error(`${kind} Account origin mismatch`); + if (required(env, 'BEACON_ACCOUNT_PROVISION_CONFIRM_ISSUER') !== origin) { + throw new Error(`${kind} provision confirmation mismatch`); + } + if (required(env, 'BEACON_ACCOUNT_TRUSTED_ORIGINS') !== origin) { + throw new Error(`${kind} trusted origins must contain only its own origin`); + } + for (const key of [ + 'BEACON_ACCOUNT_AUTH_SECRET', 'BEACON_ACCOUNT_RATE_SECRET', + 'BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN', 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY', + ]) { + const secret = required(env, key, 32); + assertRealSecret(secret, key, allowPlaceholders); + } + assertBase64Url32(required(env, 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY'), 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY'); + if (required(env, 'BEACON_ACCOUNT_MAIL_DELIVERY_URL') !== MAIL_URL) { + throw new Error('Account mail URL must use the exact private endpoint'); + } + for (const provider of ['GOOGLE', 'APPLE']) { + const gate = required(env, `BEACON_ACCOUNT_${provider}_ENABLED`); + if (!['0', '1'].includes(gate)) throw new Error(`${provider} gate must be 0 or 1`); + const id = env.get(`BEACON_ACCOUNT_${provider}_CLIENT_ID`) ?? ''; + const secret = env.get(`BEACON_ACCOUNT_${provider}_CLIENT_SECRET`) ?? ''; + if ((gate === '1') !== Boolean(id && secret)) throw new Error(`${provider} gate and credentials disagree`); + if (gate === '1') { + assertRealSecret(id, `BEACON_ACCOUNT_${provider}_CLIENT_ID`, allowPlaceholders); + assertRealSecret(secret, `BEACON_ACCOUNT_${provider}_CLIENT_SECRET`, allowPlaceholders); + } + } + const active = production + ? ['BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER', 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE'] + : ['BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER_STAGING', 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE_STAGING']; + const inactive = production + ? ['BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER_STAGING', 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE_STAGING'] + : ['BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER', 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE']; + active.forEach((key) => { + const secret = required(env, key, 32); + assertRealSecret(secret, key, allowPlaceholders); + }); + inactive.forEach((key) => { + if (env.get(key)) throw new Error(`${key} must be empty outside its issuer`); + }); + const database = validateDatabase(required(env, 'DATABASE_URL'), production + ? { host: 'earlybirds-preview-postgres', database: 'earlybirds_preview', schema: 'public', user: 'account_prod' } + : { host: 'account-staging-postgres', database: 'beacon_account_staging', schema: 'public', user: 'beacon_account_staging' }); + if (!production) { + assertRealSecret(required(stagingDatabaseEnv, 'POSTGRES_PASSWORD', 32), 'POSTGRES_PASSWORD', allowPlaceholders); + if (!stagingDatabaseEnv || + required(stagingDatabaseEnv, 'POSTGRES_USER') !== decodeURIComponent(database.url.username) || + required(stagingDatabaseEnv, 'POSTGRES_PASSWORD') !== decodeURIComponent(database.url.password) || + required(stagingDatabaseEnv, 'POSTGRES_DB') !== database.url.pathname.slice(1)) { + throw new Error('staging PostgreSQL bootstrap values and DATABASE_URL disagree'); + } + } + const secretKeys = [ + 'BEACON_ACCOUNT_AUTH_SECRET', 'BEACON_ACCOUNT_RATE_SECRET', + 'BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN', 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY', ...active, + ...['GOOGLE', 'APPLE'].flatMap((provider) => + (env.get(`BEACON_ACCOUNT_${provider}_ENABLED`) === '1' + ? [`BEACON_ACCOUNT_${provider}_CLIENT_SECRET`] : [])), + ]; + const seen = new Map(); + for (const key of secretKeys) { + const value = env.get(key) ?? ''; + if (!value) continue; + const previous = seen.get(value); + if (previous) throw new Error(`${key} must differ from ${previous}`); + seen.set(value, key); + } + return database.identity; +} + +const WORKER_KEYS = new Set([ + 'DATABASE_URL', + 'BEACON_ACCOUNT_BASE_URL', + 'BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN', + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY', +]); + +function validateWorkerEnvironment(worker, application, kind, allowPlaceholders) { + for (const key of worker.keys()) { + if (!WORKER_KEYS.has(key)) throw new Error(`${kind} mail worker contains forbidden key ${key}`); + } + for (const key of WORKER_KEYS) { + const value = required(worker, key, key.includes('TOKEN') || key.includes('KEY') ? 32 : 1); + if (value !== application.get(key)) { + throw new Error(`${kind} mail worker ${key} must match the Account application value`); + } + } + assertRealSecret(required(worker, 'BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN', 32), + 'BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN', allowPlaceholders); + assertRealSecret(required(worker, 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY', 32), + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY', allowPlaceholders); + assertBase64Url32(required(worker, 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY'), + 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY'); +} + +export function validatePair( + productionFile, + stagingFile, + stagingDatabaseFile, + allowPlaceholders = false, + productionWorkerFile = path.join(ROOT, 'account-mail-worker.production.env.example'), + stagingWorkerFile = path.join(ROOT, 'account-mail-worker.staging.env.example'), +) { + const production = parseEnvFile(productionFile); + const staging = parseEnvFile(stagingFile); + const stagingDatabaseEnvironment = parseEnvFile(stagingDatabaseFile); + const productionWorker = parseEnvFile(productionWorkerFile); + const stagingWorker = parseEnvFile(stagingWorkerFile); + const productionDatabase = validateEnvironment(production, 'production', allowPlaceholders, null); + const stagingDatabase = validateEnvironment(staging, 'staging', allowPlaceholders, stagingDatabaseEnvironment); + validateWorkerEnvironment(productionWorker, production, 'production', allowPlaceholders); + validateWorkerEnvironment(stagingWorker, staging, 'staging', allowPlaceholders); + if (productionDatabase === stagingDatabase) throw new Error('production and staging DATABASE_URL must be isolated'); + for (const key of [ + 'BEACON_ACCOUNT_AUTH_SECRET', 'BEACON_ACCOUNT_RATE_SECRET', + 'BEACON_ACCOUNT_MAIL_DELIVERY_TOKEN', 'BEACON_ACCOUNT_MAIL_OUTBOX_KEY', + ]) { + if (production.get(key) === staging.get(key)) throw new Error(`${key} must differ between issuers`); + } + const productionSecrets = new Map([...production] + .filter(([key, value]) => value && /(?:SECRET|TOKEN|PASSWORD)/.test(key)) + .map(([key, value]) => [value, key])); + for (const [key, value] of staging) { + if (!value || !/(?:SECRET|TOKEN|PASSWORD)/.test(key)) continue; + const productionKey = productionSecrets.get(value); + if (productionKey) throw new Error(`${key} must differ from production ${productionKey}`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) { + const productionFile = process.argv[2] ?? path.join(ROOT, 'account.production.env.example'); + const stagingFile = process.argv[3] ?? path.join(ROOT, 'account.staging.env.example'); + const stagingDatabaseFile = process.argv[4] ?? path.join(ROOT, 'database.staging.env.example'); + const productionWorkerFile = process.argv[5] ?? path.join(ROOT, 'account-mail-worker.production.env.example'); + const stagingWorkerFile = process.argv[6] ?? path.join(ROOT, 'account-mail-worker.staging.env.example'); + const canonicalExamples = [ + path.join(ROOT, 'account.production.env.example'), + path.join(ROOT, 'account.staging.env.example'), + path.join(ROOT, 'database.staging.env.example'), + path.join(ROOT, 'account-mail-worker.production.env.example'), + path.join(ROOT, 'account-mail-worker.staging.env.example'), + ].map((value) => fs.realpathSync(value)); + const requestedFiles = [ + productionFile, stagingFile, stagingDatabaseFile, productionWorkerFile, stagingWorkerFile, + ].map((value) => fs.realpathSync(value)); + const examples = requestedFiles.every((value, index) => value === canonicalExamples[index]); + validatePair( + productionFile, stagingFile, stagingDatabaseFile, examples, + productionWorkerFile, stagingWorkerFile, + ); +} diff --git a/ops/early-birds-preview/.gitignore b/ops/early-birds-preview/.gitignore new file mode 100644 index 00000000..25f347f4 --- /dev/null +++ b/ops/early-birds-preview/.gitignore @@ -0,0 +1 @@ +preview.env diff --git a/ops/early-birds-preview/authority-network.override.yml b/ops/early-birds-preview/authority-network.override.yml new file mode 100644 index 00000000..3c4768fe --- /dev/null +++ b/ops/early-birds-preview/authority-network.override.yml @@ -0,0 +1,14 @@ +# Optional handoff to an external, Free-only canonical membership authority. +# Include this file only through the guarded lifecycle scripts by setting the +# dedicated network name in preview.env. No paid-provider service is defined. +services: + listener: + networks: + authority_private: + aliases: + - earlybirds-listener + +networks: + authority_private: + external: true + name: ${EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK:?set_only_for_private_authority_handoff} diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml new file mode 100644 index 00000000..08f9177e --- /dev/null +++ b/ops/early-birds-preview/compose.yml @@ -0,0 +1,195 @@ +# Isolated EarlyBirds staging runtime. This project never joins the weekend +# event compose stack and publishes only the Listener and stream origin on +# loopback for host nginx. +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${EARLYBIRDS_PREVIEW_DB_USER:?set_in_preview.env} + POSTGRES_PASSWORD: ${EARLYBIRDS_PREVIEW_DB_PASSWORD:?set_in_preview.env} + POSTGRES_DB: ${EARLYBIRDS_PREVIEW_DB_NAME:?set_in_preview.env} + volumes: + - earlybirds-preview-postgres:/var/lib/postgresql/data + networks: + preview_db: + aliases: + # The Listener also joins the private membership-authority network, + # whose Compose project has its own `postgres` service. Keep the + # preview database hostname unambiguous across both networks. + - earlybirds-preview-postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 3s + retries: 12 + deploy: + resources: + limits: { cpus: "1.0", memory: 1G } + reservations: { cpus: "0.25", memory: 256M } + + # Forward-only gate: the Listener cannot start until every checked-in Prisma + # migration has applied successfully to this preview-only database. + migration: + build: + context: ../.. + target: deps + restart: "no" + working_dir: /app + command: ["npx", "prisma", "migrate", "deploy"] + environment: + DATABASE_URL: postgresql://${EARLYBIRDS_PREVIEW_DB_USER}:${EARLYBIRDS_PREVIEW_DB_PASSWORD}@earlybirds-preview-postgres:5432/${EARLYBIRDS_PREVIEW_DB_NAME}?schema=public + depends_on: + postgres: { condition: service_healthy } + networks: [preview_db] + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + + listener: + image: harmonic-beacon/earlybirds-preview-listener:${EARLYBIRDS_PREVIEW_IMAGE_TAG:-synthetic} + build: + context: ../.. + target: runner + args: + NEXT_PUBLIC_LIVEKIT_URL: https://livekit.example.invalid + BEACON_GIT_SHA: ${EARLYBIRDS_PREVIEW_GIT_SHA:-synthetic-preview} + BEACON_BUILD_TIME: ${EARLYBIRDS_PREVIEW_BUILD_TIME:-synthetic-preview} + BEACON_DATABASE_SCHEMA_VERSION: ${EARLYBIRDS_PREVIEW_SCHEMA_VERSION:?set_in_preview.env} + restart: unless-stopped + init: true + environment: + NODE_ENV: production + BEACON_GIT_SHA: ${EARLYBIRDS_PREVIEW_GIT_SHA:-synthetic-preview} + BEACON_BUILD_TIME: ${EARLYBIRDS_PREVIEW_BUILD_TIME:-synthetic-preview} + BEACON_DATABASE_SCHEMA_VERSION: ${EARLYBIRDS_PREVIEW_SCHEMA_VERSION:?set_in_preview.env} + DATABASE_URL: postgresql://${EARLYBIRDS_PREVIEW_DB_USER}:${EARLYBIRDS_PREVIEW_DB_PASSWORD}@earlybirds-preview-postgres:5432/${EARLYBIRDS_PREVIEW_DB_NAME}?schema=public + EARLY_BIRDS_ENABLED: ${EARLY_BIRDS_ENABLED:-0} + EARLY_BIRDS_FREE_FOR_ALL: ${EARLY_BIRDS_FREE_FOR_ALL:-0} + EARLY_BIRDS_AUTH_BASE_URL: ${EARLY_BIRDS_AUTH_BASE_URL:?set_in_preview.env} + EARLY_BIRDS_TRUSTED_ORIGINS: ${EARLY_BIRDS_TRUSTED_ORIGINS:?set_in_preview.env} + EARLY_BIRDS_AUTH_SECRET: ${EARLY_BIRDS_AUTH_SECRET:?set_in_preview.env} + EARLY_BIRDS_GOOGLE_CLIENT_ID: ${EARLY_BIRDS_GOOGLE_CLIENT_ID:-} + EARLY_BIRDS_GOOGLE_CLIENT_SECRET: ${EARLY_BIRDS_GOOGLE_CLIENT_SECRET:-} + BEACON_LISTENER_APPLE_ENABLED: ${BEACON_LISTENER_APPLE_ENABLED:-0} + BEACON_LISTENER_APPLE_CLIENT_ID: ${BEACON_LISTENER_APPLE_CLIENT_ID:-} + BEACON_LISTENER_APPLE_CLIENT_SECRET: ${BEACON_LISTENER_APPLE_CLIENT_SECRET:-} + # Central Account RP is an independent, default-off cutover. All values + # live only in the root-owned runtime env; prod/staging secrets differ. + BEACON_LISTENER_ACCOUNT_ENABLED: ${BEACON_LISTENER_ACCOUNT_ENABLED:-0} + BEACON_LISTENER_ACCOUNT_ENVIRONMENT: production + BEACON_LISTENER_ACCOUNT_CLIENT_SECRET: ${BEACON_LISTENER_ACCOUNT_CLIENT_SECRET:-} + BEACON_LISTENER_ACCOUNT_STATE_SECRET: ${BEACON_LISTENER_ACCOUNT_STATE_SECRET:-} + EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL: ${EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL:-} + EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN: ${EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN:-} + EARLY_BIRDS_MAGIC_LINK_RATE_SECRET: ${EARLY_BIRDS_MAGIC_LINK_RATE_SECRET:-} + EARLY_BIRDS_AUTHORITY_BASE_URL: ${EARLY_BIRDS_AUTHORITY_BASE_URL:?set_in_preview.env} + EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID: ${EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID:?set_in_preview.env} + EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN: ${EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN:?set_in_preview.env} + EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID: ${EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID:?set_in_preview.env} + EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT: ${EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT:?set_in_preview.env} + EARLY_BIRDS_STREAM_ORIGIN: ${EARLY_BIRDS_STREAM_ORIGIN:?set_in_preview.env} + EARLY_BIRDS_STREAM_CONTROL_ORIGIN: ${EARLY_BIRDS_STREAM_CONTROL_ORIGIN:?set_in_preview.env} + EARLY_BIRDS_STREAM_ARTIFACT_ID: ${EARLY_BIRDS_STREAM_ARTIFACT_ID:?set_in_preview.env} + EARLY_BIRDS_STREAM_SIGNING_SECRET: ${EARLY_BIRDS_STREAM_SIGNING_SECRET:?set_in_preview.env} + EARLY_BIRDS_DEVICE_PEPPER: ${EARLY_BIRDS_DEVICE_PEPPER:?set_in_preview.env} + EARLY_BIRDS_DROPIN_ES_PATH: ${EARLY_BIRDS_DROPIN_ES_PATH:-} + EARLY_BIRDS_DROPIN_EN_PATH: ${EARLY_BIRDS_DROPIN_EN_PATH:-} + EARLY_BIRDS_TEST_ACCESS_ENABLED: ${EARLY_BIRDS_TEST_ACCESS_ENABLED:-0} + EARLY_BIRDS_TEST_LOGIN_SECRET: ${EARLY_BIRDS_TEST_LOGIN_SECRET:-} + EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED: ${EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED:-0} + EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS: ${EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS:?set_in_preview.env} + BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED: ${BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED:-0} + # Public consumer withdrawal stays dark until the additive migration, + # dedicated secret and queue alerting have all been installed. + LISTENER_WITHDRAWAL_ENABLED: ${LISTENER_WITHDRAWAL_ENABLED:-0} + LISTENER_WITHDRAWAL_SECRET: ${LISTENER_WITHDRAWAL_SECRET:-} + BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED: ${BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED:-0} + BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED: ${BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED:-0} + # Public Live checkout has a separate, fail-closed gate from the staging + # providers. Both stay OFF until the supervised commercial cutover. + BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED: ${BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED:-0} + BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED: ${BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED:-0} + # A separate staging-only, account-bound Live acceptance seam. The + # normal staging checkout above remains Sandbox/TEST. All four values + # are server-side and the enabled default is deliberately OFF. + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED: ${BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED:-0} + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID: ${BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID:-} + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER: ${BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER:-} + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET: ${BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET:-} + BEACON_LISTENER_GEOIP_DB_PATH: /data/geoip/dbip-country-lite.mmdb + TRUSTED_PROXY_HOPS: "1" + ports: + - "127.0.0.1:${EARLYBIRDS_PREVIEW_APP_PORT:-13000}:3000" + volumes: + - ${BEACON_STREAM_ARTIFACTS_HOST_PATH:?set_in_preview.env}:/media/artifacts:ro + - ${BEACON_LISTENER_GEOIP_HOST_PATH:?set_in_preview.env}:/data/geoip/dbip-country-lite.mmdb:ro + networks: + - preview_db + - listener_egress + - stream_control + depends_on: + postgres: { condition: service_healthy } + migration: { condition: service_completed_successfully } + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:3000/api/health/ready"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 10s + deploy: + resources: + limits: { cpus: "1.0", memory: 1G } + reservations: { cpus: "0.25", memory: 256M } + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" + + # Durable operator seam: intentionally independent from the app container so + # a Listener image rollback cannot remove queue processing or metrics. Pin an + # exact image that contains the withdrawal operator; never use a moving tag. + withdrawal-operator: + image: harmonic-beacon/earlybirds-preview-listener:${EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG:?set_exact_operator_image_tag} + restart: unless-stopped + init: true + command: ["tail", "-f", "/dev/null"] + environment: + NODE_ENV: production + DATABASE_URL: postgresql://${EARLYBIRDS_PREVIEW_DB_USER}:${EARLYBIRDS_PREVIEW_DB_PASSWORD}@earlybirds-preview-postgres:5432/${EARLYBIRDS_PREVIEW_DB_NAME}?schema=public + networks: [preview_db] + depends_on: + postgres: { condition: service_healthy } + migration: { condition: service_completed_successfully } + healthcheck: + test: ["CMD", "sh", "-ec", "test -f scripts/listener-withdrawal-operator.ts && test -f src/lib/listener/consumer-withdrawal.ts && test -x node_modules/.bin/tsx"] + interval: 30s + timeout: 3s + retries: 3 + deploy: + resources: + limits: { cpus: "0.25", memory: 256M } + logging: + driver: json-file + options: + max-size: 5m + max-file: "2" + +networks: + preview_db: + name: earlybirds_preview_db_internal + internal: true + # Only the Listener joins this bridge, so it can fetch the approved public + # HTTPS stream origin without giving PostgreSQL or migrations internet egress. + listener_egress: + name: earlybirds_preview_listener_egress + # Private control-plane link: Listener renews opaque media grants here while + # browsers fetch audio directly from the public stream origin. + stream_control: + name: earlybirds_stream_control_internal + internal: true + +volumes: + earlybirds-preview-postgres: + name: earlybirds-preview-postgres diff --git a/ops/early-birds-preview/nginx/acme-bootstrap.conf.template b/ops/early-birds-preview/nginx/acme-bootstrap.conf.template new file mode 100644 index 00000000..2717958d --- /dev/null +++ b/ops/early-birds-preview/nginx/acme-bootstrap.conf.template @@ -0,0 +1,16 @@ +# Temporary certificate bootstrap for the two isolated EarlyBirds hosts. +# Install only until both webroot certificates exist, then replace it with the +# two HTTPS vhosts in this directory. It never proxies application traffic. +server { + listen 80; + listen [::]:80; + server_name earlybirds-staging.harmonicbeacon.com stream.harmonicbeacon.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 503; + } +} diff --git a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template new file mode 100644 index 00000000..d3e15390 --- /dev/null +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -0,0 +1,649 @@ +# Isolated EarlyBirds staging vhost. Review and install separately from every +# live/event vhost only after the named certificate exists. +limit_req_zone $binary_remote_addr zone=listener_visual_analysis:1m rate=20r/s; +limit_req_zone $binary_remote_addr zone=listener_payment_webhooks:1m rate=60r/m; +limit_req_zone $binary_remote_addr zone=listener_checkout:1m rate=6r/m; +limit_req_zone $binary_remote_addr zone=listener_staging_withdrawal:1m rate=1r/m; +limit_req_zone $binary_remote_addr zone=listener_staging_auth_recovery:1m rate=12r/m; +log_format listener_payment_webhook '$remote_addr - $request_method $uri $status $body_bytes_sent'; + +server { + listen 80; + listen [::]:80; + server_name earlybirds-staging.harmonicbeacon.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location = / { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://earlybirds-staging.harmonicbeacon.com$request_uri; + } + + # Legacy invitation links carry a bearer in the query string. Suppress the + # very first edge log and avoid caching the one redirect that still has it. + location = /early-birds { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://earlybirds-staging.harmonicbeacon.com$request_uri; + } + + location = /early-birds/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com/listener/redeem$is_args$args; + } + + location = /listener { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://earlybirds-staging.harmonicbeacon.com$request_uri; + } + + location = /listener/membership { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://earlybirds-staging.harmonicbeacon.com$request_uri; + } + + location = /listener/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com/listener/redeem$is_args$args; + } + + location = /listener/withdrawal { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://earlybirds-staging.harmonicbeacon.com$request_uri; + } + + location = /listener/cancel-service { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://earlybirds-staging.harmonicbeacon.com$request_uri; + } + + location = /api/early-birds/auth/magic-link/verify { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + # Redemption authority lives only on the canonical Listener origin. Keep + # both compatibility POST aliases dark on staging instead of forwarding a + # mutation (or allowing the legacy alias to fall through a broad prefix). + location = /api/listener/free/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 404; + } + + location = /api/early-birds/free/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 404; + } + + location / { + return 301 https://earlybirds-staging.harmonicbeacon.com$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name earlybirds-staging.harmonicbeacon.com; + + ssl_certificate /etc/letsencrypt/live/earlybirds-staging.harmonicbeacon.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/earlybirds-staging.harmonicbeacon.com/privkey.pem; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + + # Membership projection is a private server-to-server boundary and is not + # exposed by this public staging vhost. + location = /api/internal { + return 404; + } + + location ^~ /api/internal/ { + return 404; + } + + # Sandbox provider ingress is deliberately two exact POST endpoints. The + # authority API itself remains bound to host loopback; no readiness, + # internal action or checkout route is exposed through this vhost. The + # dedicated access format records only `$uri`, never query parameters, + # headers or request bodies. + location = /v1/webhooks/early-birds/paypal { + if ($request_method != POST) { return 405; } + client_max_body_size 1m; + limit_req zone=listener_payment_webhooks burst=60 nodelay; + limit_req_status 429; + access_log /var/log/nginx/listener-payment-webhooks.log listener_payment_webhook; + proxy_pass http://127.0.0.1:18876; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /v1/webhooks/early-birds/mercado-pago { + if ($request_method != POST) { return 405; } + client_max_body_size 1m; + limit_req zone=listener_payment_webhooks burst=60 nodelay; + limit_req_status 429; + access_log /var/log/nginx/listener-payment-webhooks.log listener_payment_webhook; + proxy_pass http://127.0.0.1:18876; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # The staging hostname targets the immutable identity-acceptance runtime on + # 13001; the accepted Listener release stays on 13000 behind + # listen.harmonicbeacon.com. + location /_next/static/ { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header Cache-Control "private, no-store" always; + } + + # One byte-pinned navigation asset; never open the broader /assets prefix. + location = /assets/hb-global-nav.js { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host earlybirds-staging.harmonicbeacon.com; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + add_header Cache-Control "public, max-age=300" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + } + + location /_next/webpack-hmr { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 1h; + } + + location = /api/health { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + } + + location = /api/health/ready { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + } + + # Exact Account RP browser endpoints. No Account authority prefix or + # wildcard is exposed on Listener staging. + location = /api/account/login { + access_log off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host earlybirds-staging.harmonicbeacon.com; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host earlybirds-staging.harmonicbeacon.com; + } + + location = /api/account/callback { + access_log off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host earlybirds-staging.harmonicbeacon.com; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host earlybirds-staging.harmonicbeacon.com; + } + + location = /api/account/frontchannel-logout { + access_log off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host earlybirds-staging.harmonicbeacon.com; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host earlybirds-staging.harmonicbeacon.com; + } + + # Exact routes above are the complete public Account RP surface. Unknown + # suffixes fail closed without logging query parameters or session hints. + location ^~ /api/account/ { + access_log off; + return 404; + } + + location = /early-birds { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 /$is_args$args; + } + + location = /listener { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 /$is_args$args; + } + + # Signed-in membership management is one exact HTML surface. Provider + # mutations remain confined to their separately rate-limited APIs below. + location = /listener/membership { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + # The preview hostname exists only for this product, so keep its public + # address canonical at `/`. The application receives the canonical + # Listener route while legacy aliases and auth callbacks remain available. + location = / { + access_log off; + rewrite ^ /listener break; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location = /early-birds/home { + return 302 /; + } + + location = /listener/terms { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + location = /listener/privacy { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + location = /listener/withdrawal { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + location = /listener/cancel-service { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + location = /api/listener/withdrawal { + if ($request_method != POST) { return 405; } + client_max_body_size 2048; + limit_req zone=listener_staging_withdrawal burst=7 nodelay; + limit_req_status 429; + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 15s; + proxy_read_timeout 30s; + } + + location = /early-birds/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + return 302 https://listen.harmonicbeacon.com/listener/redeem$is_args$args; + } + + location = /listener/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + return 302 https://listen.harmonicbeacon.com/listener/redeem$is_args$args; + } + + # The shared preview's auth/session origin is the canonical Listener host. + # Never let the one-use magic token fall through the logged broad API + # prefix; carry it once to the exact unlogged canonical verifier. + location = /api/early-birds/auth/magic-link/verify { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + # The canonical Listener host owns the session and invitation cookie. A + # POST cannot be safely redirected, so fail both staging aliases closed. + location = /api/listener/free/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + return 404; + } + + location = /api/early-birds/free/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + return 404; + } + + location = /api/listener/access-state { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Authenticated browser checkout is independently fail-closed inside the + # application. This staging surface can enable only Sandbox/TEST providers; + # the public vhost uses separate Live flags and contracts. + location = /api/listener/checkout { + access_log off; + client_max_body_size 512; + limit_req zone=listener_checkout burst=4 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + } + + # One-account supervised Live acceptance. The application additionally + # requires the exact staging Host/Origin/fetch metadata, an authenticated + # allowlisted account and a session-bound CSRF proof. This exact route is + # intentionally absent from listen.harmonicbeacon.com. + location = /api/listener/checkout/live-workbench { + access_log off; + client_max_body_size 256; + limit_req zone=listener_checkout burst=2 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + } + + # Account-derived sandbox membership action. The browser cannot name a provider + # or subscription; the Listener resolves the current canonical projection. + location = /api/listener/membership/action { + access_log off; + client_max_body_size 256; + limit_req zone=listener_checkout burst=2 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + } + + # The staging OAuth recovery contract mirrors the public route while + # remaining isolated on the disposable Listener runtime at 13001. + location = /api/listener/auth/recover { + if ($request_method != POST) { return 405; } + access_log off; + limit_req zone=listener_staging_auth_recovery burst=3 nodelay; + limit_req_status 429; + client_max_body_size 64; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + } + + # Renderer-neutral frames for the disposable visual workbench. The public + # Listener edge intentionally has no matching location. + location = /api/listener/analysis/frame { + access_log off; + limit_req zone=listener_visual_analysis burst=40 nodelay; + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 15s; + proxy_read_timeout 15s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + } + + location = /api/listener/free-window { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /api/listener/welcome-access { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location ^~ /early-birds/ { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/early-birds/ { + proxy_pass http://127.0.0.1:13001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # The image also contains weekend/event routes. They are intentionally not + # part of this staging vhost, keeping checkout and staff surfaces dark. + location / { + return 404; + } +} diff --git a/ops/early-birds-preview/nginx/listen-acme-bootstrap.conf.template b/ops/early-birds-preview/nginx/listen-acme-bootstrap.conf.template new file mode 100644 index 00000000..f5fa3439 --- /dev/null +++ b/ops/early-birds-preview/nginx/listen-acme-bootstrap.conf.template @@ -0,0 +1,16 @@ +# Temporary HTTP-only bootstrap for the first listen.harmonicbeacon.com +# certificate. Replace this site atomically with the reviewed HTTPS template +# after issuance. +server { + listen 80; + listen [::]:80; + server_name listen.harmonicbeacon.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 503; + } +} diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template new file mode 100644 index 00000000..b24cc3fd --- /dev/null +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -0,0 +1,728 @@ +# Public Listener edge. It shares the isolated Listener runtime and exposes the +# Listener's dedicated OAuth/session and exact invitation-redeem boundaries, +# but never staging synthetic entry, membership projection, staff or event +# routes. +limit_req_zone $binary_remote_addr zone=listener_invitation_redeem:1m rate=30r/m; +limit_req_zone $binary_remote_addr zone=listener_public_visual_analysis:1m rate=20r/s; +limit_req_zone $binary_remote_addr zone=listener_live_checkout:1m rate=6r/m; +limit_req_zone $binary_remote_addr zone=listener_membership_action:1m rate=6r/m; +limit_req_zone $binary_remote_addr zone=listener_provider_webhook:1m rate=120r/m; +limit_req_zone $binary_remote_addr zone=listener_withdrawal:1m rate=1r/m; +limit_req_zone $binary_remote_addr zone=listener_auth_recovery:1m rate=12r/m; + +server { + listen 80; + listen [::]:80; + server_name listen.harmonicbeacon.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + # A shared invitation may arrive over HTTP first. Preserve it only long + # enough for the HTTPS middleware to scrub it, without edge logging, + # referrer propagation or a cacheable permanent redirect. + location = / { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /early-birds { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /listener { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /listener/membership { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /early-birds/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /listener/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /listener/terms { + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /listener/privacy { + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /listener/withdrawal { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location = /listener/cancel-service { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + # A magic-link bearer is valid only over HTTPS. Suppress accidental HTTP + # request logging before preserving the URI for the TLS endpoint. + location = /api/early-birds/auth/magic-link/verify { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 https://listen.harmonicbeacon.com$request_uri; + } + + location / { + return 301 https://listen.harmonicbeacon.com$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name listen.harmonicbeacon.com; + + ssl_certificate /etc/letsencrypt/live/listen.harmonicbeacon.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/listen.harmonicbeacon.com/privkey.pem; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + + location /_next/static/ { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # One byte-pinned navigation asset; never open the broader /assets prefix. + location = /assets/hb-global-nav.js { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + access_log off; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host listen.harmonicbeacon.com; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + add_header Cache-Control "public, max-age=300" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + } + + location = /api/health { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + } + + location = /api/health/ready { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + } + + # Exact Account RP browser endpoints. No Account authority prefix or + # wildcard is exposed on Listener. + location = /api/account/login { + access_log off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host listen.harmonicbeacon.com; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host listen.harmonicbeacon.com; + } + + location = /api/account/callback { + access_log off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host listen.harmonicbeacon.com; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host listen.harmonicbeacon.com; + } + + location = /api/account/frontchannel-logout { + access_log off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host listen.harmonicbeacon.com; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host listen.harmonicbeacon.com; + } + + # Public, coarse and privacy-bounded Listener presence. The application + # returns only qualitative macro-region bands and caches the response. + location = /api/listener/presence { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + } + + # Public search discovery is exposed only at the canonical filenames. The + # application endpoints remain internal and also verify the Host header so + # this branch cannot publish Listener discovery on an event vhost. + location = /robots.txt { + rewrite ^ /api/listener/public-discovery/robots.txt break; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + } + + location = /sitemap.xml { + rewrite ^ /api/listener/public-discovery/sitemap.xml break; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + } + + location = /early-birds { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 /$is_args$args; + } + + location = /listener { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 /$is_args$args; + } + + # One exact signed-in membership management page. No broader Listener + # page prefix is opened and all mutations keep their dedicated boundaries. + location = /listener/membership { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + # The exact invitation pages are safe to expose: middleware first moves a + # valid bearer query into a short HttpOnly host cookie and redirects to the + # clean URL. No prefix or internal membership route is opened here. + location = /early-birds/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /listener/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /listener/terms { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + location = /listener/privacy { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + # Exact no-login consumer-withdrawal boundaries. The application remains + # fail-closed behind its separate feature switch and secret. + location = /listener/withdrawal { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + location = /listener/cancel-service { + if ($request_method != GET) { return 405; } + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + } + + location = /api/listener/withdrawal { + if ($request_method != POST) { return 405; } + client_max_body_size 2048; + limit_req zone=listener_withdrawal burst=7 nodelay; + limit_req_status 429; + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 15s; + proxy_read_timeout 30s; + } + + location = / { + # The root may receive a legacy invitation bearer before the internal + # Listener rewrite can scrub it. Never persist that first request. + access_log off; + rewrite ^ /listener break; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/early-birds/stream/ { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location ^~ /api/early-birds/drop-ins/ { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Canonical account-local Listener state. Keep the exact legacy aliases + # during the measured namespace support window; neither family exposes a + # commerce membership or staging synthetic seam. + location = /api/listener/access-state { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # New sales and membership actions are exact, authenticated, same-origin app + # routes. Their independent runtime flags remain OFF until launch approval. + location = /api/listener/checkout { + access_log off; + limit_req zone=listener_live_checkout burst=4 nodelay; + limit_req_status 429; + client_max_body_size 512; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + } + + location = /api/listener/membership/action { + access_log off; + limit_req zone=listener_membership_action burst=2 nodelay; + limit_req_status 429; + client_max_body_size 256; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + } + + # Provider-neutral recovery clears only the caller's Listener session and + # transient OAuth cookies. Keep this mutation exact, POST-only, private and + # independently rate-limited at the public edge. + location = /api/listener/auth/recover { + if ($request_method != POST) { return 405; } + access_log off; + limit_req zone=listener_auth_recovery burst=3 nodelay; + limit_req_status 429; + client_max_body_size 64; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + } + + # Provider callbacks reach only the exact signed webhook handlers in the + # isolated membership authority. No internal/readiness prefix is exposed. + location = /v1/webhooks/listener/paypal { + if ($request_method != POST) { return 405; } + access_log off; + limit_req zone=listener_provider_webhook burst=30 nodelay; + limit_req_status 429; + client_max_body_size 1m; + proxy_pass http://127.0.0.1:18876; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /v1/webhooks/listener/mercado-pago { + if ($request_method != POST) { return 405; } + access_log off; + limit_req zone=listener_provider_webhook burst=30 nodelay; + limit_req_status 429; + client_max_body_size 1m; + proxy_pass http://127.0.0.1:18876; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Renderer-neutral frames for the public visual field. This exact, + # authenticated endpoint is bounded independently and exposes no media or + # analysis prefix. + location = /api/listener/analysis/frame { + access_log off; + limit_req zone=listener_public_visual_analysis burst=40 nodelay; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 15s; + proxy_read_timeout 15s; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + } + + location = /api/early-birds/access-state { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /api/listener/free-window { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /api/early-birds/free-window { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /api/listener/welcome-access { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /api/early-birds/welcome-access { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Invitation mutation stays exact, same-origin and rate bounded. The app + # independently verifies Host, Origin, Listener session and the one-use + # canonical authority token before changing access. + location = /api/listener/free/redeem { + access_log off; + limit_req zone=listener_invitation_redeem burst=20 nodelay; + limit_req_status 429; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location = /api/early-birds/free/redeem { + access_log off; + limit_req zone=listener_invitation_redeem burst=20 nodelay; + limit_req_status 429; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Better Auth puts the one-use magic token in this exact query URL. Keep it + # out of edge logs even while the delivery backend and public control stay + # disabled. Exact matching still leaves every unrelated route fail closed. + location = /api/early-birds/auth/magic-link/verify { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Harmonic-Beacon-Environment "listener-public-free" always; + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Better Auth owns only this dedicated Listener namespace. The app itself + # hides synthetic email sign-up/sign-in; nginx keeps every staging-only + # entry and internal membership route outside the public edge. + location ^~ /api/early-birds/auth/ { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Synthetic entry, membership projection, staff and event routes are + # deliberately absent from the public Listener host. + location / { + return 404; + } +} diff --git a/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template new file mode 100644 index 00000000..fbbff21a --- /dev/null +++ b/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template @@ -0,0 +1,59 @@ +# Isolated EarlyBirds stream-origin vhost. The public listener is loopback-only; +# metrics and readiness remain private inside its container network. +server { + listen 80; + listen [::]:80; + server_name stream.harmonicbeacon.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://stream.harmonicbeacon.com$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name stream.harmonicbeacon.com; + + ssl_certificate /etc/letsencrypt/live/stream.harmonicbeacon.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stream.harmonicbeacon.com/privkey.pem; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy no-referrer always; + add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; + + location = /healthz { + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 5s; + } + + location ^~ /v1/hls/ { + # Signed/granted media URLs are bearer credentials. Never persist the + # query string in edge access logs. + access_log off; + error_log /dev/null crit; + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_buffering off; + } + + location / { + return 404; + } +} diff --git a/ops/early-birds-preview/package.json b/ops/early-birds-preview/package.json new file mode 100644 index 00000000..89f83472 --- /dev/null +++ b/ops/early-birds-preview/package.json @@ -0,0 +1,11 @@ +{ + "name": "harmonic-beacon-earlybirds-preview", + "private": true, + "type": "module", + "scripts": { + "check": "node --check ../../scripts/early-birds-preview/validate.mjs ../../scripts/early-birds-preview/listener-live-dormant-check.mjs && sh -n ../../scripts/early-birds-preview/lib.sh ../../scripts/early-birds-preview/start.sh ../../scripts/early-birds-preview/stop.sh ../../scripts/early-birds-preview/rollback.sh ../../scripts/early-birds-preview/disable-public.sh ../../scripts/early-birds-preview/rehearse-migration.sh ../../scripts/early-birds-preview/health-smoke.sh ../../scripts/early-birds-preview/canonical-free-smoke.sh ../../scripts/early-birds-preview/registered-free-smoke.sh", + "validate": "node ../../scripts/early-birds-preview/validate.mjs", + "validate:build": "node ../../scripts/early-birds-preview/validate.mjs --build", + "test": "node --test test/*.test.mjs" + } +} diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example new file mode 100644 index 00000000..93f91f5e --- /dev/null +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -0,0 +1,100 @@ +# Synthetic-only staging values. Copy outside Git with mode 0600. +# The lifecycle guard requires these exact staging origins, fixed nginx ports, +# blank OAuth credentials, preview DB identity, and visibly synthetic secrets. +EARLYBIRDS_PREVIEW_ENV=synthetic +EARLYBIRDS_PREVIEW_DB_USER=earlybirds_preview +EARLYBIRDS_PREVIEW_DB_PASSWORD=synthetic-preview-database-password +EARLYBIRDS_PREVIEW_DB_NAME=earlybirds_preview +EARLYBIRDS_PREVIEW_APP_PORT=13000 +EARLYBIRDS_PREVIEW_IMAGE_TAG=synthetic +EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG=synthetic +EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA=synthetic-preview +EARLYBIRDS_PREVIEW_GIT_SHA=synthetic-preview +EARLYBIRDS_PREVIEW_BUILD_TIME=synthetic-preview +EARLYBIRDS_PREVIEW_SCHEMA_VERSION=20260813190000_listener_withdrawal_request +# Leave empty for the default disconnected fixture. The guarded optional value +# is earlybirds_authority_private; see the runbook before joining it. +EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK= + +# Public Listener entry is deliberately OFF at first boot. Change only this +# value to 1 after migration, liveness, readiness and reverse-proxy gates pass. +EARLY_BIRDS_ENABLED=0 +EARLY_BIRDS_FREE_FOR_ALL=0 +EARLY_BIRDS_AUTH_BASE_URL=https://earlybirds-staging.harmonicbeacon.com +EARLY_BIRDS_TRUSTED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com +EARLY_BIRDS_AUTH_SECRET=synthetic-preview-auth-secret-at-least-32-characters + +# Staging uses the supervised synthetic login seam, never real provider creds. +EARLY_BIRDS_GOOGLE_CLIENT_ID= +EARLY_BIRDS_GOOGLE_CLIENT_SECRET= +BEACON_LISTENER_APPLE_ENABLED=0 +BEACON_LISTENER_APPLE_CLIENT_ID= +BEACON_LISTENER_APPLE_CLIENT_SECRET= +# Central Account RP remains OFF in the disconnected fixture. The production +# cutover installs only the dedicated production client/state pair; staging +# secrets never belong in this runtime file or container. +BEACON_LISTENER_ACCOUNT_ENABLED=0 +BEACON_LISTENER_ACCOUNT_CLIENT_SECRET= +BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING= +BEACON_LISTENER_ACCOUNT_STATE_SECRET= +BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING= +# The mail fallback is absent in synthetic preview until the private delivery +# service implements listener-magic-link.v1. +EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL= +EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN= +EARLY_BIRDS_MAGIC_LINK_RATE_SECRET= +EARLY_BIRDS_TEST_ACCESS_ENABLED=1 +EARLY_BIRDS_TEST_LOGIN_SECRET=synthetic-preview-login-secret-at-least-32-characters +# The human form is a second kill switch. Keep it off until the supervised +# window, then enable it together with EARLY_BIRDS_ENABLED. +EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0 +BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED=0 +# Install a root-owned secret before changing this switch. The synthetic +# example deliberately keeps both values dark. +LISTENER_WITHDRAWAL_ENABLED=0 +LISTENER_WITHDRAWAL_SECRET= +BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED=0 +BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED=0 +BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=0 +BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED=0 +# Supervised Live acceptance never belongs in the synthetic fixture. Operators +# install its account/provider/CSRF values only in a root-owned runtime file. +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED=0 +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID= +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER= +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET= +EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com + +# Local country-only GeoIP data. The reviewed July 2026 DB-IP Lite artifact is +# installed by install-geoip-country.sh and mounted read-only into Listener. +BEACON_LISTENER_GEOIP_HOST_PATH=/mnt/beacon-data/listener/geoip/dbip-country-lite-2026-07.mmdb + +# No real authority/provider is contacted by the synthetic fixture. The +# example.invalid authority remains non-routable and its credentials are fake. +EARLY_BIRDS_AUTHORITY_BASE_URL=https://authority.example.invalid +EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID=synthetic-v1 +EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN=synthetic-preview-authority-token-at-least-43-characters-long +EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID=synthetic-v1 +EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT=synthetic-preview-inbound-token-at-least-43-characters-long + +# The Listener is production-mode and therefore accepts only HTTPS here. The +# shared secret and artifact identifier must match beacon-stream below. +EARLY_BIRDS_STREAM_ORIGIN=https://stream.harmonicbeacon.com +EARLY_BIRDS_STREAM_CONTROL_ORIGIN=http://beacon-stream:8080 +EARLY_BIRDS_STREAM_ARTIFACT_ID=synthetic-preview-artifact +EARLY_BIRDS_STREAM_SIGNING_SECRET=synthetic-preview-stream-signing-secret-at-least-32-characters +EARLY_BIRDS_DEVICE_PEPPER=synthetic-preview-device-pepper-at-least-32-characters +# Optional reviewed private media. Paths are inside the Listener's read-only +# artifact mount; leave blank until an approved file is installed there. +EARLY_BIRDS_DROPIN_ES_PATH= +EARLY_BIRDS_DROPIN_EN_PATH= + +# Mount only a generated synthetic HLS fixture outside Git. No artifact is +# supplied by this runtime-plumbing change. +BEACON_STREAM_ARTIFACTS_HOST_PATH=/absolute/path/to/synthetic-artifacts +BEACON_STREAM_MEDIA_ROOT=/media/artifacts +BEACON_STREAM_ARTIFACT_ID=synthetic-preview-artifact +BEACON_STREAM_PUBLIC_ORIGIN=https://stream.harmonicbeacon.com +BEACON_STREAM_ALLOWED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com +BEACON_STREAM_SIGNING_SECRET=synthetic-preview-stream-signing-secret-at-least-32-characters +BEACON_STREAM_HOST_PORT=18080 diff --git a/ops/early-birds-preview/stream-build.override.yml b/ops/early-birds-preview/stream-build.override.yml new file mode 100644 index 00000000..816682d3 --- /dev/null +++ b/ops/early-birds-preview/stream-build.override.yml @@ -0,0 +1,14 @@ +# Compose resolves relative paths from the first -f file. This last overlay +# deliberately replaces services/beacon-stream/docker-compose.yml's `build: .` +# after it is merged, keeping the origin build rooted at its own service. +services: + beacon-stream: + build: + context: ../../services/beacon-stream + dockerfile: Dockerfile + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 5s diff --git a/ops/early-birds-preview/test/disable-public.test.mjs b/ops/early-birds-preview/test/disable-public.test.mjs new file mode 100644 index 00000000..638628ed --- /dev/null +++ b/ops/early-birds-preview/test/disable-public.test.mjs @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +const previewRoot = path.resolve(import.meta.dirname, '..'); +const repositoryRoot = path.resolve(previewRoot, '../..'); +const script = path.join(repositoryRoot, 'scripts/early-birds-preview/disable-public.sh'); +const example = path.join(previewRoot, 'preview.env.synthetic.example'); + +async function executable(pathname, content) { + await fs.writeFile(pathname, content, { mode: 0o700 }); + await fs.chmod(pathname, 0o700); +} + +async function fixture(t, { denialStatus = '503', healthFailures = 0 } = {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'listener-disable-public-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const envFile = path.join(directory, 'preview.env'); + const source = (await fs.readFile(example, 'utf8')) + .replace('EARLY_BIRDS_ENABLED=0', 'EARLY_BIRDS_ENABLED=1') + .replace('EARLY_BIRDS_FREE_FOR_ALL=0', 'EARLY_BIRDS_FREE_FOR_ALL=1') + .replace( + 'EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0', + 'EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=1', + ); + await fs.writeFile(envFile, source, { mode: 0o600 }); + await fs.chmod(envFile, 0o600); + + const bin = path.join(directory, 'bin'); + await fs.mkdir(bin); + const commandLog = path.join(directory, 'commands.log'); + await executable(path.join(bin, 'id'), '#!/bin/sh\necho 0\n'); + await executable(path.join(bin, 'docker'), [ + '#!/bin/sh', + 'printf "%s\\n" "$*" >> "$TEST_COMMAND_LOG"', + 'case "$*" in', + ' "ps -q --filter label=com.docker.compose.project=earlybirds-preview --filter label=com.docker.compose.service=listener") printf "isolated-listener-id\\n" ;;', + 'esac', + 'exit 0', + '', + ].join('\n')); + await executable(path.join(bin, 'curl'), [ + '#!/bin/sh', + 'printf "%s\\n" "$*" >> "$TEST_COMMAND_LOG"', + 'case "$*" in', + ` *api/early-birds/stream/lease*) printf '${denialStatus}' ;;`, + ' *api/health*)', + ' attempts=$(grep -c "api/health" "$TEST_COMMAND_LOG" || true)', + ' test "$attempts" -le "$TEST_HEALTH_FAILURES" && exit 56', + ' ;;', + 'esac', + 'exit 0', + '', + ].join('\n')); + return { directory, envFile, bin, commandLog, source, healthFailures }; +} + +function run(mode, current) { + return spawnSync('sh', [script, mode, current.envFile], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${current.bin}:${process.env.PATH}`, + TEST_COMMAND_LOG: current.commandLog, + TEST_HEALTH_FAILURES: String(current.healthFailures), + }, + }); +} + +test('explicit dry-run is non-mutating and invokes no runtime command', async (t) => { + const current = await fixture(t); + const before = await fs.readFile(current.envFile, 'utf8'); + const result = run('--dry-run', current); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /DRY RUN/); + assert.equal(await fs.readFile(current.envFile, 'utf8'), before); + await assert.rejects(fs.access(current.commandLog)); + const files = await fs.readdir(current.directory); + assert.equal(files.some((name) => name.includes('pre-disable-public')), false); +}); + +test('dry-run refuses a concurrent public-mode operation', async (t) => { + const current = await fixture(t); + const lockFile = `${current.envFile}.listener-public.lock`; + const holder = spawn('flock', [ + lockFile, + 'sh', '-c', 'echo locked; read line', + ], { stdio: ['pipe', 'pipe', 'pipe'] }); + await once(holder.stdout, 'data'); + try { + const result = run('--dry-run', current); + assert.equal(result.status, 2); + assert.match(result.stderr, /holds the lock/); + } finally { + holder.stdin.end(); + await once(holder, 'close'); + } +}); + +test('dry-run refuses duplicate public switch assignments', async (t) => { + const current = await fixture(t); + await fs.appendFile(current.envFile, '\nEARLY_BIRDS_ENABLED=0\n'); + const result = run('--dry-run', current); + assert.equal(result.status, 2); + assert.match(result.stderr, /EARLY_BIRDS_ENABLED must appear exactly once/); +}); + +test('apply backs up mode 0600, disables every public flag and recreates only Listener', async (t) => { + const current = await fixture(t); + const result = run('--apply', current); + assert.equal(result.status, 0, result.stderr); + const updated = await fs.readFile(current.envFile, 'utf8'); + assert.match(updated, /^EARLY_BIRDS_ENABLED=0$/m); + assert.match(updated, /^EARLY_BIRDS_FREE_FOR_ALL=0$/m); + assert.match(updated, /^EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0$/m); + assert.equal((await fs.stat(current.envFile)).mode & 0o777, 0o600); + + const backupName = (await fs.readdir(current.directory)) + .find((name) => name.includes('pre-disable-public')); + assert.ok(backupName); + const backup = path.join(current.directory, backupName); + assert.equal((await fs.stat(backup)).mode & 0o777, 0o600); + assert.equal(await fs.readFile(backup, 'utf8'), current.source); + + const commands = await fs.readFile(current.commandLog, 'utf8'); + const dockerCommands = commands.split('\n').filter((line) => line.startsWith('compose ')); + assert.equal(dockerCommands.length, 1); + assert.match(dockerCommands[0], / up -d --no-deps --force-recreate --no-build listener$/); + assert.match(commands, /api\/health\b/); + assert.match(commands, /api\/health\/ready/); + assert.match(commands, /api\/early-birds\/stream\/lease/); + assert.match(result.stdout, /denied with 503/); +}); + +test('failed denial smoke leaves flags disabled and stops only Listener', async (t) => { + const current = await fixture(t, { denialStatus: '401' }); + const result = run('--apply', current); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /stopping only Listener/); + const updated = await fs.readFile(current.envFile, 'utf8'); + assert.match(updated, /^EARLY_BIRDS_ENABLED=0$/m); + const commands = await fs.readFile(current.commandLog, 'utf8'); + assert.match(commands, /ps -q --filter label=com\.docker\.compose\.project=earlybirds-preview --filter label=com\.docker\.compose\.service=listener/); + assert.match(commands, /stop isolated-listener-id/); + assert.doesNotMatch(commands, /stop (?:.* )?(postgres|beacon-stream|livekit|playlist-bot)/); +}); + +test('apply tolerates a healthy Listener that needs several startup probes', async (t) => { + const current = await fixture(t, { healthFailures: 2 }); + const result = run('--apply', current); + assert.equal(result.status, 0, result.stderr); + const commands = await fs.readFile(current.commandLog, 'utf8'); + const healthAttempts = commands.split('\n').filter((line) => /api\/health$/.test(line)); + assert.equal(healthAttempts.length, 3); + assert.match(result.stdout, /denied with 503/); +}); diff --git a/ops/early-birds-preview/test/listener-live-dormant-check.test.mjs b/ops/early-birds-preview/test/listener-live-dormant-check.test.mjs new file mode 100644 index 00000000..9a8ca3f8 --- /dev/null +++ b/ops/early-birds-preview/test/listener-live-dormant-check.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + verifyDormantListenerLiveState, +} from '../../../scripts/early-birds-preview/listener-live-dormant-check.mjs'; + +function responseFor(url, init, overrides = {}) { + const path = new URL(url).pathname; + if (init.method === 'POST') return new Response('', { status: overrides[path] ?? 404 }); + if (path.startsWith('/api/health')) { + return Response.json({ status: 'ok' }, { status: overrides[path] ?? 200 }); + } + return new Response('Listener', { + status: overrides[path] ?? 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); +} + +test('passes only with healthy fixed hosts, legal pages and every Live checkout path closed', async () => { + const calls = []; + const result = await verifyDormantListenerLiveState({ + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return responseFor(url, init); + }, + }); + + assert.equal(result.status, 'PASS'); + assert.equal(result.checks.length, 14); + assert.ok(result.checks.every((check) => check.passed)); + const posts = calls.filter((call) => call.init.method === 'POST'); + assert.equal(posts.length, 6); + for (const { url, init } of posts) { + const origin = new URL(url).origin; + assert.equal(init.credentials, 'omit'); + assert.equal(init.redirect, 'manual'); + assert.equal(init.headers.origin, origin); + assert.equal(init.headers['sec-fetch-site'], 'same-origin'); + assert.equal(init.headers.authorization, undefined); + assert.equal(init.headers.cookie, undefined); + assert.doesNotMatch(init.body, /email|token|subscription|account/i); + } +}); + +test('fails closed if either productive provider becomes reachable', async () => { + let checkoutCount = 0; + const result = await verifyDormantListenerLiveState({ + fetchImpl: async (url, init) => { + if (init.method === 'POST' && new URL(url).hostname === 'listen.harmonicbeacon.com' && + new URL(url).pathname === '/api/listener/checkout') { + checkoutCount += 1; + return new Response('', { status: checkoutCount === 2 ? 401 : 404 }); + } + return responseFor(url, init); + }, + }); + + assert.equal(result.status, 'FAIL'); + assert.deepEqual( + result.checks.find((check) => check.name === 'listener-mercado-pago-live-checkout-off'), + { name: 'listener-mercado-pago-live-checkout-off', passed: false, status: 401 }, + ); +}); + +test('fails closed on redirects, malformed health, oversized bodies and network errors', async () => { + const result = await verifyDormantListenerLiveState({ + fetchImpl: async (url, init) => { + const path = new URL(url).pathname; + if (path === '/api/health') return new Response('not-json', { headers: { 'content-type': 'application/json' } }); + if (path === '/api/health/ready' && new URL(url).hostname === 'earlybirds-staging.harmonicbeacon.com') { + return new Response('', { status: 302, headers: { location: 'https://example.invalid/' } }); + } + if (path === '/listener/privacy') { + return new Response('x', { headers: { 'content-type': 'text/html', 'content-length': '70000' } }); + } + if (path === '/listener/withdrawal') throw new Error('network details must not escape'); + return responseFor(url, init); + }, + }); + + assert.equal(result.status, 'FAIL'); + assert.deepEqual(Object.keys(result).sort(), ['checks', 'schemaVersion', 'status']); + assert.doesNotMatch(JSON.stringify(result), /network details|example\.invalid|not-json/); + assert.equal(result.checks.filter((check) => !check.passed).length, 4); +}); diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs new file mode 100644 index 00000000..efde0bf0 --- /dev/null +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -0,0 +1,796 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +const previewRoot = path.resolve(import.meta.dirname, '..'); +const repositoryRoot = path.resolve(previewRoot, '../..'); +const readPreview = (name) => fs.readFile(path.join(previewRoot, name), 'utf8'); +const readRepository = (name) => fs.readFile(path.join(repositoryRoot, name), 'utf8'); + +const runGuard = (envFile) => spawnSync( + 'sh', + ['-c', '. "$1"; require_synthetic_env "$2"', 'sh', + path.resolve(repositoryRoot, 'scripts/early-birds-preview/lib.sh'), envFile], + { encoding: 'utf8' }, +); + +test('synthetic guard accepts the example and rejects unsafe effective values', async (t) => { + const source = await readPreview('preview.env.synthetic.example'); + assert.equal(runGuard(path.join(previewRoot, 'preview.env.synthetic.example')).status, 0); + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'earlybirds-preview-guard-')); + t.after(() => fs.rm(temporary, { recursive: true, force: true })); + + const cases = [ + ['live hostname', 'EARLY_BIRDS_AUTH_BASE_URL=https://live.harmonicbeacon.com', /must be https:\/\/earlybirds-staging/], + ['HTTP stream origin', 'EARLY_BIRDS_STREAM_ORIGIN=http://stream.harmonicbeacon.com', /must be https:\/\/stream/], + ['half-configured OAuth seam', 'EARLY_BIRDS_GOOGLE_CLIENT_ID=real-client-id', /configured together/], + ['unsafe Apple switch', 'BEACON_LISTENER_APPLE_ENABLED=true', /must be 0 or 1/], + ['enabled Apple without credentials', 'BEACON_LISTENER_APPLE_ENABLED=1', /requires its client ID/], + ['event database identity', 'EARLYBIRDS_PREVIEW_DB_NAME=beacon', /must be earlybirds_preview/], + ['unsafe kill switch value', 'EARLY_BIRDS_ENABLED=true', /must be 0 or 1/], + ['unsafe free-for-all switch', 'EARLY_BIRDS_FREE_FOR_ALL=true', /must be 0 or 1/], + ['unsafe team-entry switch', 'EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=true', /must be 0 or 1/], + ['unsafe withdrawal switch', 'LISTENER_WITHDRAWAL_ENABLED=true', /must be 0 or 1/], + ['unsafe PayPal checkout switch', 'BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED=true', /must be 0 or 1/], + ['unsafe Mercado Pago checkout switch', 'BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED=true', /must be 0 or 1/], + ['unsafe PayPal Live switch', 'BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=1', /must be 0/], + ['unsafe Mercado Pago Live switch', 'BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED=1', /must be 0/], + ['unsafe private Live workbench switch', 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED=1', /must be 0/], + ['synthetic private Live account', 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID=opaque-account', /cannot contain a private Live account/], + ['wrong team-entry host', 'EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=staging.example.invalid', /must be earlybirds-staging/], + ['unreviewed GeoIP path', 'BEACON_LISTENER_GEOIP_HOST_PATH=/tmp/random.mmdb', /reviewed absolute July 2026/], + ['non-synthetic secret', 'EARLY_BIRDS_AUTH_SECRET=not-a-real-but-long-enough-secret-value', /visibly synthetic/], + ]; + + for (const [name, assignment, errorPattern] of cases) { + await t.test(name, async () => { + const envFile = path.join(temporary, `${name.replaceAll(' ', '-')}.env`); + await fs.writeFile(envFile, `${source}\n${assignment}\n`, { mode: 0o600 }); + const result = runGuard(envFile); + assert.equal(result.status, 2); + assert.match(result.stderr, errorPattern); + }); + } + + await t.test('guarded private authority handoff', async () => { + const envFile = path.join(temporary, 'private-authority.env'); + await fs.writeFile(envFile, [ + source, + 'EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK=earlybirds_authority_private', + 'EARLY_BIRDS_AUTHORITY_BASE_URL=http://pmp-myth-api:8765', + '', + ].join('\n'), { mode: 0o600 }); + assert.equal(runGuard(envFile).status, 0); + }); + + await t.test('guarded reviewed Beacon artifact handoff', async () => { + const envFile = path.join(temporary, 'reviewed-beacon.env'); + await fs.writeFile(envFile, source + .replaceAll('synthetic-preview-artifact', 'beacon-luz-20260624-2hs-aac320-v2'), { mode: 0o600 }); + assert.equal(runGuard(envFile).status, 0); + }); + + await t.test('guarded public Google OAuth handoff', async () => { + const envFile = path.join(temporary, 'public-google-oauth.env'); + await fs.writeFile(envFile, source + .replace( + 'EARLY_BIRDS_AUTH_BASE_URL=https://earlybirds-staging.harmonicbeacon.com', + 'EARLY_BIRDS_AUTH_BASE_URL=https://listen.harmonicbeacon.com', + ) + .replace( + 'EARLY_BIRDS_TRUSTED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com', + 'EARLY_BIRDS_TRUSTED_ORIGINS=https://listen.harmonicbeacon.com,https://earlybirds-staging.harmonicbeacon.com', + ) + .replace('EARLY_BIRDS_GOOGLE_CLIENT_ID=', 'EARLY_BIRDS_GOOGLE_CLIENT_ID=google-client-id') + .replace('EARLY_BIRDS_GOOGLE_CLIENT_SECRET=', 'EARLY_BIRDS_GOOGLE_CLIENT_SECRET=google-client-secret'), { + mode: 0o600, + }); + assert.equal(runGuard(envFile).status, 0); + }); + + await t.test('guarded production Account handoff uses only production secrets', async () => { + const envFile = path.join(temporary, 'production-account.env'); + await fs.writeFile(envFile, source + .replace( + 'EARLY_BIRDS_AUTH_BASE_URL=https://earlybirds-staging.harmonicbeacon.com', + 'EARLY_BIRDS_AUTH_BASE_URL=https://listen.harmonicbeacon.com', + ) + .replace( + 'EARLY_BIRDS_TRUSTED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com', + 'EARLY_BIRDS_TRUSTED_ORIGINS=https://listen.harmonicbeacon.com,https://earlybirds-staging.harmonicbeacon.com', + ) + .replace('BEACON_LISTENER_ACCOUNT_ENABLED=0', 'BEACON_LISTENER_ACCOUNT_ENABLED=1') + .replace('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=', `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${'c'.repeat(64)}`) + .replace('BEACON_LISTENER_ACCOUNT_STATE_SECRET=', `BEACON_LISTENER_ACCOUNT_STATE_SECRET=${'s'.repeat(64)}`), { + mode: 0o600, + }); + assert.equal(runGuard(envFile).status, 0); + + await fs.appendFile(envFile, `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING=${'x'.repeat(64)}\n`); + const result = runGuard(envFile); + assert.equal(result.status, 2); + assert.match(result.stderr, /must not contain staging Account secrets/); + }); + + await t.test('disabled Listener rejects dormant Account secrets in its runtime env', async () => { + const envFile = path.join(temporary, 'disabled-account-with-secret.env'); + await fs.writeFile(envFile, `${source}\nBEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${'c'.repeat(64)}\n`, { mode: 0o600 }); + const result = runGuard(envFile); + assert.equal(result.status, 2); + assert.match(result.stderr, /disabled Listener must not carry Account RP secrets/); + }); + + await t.test('guarded staging Apple OAuth handoff remains on the staging callback host', async () => { + const envFile = path.join(temporary, 'staging-apple-oauth.env'); + await fs.writeFile(envFile, source + .replace('BEACON_LISTENER_APPLE_ENABLED=0', 'BEACON_LISTENER_APPLE_ENABLED=1') + .replace('BEACON_LISTENER_APPLE_CLIENT_ID=', 'BEACON_LISTENER_APPLE_CLIENT_ID=services-id') + .replace('BEACON_LISTENER_APPLE_CLIENT_SECRET=', 'BEACON_LISTENER_APPLE_CLIENT_SECRET=synthetic-jwt'), { + mode: 0o600, + }); + assert.equal(runGuard(envFile).status, 0); + }); + + await t.test('mismatched Listener and origin artifacts fail closed', async () => { + const envFile = path.join(temporary, 'mismatched-beacon.env'); + await fs.writeFile(envFile, `${source}\nEARLY_BIRDS_STREAM_ARTIFACT_ID=beacon-luz-20260624-2hs-aac320-v2\n`, { mode: 0o600 }); + const result = runGuard(envFile); + assert.equal(result.status, 2); + assert.match(result.stderr, /artifact IDs must match/); + }); +}); + +test('payment workbench keeps OAuth state and callback on the staging origin', async () => { + const source = await readRepository('scripts/listener-ui-preview.sh'); + assert.match(source, /PREVIEW_ORIGIN="https:\/\/earlybirds-staging\.harmonicbeacon\.com"/); + assert.match(source, /set_env_file_value BEACON_LISTENER_AUTH_BASE_URL "\$PREVIEW_ORIGIN"/); + assert.match(source, /set_env_file_value EARLY_BIRDS_AUTH_BASE_URL "\$PREVIEW_ORIGIN"/); + const authBaseIndex = source.indexOf('set_env_file_value BEACON_LISTENER_AUTH_BASE_URL "$PREVIEW_ORIGIN"'); + const paymentModeIndex = source.indexOf('if [ "$PREVIEW_PAYPAL_CHECKOUT" = 1 ]'); + assert.ok(authBaseIndex > 0 && authBaseIndex < paymentModeIndex, + 'staging auth base must be fixed before selecting ordinary or payment runtime mode'); + assert.equal( + source.match(/set_env_file_value BEACON_LISTENER_AUTH_BASE_URL "\$PREVIEW_ORIGIN"/g)?.length, + 1, + ); + assert.equal( + source.match(/set_env_file_value EARLY_BIRDS_AUTH_BASE_URL "\$PREVIEW_ORIGIN"/g)?.length, + 1, + ); + assert.match(source, /PREVIEW_LIVE_WORKBENCH="\$\{LISTENER_UI_PREVIEW_LIVE_WORKBENCH_ENABLED:-0\}"/); + assert.match(source, /PREVIEW_APPLE="\$\{LISTENER_UI_PREVIEW_APPLE_ENABLED:-0\}"/); + assert.match(source, /Apple sign-in acceptance requires Free For All to be disabled/); + assert.match(source, /set_env_file_value BEACON_LISTENER_APPLE_ENABLED "\$PREVIEW_APPLE"/); + assert.equal( + source.match(/set_env_file_value BEACON_LISTENER_APPLE_ENABLED "\$PREVIEW_APPLE"/g)?.length, + 1, + ); + assert.match(source, /LIVE_WORKBENCH_ENV_FILE="\/etc\/harmonic-beacon\/listener-live-workbench\.env"/); + assert.match(source, /harmonic-beacon\/earlybirds-preview-listener:\$\{PREVIEW_EXPECTED_SHA\}/); + assert.match(source, /grep -Fqx "BEACON_GIT_SHA=\$PREVIEW_EXPECTED_SHA"/); + assert.match(source, /PREVIEW_LIVE_WORKBENCH" = 1/); + assert.match(source, /set_env_file_value EARLY_BIRDS_FREE_FOR_ALL 0/); + assert.match(source, /set_env_file_value BEACON_LISTENER_FREE_FOR_ALL 0/); + assert.match(source, /set_env_file_value BEACON_GIT_SHA "\$PREVIEW_EXPECTED_SHA"/); + assert.match(source, /while IFS='=' read -r workbench_key workbench_value; do[\s\S]*set_env_file_value "\$workbench_key" "\$workbench_value"[\s\S]*done < <\(sudo cat "\$LIVE_WORKBENCH_ENV_FILE"\)/); + assert.doesNotMatch(source, /sudo cat "\$LIVE_WORKBENCH_ENV_FILE" >> "\$env_file"/); + assert.match(source, /sudo stat -c '%u:%g:%a'/); + assert.match(source, /workbench_container_started=1/); + assert.match(source, /workbench_validated=1/); + assert.match(source, /docker rm -f "\$DEV_CONTAINER"/); + assert.match(source, /127\.0\.0\.1:13001/); + assert.match(source, /LISTENER_UI_PREVIEW_REACTIVE_FIELD_LAB_ENABLED:-1/); + assert.match(source, /BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED="\$PREVIEW_REACTIVE_FIELD_LAB"/); + assert.match(source, /LISTENER_UI_PREVIEW_DROPIN_EN_PATH:-/); + assert.match(source, /LISTENER_UI_PREVIEW_DROPIN_ES_PATH:-/); + assert.match(source, /\^\/media\/artifacts\/drop-ins\/\[A-Za-z0-9\]/); + assert.match(source, /set_env_file_value EARLY_BIRDS_DROPIN_EN_PATH "\$PREVIEW_DROPIN_EN_PATH"/); + assert.match(source, /set_env_file_value EARLY_BIRDS_DROPIN_ES_PATH "\$PREVIEW_DROPIN_ES_PATH"/); + assert.match(source, /ACCOUNT_STAGING_ENV_FILE="\/etc\/harmonic-beacon\/listener-account-staging\.env"/); + assert.match(source, /unset_env_file_value BEACON_LISTENER_ACCOUNT_CLIENT_SECRET/); + assert.match(source, /unset_env_file_value BEACON_LISTENER_ACCOUNT_STATE_SECRET/); + assert.match(source, /set_env_file_value BEACON_LISTENER_ACCOUNT_ENVIRONMENT staging/); + assert.match(source, /BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING/); + assert.match(source, /BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING/); + assert.match(source, /root:root:600/); + assert.match(source, /must contain exactly the two approved keys/); + assert.doesNotMatch(source, /ACCOUNT_CLIENT_SECRET_STAGING[^\n]*ssh/); + assert.match(source, /docker network connect earlybirds_stream_control_internal "\$DEV_CONTAINER"/); + assert.match(source, /api\/health\/ready/); + assert.match(source, /npm run dev -- --webpack --hostname 0\.0\.0\.0 --port 3000/); + assert.doesNotMatch(source, /PREVIEW_ORIGIN="https:\/\/listen\.harmonicbeacon\.com"/); +}); + +test('Listener edges expose only the three exact Account RP browser routes', async () => { + for (const [name, host, port] of [ + ['listen.harmonicbeacon.com.conf.template', 'listen.harmonicbeacon.com', '13000'], + ['earlybirds-staging.harmonicbeacon.com.conf.template', 'earlybirds-staging.harmonicbeacon.com', '13001'], + ]) { + const source = await readPreview(`nginx/${name}`); + for (const route of ['login', 'callback', 'frontchannel-logout']) { + const marker = `location = /api/account/${route} {`; + assert.equal(source.split(marker).length - 1, 1, `${name} must expose ${route} exactly once`); + const block = source.slice(source.indexOf(marker), source.indexOf('\n }', source.indexOf(marker)) + 6); + assert.match(block, /access_log off;/); + assert.match(block, /Cache-Control "private, no-store"/); + assert.match(block, /Referrer-Policy "no-referrer"/); + assert.match(block, /Strict-Transport-Security "max-age=31536000; includeSubDomains"/); + assert.match(block, /X-Content-Type-Options nosniff/); + if (route === 'frontchannel-logout') assert.doesNotMatch(block, /X-Frame-Options/); + else assert.match(block, /X-Frame-Options SAMEORIGIN/); + assert.match(block, new RegExp(`proxy_pass http:\\/\\/127\\.0\\.0\\.1:${port};`)); + assert.match(block, new RegExp(`proxy_set_header Host ${host.replaceAll('.', '\\.')};`)); + assert.match(block, /proxy_set_header X-Forwarded-Proto https;/); + } + const closedPrefix = source.match(/location \^~ \/api\/account\/ \{([\s\S]*?)\n \}/); + if (closedPrefix) { + assert.match(closedPrefix[1], /access_log off;/); + assert.match(closedPrefix[1], /return 404;/); + assert.doesNotMatch(closedPrefix[1], /proxy_pass/); + } + assert.doesNotMatch(source, /location \/api\/account\//); + } +}); + +test('compose gates the loopback Listener on a forward-only isolated database migration', async () => { + const source = await readPreview('compose.yml'); + const env = await readPreview('preview.env.synthetic.example'); + const schemaVersion = env.match(/^EARLYBIRDS_PREVIEW_SCHEMA_VERSION=(.+)$/m)?.[1]; + const migrations = await fs.readdir(path.join(repositoryRoot, 'prisma/migrations')); + assert.ok(schemaVersion, 'preview schema provenance must be explicit'); + assert.ok(migrations.includes(schemaVersion), 'preview schema provenance must name a checked-in migration'); + assert.match(source, /BEACON_DATABASE_SCHEMA_VERSION: \$\{EARLYBIRDS_PREVIEW_SCHEMA_VERSION:\?set_in_preview\.env\}/); + assert.equal((source.match(/BEACON_GIT_SHA: \$\{EARLYBIRDS_PREVIEW_GIT_SHA:-synthetic-preview\}/g) ?? []).length, 2); + assert.equal((source.match(/BEACON_BUILD_TIME: \$\{EARLYBIRDS_PREVIEW_BUILD_TIME:-synthetic-preview\}/g) ?? []).length, 2); + assert.equal((source.match(/BEACON_DATABASE_SCHEMA_VERSION: \$\{EARLYBIRDS_PREVIEW_SCHEMA_VERSION:\?set_in_preview\.env\}/g) ?? []).length, 2); + assert.doesNotMatch(source, /preview-forward-only/); + assert.match(source, /^ listener:$/m); + assert.match(source, /127\.0\.0\.1:\$\{EARLYBIRDS_PREVIEW_APP_PORT:-13000\}:3000/); + assert.match(source, /^ migration:$/m); + assert.match(source, /command: \["npx", "prisma", "migrate", "deploy"\]/); + assert.match(source, /condition: service_completed_successfully/); + assert.doesNotMatch(source, /prisma[^\n]*(migrate reset|db push)/i); + assert.match(source, /EARLY_BIRDS_ENABLED: \$\{EARLY_BIRDS_ENABLED:-0\}/); + assert.match(source, /EARLY_BIRDS_FREE_FOR_ALL: \$\{EARLY_BIRDS_FREE_FOR_ALL:-0\}/); + assert.match(source, /EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED: \$\{EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED:-0\}/); + assert.match(source, /BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED: \$\{BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED:-0\}/); + assert.match(source, /LISTENER_WITHDRAWAL_ENABLED: \$\{LISTENER_WITHDRAWAL_ENABLED:-0\}/); + assert.match(source, /LISTENER_WITHDRAWAL_SECRET: \$\{LISTENER_WITHDRAWAL_SECRET:-\}/); + assert.match(source, /BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED: \$\{BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED:-0\}/); + assert.match(source, /BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED: \$\{BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED:-0\}/); + assert.match(source, /BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED: \$\{BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED:-0\}/); + assert.match(source, /BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED: \$\{BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED:-0\}/); + assert.match(source, /BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED: \$\{BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED:-0\}/); + assert.match(source, /BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID: \$\{BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID:-\}/); + assert.match(source, /BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER: \$\{BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER:-\}/); + assert.match(source, /BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET: \$\{BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET:-\}/); + assert.match(source, /NODE_ENV: production/); + assert.match(source, /preview_db:[\s\S]*internal: true/); + assert.match(source, /listener_egress:/); + assert.doesNotMatch(source, /livekit:|playlist-bot:|tapestry:/i); + assert.doesNotMatch(source, /PAYPAL_(?:CLIENT|SECRET|PRODUCT|PLAN|WEBHOOK)|MERCADO_PAGO_(?:ACCESS_TOKEN|WEBHOOK_SECRET)|PAID_CHECKOUT_ENABLED/); + + const postgresBlock = source.slice(source.indexOf(' postgres:'), source.indexOf('\n # Forward-only')); + assert.doesNotMatch(postgresBlock, /ports:/, 'preview PostgreSQL must stay container-private'); + assert.match(postgresBlock, /earlybirds-preview-postgres/, 'preview PostgreSQL needs a collision-proof alias'); + assert.match(source, /@earlybirds-preview-postgres:5432/, 'database URLs must use the collision-proof alias'); + assert.match(source, /BEACON_LISTENER_GEOIP_DB_PATH: \/data\/geoip\/dbip-country-lite\.mmdb/); + assert.match(source, /BEACON_LISTENER_GEOIP_HOST_PATH[^\n]*:\/data\/geoip\/dbip-country-lite\.mmdb:ro/); + + const operatorBlock = source.slice(source.indexOf(' withdrawal-operator:'), source.indexOf('\nnetworks:')); + assert.match(operatorBlock, /EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG:\?set_exact_operator_image_tag/); + assert.match(operatorBlock, /restart: unless-stopped/); + assert.match(operatorBlock, /command: \["tail", "-f", "\/dev\/null"\]/); + assert.match(operatorBlock, /networks: \[preview_db\]/); + assert.match(operatorBlock, /migration: \{ condition: service_completed_successfully \}/); + assert.match(operatorBlock, /test -f scripts\/listener-withdrawal-operator\.ts[\s\S]*node_modules\/\.bin\/tsx/); + assert.doesNotMatch(operatorBlock, /ports:|listener_egress|authority_private|volumes:/); +}); + +test('preview lifecycle pins and preserves the private withdrawal operator', async () => { + const helper = await readRepository('scripts/early-birds-preview/lib.sh'); + const start = await readRepository('scripts/early-birds-preview/start.sh'); + const rollback = await readRepository('scripts/early-birds-preview/rollback.sh'); + assert.match(helper, /EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG must be an exact lowercase sha40/); + assert.match(helper, /EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA/); + assert.match(helper, /EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG is required/); + assert.doesNotMatch( + helper.slice(helper.indexOf('require_withdrawal_operator_image'), helper.indexOf('require_synthetic_env')), + /EARLYBIRDS_PREVIEW_GIT_SHA/, + ); + assert.match(helper, /docker image inspect[\s\S]*BEACON_GIT_SHA/); + assert.match(start, /build listener[\s\S]*require_withdrawal_operator_image[\s\S]*up -d listener withdrawal-operator[\s\S]*verify_running_withdrawal_operator/); + assert.match(helper, /withdrawal operator container is not healthy/); + assert.match(helper, /running withdrawal operator provenance does not match its pinned SHA/); + assert.match(helper, /while test "\$operator_attempt" -lt 60; do[\s\S]*sleep 1/); + assert.match(rollback, /stop listener/); + assert.doesNotMatch(rollback, /stop[^\n]*withdrawal-operator/); + + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'withdrawal-operator-pin-')); + const missingTagEnv = path.join(temporary, 'missing-tag.env'); + await fs.writeFile(missingTagEnv, [ + 'EARLYBIRDS_PREVIEW_ENV=runtime', + 'EARLYBIRDS_PREVIEW_IMAGE_TAG=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + '', + ].join('\n')); + const missingTag = spawnSync('sh', [ + '-c', '. "$1"; require_withdrawal_operator_image "$2"', 'sh', + path.join(repositoryRoot, 'scripts/early-birds-preview/lib.sh'), missingTagEnv, + ], { encoding: 'utf8' }); + assert.equal(missingTag.status, 2); + assert.match(missingTag.stderr, /EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG is required/); + + const exactSha = 'cccccccccccccccccccccccccccccccccccccccc'; + const exactRuntimeEnv = path.join(temporary, 'exact-runtime.env'); + const fakeBin = path.join(temporary, 'bin'); + await fs.mkdir(fakeBin); + await fs.writeFile(exactRuntimeEnv, [ + 'EARLYBIRDS_PREVIEW_ENV=synthetic', + `EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG=${exactSha}`, + `EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA=${exactSha}`, + '', + ].join('\n')); + await fs.writeFile(path.join(fakeBin, 'docker'), `#!/bin/sh\nprintf '%s\\n' 'BEACON_GIT_SHA=${exactSha}'\n`, { mode: 0o755 }); + const exactRuntime = spawnSync('sh', [ + '-c', '. "$1"; require_withdrawal_operator_image "$2"', 'sh', + path.join(repositoryRoot, 'scripts/early-birds-preview/lib.sh'), exactRuntimeEnv, + ], { + encoding: 'utf8', + env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}` }, + }); + await fs.rm(temporary, { recursive: true, force: true }); + assert.equal(exactRuntime.status, 0, exactRuntime.stderr); +}); + +test('withdrawal edge is exact, private-by-default and isolated from non-Listener vhosts', async () => { + const listener = await readPreview('nginx/listen.harmonicbeacon.com.conf.template'); + const staging = await readPreview('nginx/earlybirds-staging.harmonicbeacon.com.conf.template'); + for (const [source, port, zone] of [ + [listener, '13000', 'listener_withdrawal'], + [staging, '13001', 'listener_staging_withdrawal'], + ]) { + assert.match(source, new RegExp(`limit_req_zone \\$binary_remote_addr zone=${zone}:1m`)); + assert.match(source, /location = \/listener\/withdrawal \{[\s\S]*if \(\$request_method != GET\) \{ return 405; \}[\s\S]*access_log off;[\s\S]*Cache-Control "private, no-store"/); + assert.match(source, /location = \/listener\/cancel-service \{[\s\S]*if \(\$request_method != GET\) \{ return 405; \}[\s\S]*access_log off;[\s\S]*Cache-Control "private, no-store"/); + const api = source.slice(source.indexOf('location = /api/listener/withdrawal')); + assert.match(api, /if \(\$request_method != POST\) \{ return 405; \}/); + assert.match(api, /client_max_body_size 2048;/); + assert.match(api, new RegExp(`limit_req zone=${zone}`)); + assert.match(api, /access_log off;/); + assert.match(api, /Cache-Control "private, no-store"/); + assert.match(api, new RegExp(`proxy_pass http:\/\/127\\.0\\.0\\.1:${port};`)); + assert.match(api, /proxy_set_header Host \$host;/); + assert.match(api, /proxy_set_header X-Real-IP \$remote_addr;/); + assert.match(api, /proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;/); + assert.match(api, /proxy_set_header X-Forwarded-Proto https;/); + } + + const nginxFiles = await fs.readdir(path.join(previewRoot, 'nginx')); + for (const name of nginxFiles.filter((entry) => !entry.startsWith('listen.') && !entry.startsWith('earlybirds-staging.'))) { + assert.doesNotMatch(await readPreview(`nginx/${name}`), /listener\/(?:withdrawal|cancel-service)/); + } + assert.doesNotMatch(await readPreview('nginx/stream.harmonicbeacon.com.conf.template'), /withdrawal|cancel-service/); +}); + +test('optional authority overlay joins only the dedicated external private network', async () => { + const source = await readPreview('authority-network.override.yml'); + assert.match(source, /^ listener:$/m); + assert.match(source, /authority_private:/); + assert.match(source, /external: true/); + assert.match(source, /EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK/); + assert.match(source, /earlybirds-listener/); + assert.doesNotMatch(source, /paypal|mercadopago|checkout|pmp_beacon_internal/i); + const helper = await readRepository('scripts/early-birds-preview/lib.sh'); + assert.match(helper, /docker network inspect --format '\{\{\.Internal\}\}'/); + assert.match(helper, /authority network must already exist with Internal=true/); +}); + +test('stream overlay preserves its isolated build and adds a public liveness probe', async () => { + const source = await readPreview('stream-build.override.yml'); + assert.match(source, /context: \.\.\/\.\.\/services\/beacon-stream/); + assert.match(source, /dockerfile: Dockerfile/); + assert.match(source, /127\.0\.0\.1:8080\/healthz/); +}); + +test('stream publishes only through a dedicated edge network', async () => { + const source = await readRepository('services/beacon-stream/docker-compose.yml'); + assert.match(source, /127\.0\.0\.1:\$\{BEACON_STREAM_HOST_PORT:-18080\}:8080/); + assert.match(source, /- stream_observability\s+[^]*- stream_edge/); + assert.match(source, /stream_observability:\s+name: earlybirds_stream_observability\s+internal: true/); + assert.match(source, /stream_edge:\s+name: earlybirds_stream_edge/); + assert.match(source, /stream_control:\s+name: earlybirds_stream_control_internal\s+internal: true/); +}); + +test('Listener renews media grants only over the shared private control network', async () => { + const listener = await readPreview('compose.yml'); + const origin = await readRepository('services/beacon-stream/docker-compose.yml'); + const nginx = await readPreview('nginx/stream.harmonicbeacon.com.conf.template'); + assert.match(listener, /EARLY_BIRDS_STREAM_CONTROL_ORIGIN:.*EARLY_BIRDS_STREAM_CONTROL_ORIGIN/); + assert.match(listener, /- stream_control/); + assert.match(origin, /- stream_control/); + assert.match(nginx, /location \^~ \/v1\/hls\/ \{\s+[^}]*access_log off;[^}]*error_log \/dev\/null crit;/); + assert.doesNotMatch(nginx, /internal\/v1\/listener\/media-grants/); +}); + +test('nginx templates isolate staging, stream and the constrained public Listener host', async () => { + const app = await readPreview('nginx/earlybirds-staging.harmonicbeacon.com.conf.template'); + const listener = await readPreview('nginx/listen.harmonicbeacon.com.conf.template'); + const stream = await readPreview('nginx/stream.harmonicbeacon.com.conf.template'); + const combined = `${app}\n${listener}\n${stream}`; + const serverNames = [...combined.matchAll(/server_name\s+([^;]+);/g)].map((match) => match[1]); + assert.deepEqual([...new Set(serverNames)].sort(), [ + 'earlybirds-staging.harmonicbeacon.com', + 'listen.harmonicbeacon.com', + 'stream.harmonicbeacon.com', + ]); + const proxyTargets = [...combined.matchAll(/proxy_pass\s+([^;]+);/g)].map((match) => match[1]); + assert.ok(proxyTargets.length >= 4); + assert.ok(proxyTargets.every((target) => /^http:\/\/127\.0\.0\.1:(13000|13001|18080|18876)$/.test(target))); + assert.doesNotMatch(combined, /live\.harmonicbeacon\.com/); + assert.match(app, /letsencrypt\/live\/earlybirds-staging\.harmonicbeacon\.com/); + assert.match(stream, /letsencrypt\/live\/stream\.harmonicbeacon\.com/); + assert.match(listener, /letsencrypt\/live\/listen\.harmonicbeacon\.com/); + assert.match(app, /location \^~ \/api\/internal\//); + assert.match(app, /location \^~ \/api\/early-birds\//); + assert.equal( + (app.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, + 12, + 'server plus eleven sensitive HTTPS staging locations retain the environment attestation when add_header inheritance stops', + ); + assert.equal( + (listener.match(/X-Harmonic-Beacon-Environment "listener-public-free"/g) ?? []).length, + 11, + 'server plus ten sensitive HTTPS locations retain the environment attestation when add_header inheritance stops', + ); + assert.match(app, /location = \/ \{[^}]*access_log off;[^}]*rewrite \^ \/listener break;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;/s); + assert.match(app, /location \/_next\/webpack-hmr \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;[^}]*Upgrade \$http_upgrade;[^}]*Connection "upgrade";/s); + assert.match(app, /location \/_next\/static\/ \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;[^}]*Cache-Control "private, no-store"/s); + for (const [source, host, port, environment] of [ + [listener, 'listen.harmonicbeacon.com', '13000', 'listener-public-free'], + [app, 'earlybirds-staging.harmonicbeacon.com', '13001', 'early-birds-staging'], + ]) { + const marker = 'location = /assets/hb-global-nav.js {'; + assert.equal(source.split(marker).length - 1, 1); + const block = source.slice(source.indexOf(marker), source.indexOf('\n }', source.indexOf(marker)) + 6); + assert.match(block, /request_method !~ \^\(GET\|HEAD\)\$/); + assert.match(block, /access_log off;/); + assert.match(block, new RegExp(`proxy_pass http://127\\.0\\.0\\.1:${port};`)); + assert.match(block, new RegExp(`proxy_set_header Host ${host.replaceAll('.', '\\.')};`)); + assert.match(block, /Cache-Control "public, max-age=300"/); + assert.match(block, /X-Content-Type-Options nosniff/); + assert.match(block, /Referrer-Policy "no-referrer"/); + assert.match(block, new RegExp(`X-Harmonic-Beacon-Environment "${environment}"`)); + } + assert.doesNotMatch(app, /location \^~ \/assets\//); + assert.doesNotMatch(listener, /location \^~ \/assets\//); + assert.match(app, /location = \/api\/listener\/analysis\/frame \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;[^}]*Cache-Control "private, no-store"/s); + assert.match(app, /location = \/api\/listener\/checkout \{[^}]*access_log off;[^}]*client_max_body_size 512;[^}]*limit_req zone=listener_checkout burst=4 nodelay;[^}]*limit_req_status 429;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;[^}]*Cache-Control "private, no-store"/s); + assert.match(app, /location = \/api\/listener\/checkout\/live-workbench \{[^}]*access_log off;[^}]*client_max_body_size 256;[^}]*limit_req zone=listener_checkout burst=2 nodelay;[^}]*limit_req_status 429;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;[^}]*Cache-Control "private, no-store"/s); + assert.doesNotMatch(listener, /location = \/api\/listener\/checkout\/live-workbench/); + assert.match(app, /location = \/api\/listener\/membership\/action \{[^}]*access_log off;[^}]*client_max_body_size 256;[^}]*limit_req zone=listener_checkout burst=2 nodelay;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;/s); + assert.match(app, /location = \/listener\/terms \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;/s); + assert.match(app, /location = \/listener\/privacy \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;/s); + assert.match(listener, /location = \/api\/listener\/checkout \{[^}]*access_log off;[^}]*limit_req zone=listener_live_checkout burst=4 nodelay;[^}]*client_max_body_size 512;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;[^}]*Cache-Control "private, no-store"/s); + assert.match(listener, /location = \/api\/listener\/membership\/action \{[^}]*access_log off;[^}]*limit_req zone=listener_membership_action burst=2 nodelay;[^}]*client_max_body_size 256;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;/s); + assert.match(listener, /limit_req_zone \$binary_remote_addr zone=listener_auth_recovery:1m rate=12r\/m;/); + assert.match(app, /limit_req_zone \$binary_remote_addr zone=listener_staging_auth_recovery:1m rate=12r\/m;/); + for (const [source, port, zone] of [ + [listener, '13000', 'listener_auth_recovery'], + [app, '13001', 'listener_staging_auth_recovery'], + ]) { + const start = source.indexOf('location = /api/listener/auth/recover {'); + assert.notEqual(start, -1); + const nextLocation = source.indexOf('\n\n location ', start + 1); + const block = source.slice(start, nextLocation === -1 ? undefined : nextLocation); + assert.match(block, /request_method != POST/); + assert.match(block, /return 405;/); + assert.match(block, /access_log off;/); + assert.match(block, new RegExp(`limit_req zone=${zone} burst=3 nodelay;`)); + assert.match(block, /client_max_body_size 64;/); + assert.match(block, new RegExp(`proxy_pass http://127\\.0\\.0\\.1:${port};`)); + assert.match(block, /proxy_set_header X-Real-IP \$remote_addr;/); + assert.match(block, /proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;/); + assert.match(block, /Cache-Control "private, no-store"/); + } + assert.doesNotMatch(stream, /\/api\/listener\/auth\/recover/); + assert.doesNotMatch(app, /location = \/api\/listener\/membership\/cancel/); + assert.doesNotMatch(listener, /location = \/api\/listener\/membership\/cancel/); + assert.match(listener, /location = \/listener\/terms \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;/s); + assert.match(listener, /location = \/listener\/privacy \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;/s); + assert.match(listener, /limit_req_zone \$binary_remote_addr zone=listener_live_checkout:1m rate=6r\/m;/); + assert.match(listener, /limit_req_zone \$binary_remote_addr zone=listener_provider_webhook:1m rate=120r\/m;/); + assert.match(app, /limit_req_zone \$binary_remote_addr zone=listener_visual_analysis:1m rate=20r\/s;/); + assert.match(app, /limit_req_zone \$binary_remote_addr zone=listener_payment_webhooks:1m rate=60r\/m;/); + assert.match(app, /limit_req_zone \$binary_remote_addr zone=listener_checkout:1m rate=6r\/m;/); + assert.match(app, /log_format listener_payment_webhook '[^']*\$request_method \$uri \$status[^']*';/); + assert.doesNotMatch(app, /log_format listener_payment_webhook[^\n]*(\$request_uri|\$args|\$query_string)/); + for (const provider of ['paypal', 'mercado-pago']) { + const start = app.indexOf(`location = /v1/webhooks/early-birds/${provider} {`); + assert.notEqual(start, -1); + const nextLocation = app.indexOf('\n\n location ', start + 1); + const block = app.slice(start, nextLocation === -1 ? undefined : nextLocation); + assert.match(block, /request_method != POST/); + assert.match(block, /return 405;/); + assert.match(block, /client_max_body_size 1m;/); + assert.match(block, /limit_req zone=listener_payment_webhooks burst=60 nodelay;/); + assert.match(block, /limit_req_status 429;/); + assert.match(block, /access_log \/var\/log\/nginx\/listener-payment-webhooks\.log listener_payment_webhook;/); + assert.match(block, /proxy_pass http:\/\/127\.0\.0\.1:18876;/); + } + assert.equal((app.match(/proxy_pass http:\/\/127\.0\.0\.1:18876;/g) ?? []).length, 2); + for (const provider of ['paypal', 'mercado-pago']) { + const start = listener.indexOf(`location = /v1/webhooks/listener/${provider} {`); + assert.notEqual(start, -1); + const nextLocation = listener.indexOf('\n\n location ', start + 1); + const block = listener.slice(start, nextLocation === -1 ? undefined : nextLocation); + assert.match(block, /request_method != POST/); + assert.match(block, /return 405;/); + assert.match(block, /access_log off;/); + assert.match(block, /client_max_body_size 1m;/); + assert.match(block, /limit_req zone=listener_provider_webhook burst=30 nodelay;/); + assert.match(block, /proxy_pass http:\/\/127\.0\.0\.1:18876;/); + } + assert.equal((listener.match(/proxy_pass http:\/\/127\.0\.0\.1:18876;/g) ?? []).length, 2); + assert.doesNotMatch(listener, /\/v1\/webhooks\/early-birds\/(paypal|mercado-pago)/); + assert.match(app, /location = \/api\/listener\/analysis\/frame \{[^}]*limit_req zone=listener_visual_analysis burst=40 nodelay;/s); + assert.match(listener, /limit_req_zone \$binary_remote_addr zone=listener_public_visual_analysis:1m rate=20r\/s;/); + assert.match(listener, /location = \/api\/listener\/analysis\/frame \{[^}]*limit_req zone=listener_public_visual_analysis burst=40 nodelay;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;[^}]*Cache-Control "private, no-store"/s); + assert.doesNotMatch(app, /proxy_pass http:\/\/127\.0\.0\.1:13000;/); + assert.match(app, /location = \/early-birds\/home \{\s*return 302 \/;/); + assert.match(app, /location \/ \{\s*return 404;/); + assert.doesNotMatch(app, /location \^~ \/api\/(auth|ops)|location \^~ \/(login|ops|session)/); + assert.doesNotMatch(stream, /proxy_pass[^\n]*(9090|readyz|metrics)/); + assert.match(listener, /location \^~ \/api\/early-birds\/stream\//); + assert.match(listener, /location \^~ \/api\/early-birds\/drop-ins\//); + assert.match(listener, /location \^~ \/api\/early-birds\/auth\//); + for (const path of ['early-birds', 'listener']) { + assert.match(listener, new RegExp( + `location = /${path} \\{[^}]*access_log off;[^}]*Cache-Control "private, no-store"[^}]*Referrer-Policy "no-referrer"[^}]*return 302 /\\$is_args\\$args;`, + 's', + )); + } + for (const path of ['early-birds/redeem', 'listener/redeem']) { + assert.match(listener, new RegExp( + `location = /${path} \\{[^}]*access_log off;[^}]*Cache-Control "private, no-store"[^}]*Referrer-Policy "no-referrer"[^}]*proxy_pass http://127\\.0\\.0\\.1:13000;`, + 's', + )); + } + for (const [sourceName, sourceText, port] of [ + ['public', listener, '13000'], + ['staging', app, '13001'], + ]) { + const membershipPages = [...sourceText.matchAll(/location = \/listener\/membership \{([\s\S]*?)\n \}/g)]; + assert.equal(membershipPages.length, 2, `${sourceName} HTTP and HTTPS membership pages must be exact`); + assert.ok(membershipPages.every((match) => ( + /request_method != GET/.test(match[1]) + && /access_log off;/.test(match[1]) + && /Cache-Control "private, no-store"/.test(match[1]) + && /Referrer-Policy "no-referrer"/.test(match[1]) + ))); + assert.match(membershipPages[1][1], new RegExp(`proxy_pass http://127\\.0\\.0\\.1:${port};`)); + assert.doesNotMatch(sourceText, /location \^~ \/listener\/|location \/listener\/membership/); + } + assert.match(listener, /location = \/ \{[^}]*access_log off;[^}]*rewrite \^ \/listener break;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;/s); + assert.match(listener, /location = \/api\/listener\/access-state/); + assert.match(listener, /location = \/api\/early-birds\/access-state/); + assert.match(listener, /location = \/api\/listener\/free-window/); + assert.match(listener, /location = \/api\/early-birds\/free-window/); + assert.match(listener, /location = \/api\/listener\/welcome-access/); + assert.match(listener, /location = \/api\/early-birds\/welcome-access/); + assert.match(listener, /location = \/api\/listener\/presence/); + assert.match(listener, /location = \/robots\.txt \{[^}]*rewrite \^ \/api\/listener\/public-discovery\/robots\.txt break;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;[^}]*proxy_set_header Host \$host;/s); + assert.match(listener, /location = \/sitemap\.xml \{[^}]*rewrite \^ \/api\/listener\/public-discovery\/sitemap\.xml break;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;[^}]*proxy_set_header Host \$host;/s); + assert.equal((listener.match(/location = \/robots\.txt/g) ?? []).length, 1); + assert.equal((listener.match(/location = \/sitemap\.xml/g) ?? []).length, 1); + assert.doesNotMatch(listener, /location \^~ \/api\/listener\/public-discovery\//); + assert.doesNotMatch(listener, /location \^~ \/api\/listener\//); + // The internal session-cookie observations exposition is loopback-only: + // the public Listener template must never expose or proxy it. + assert.doesNotMatch(listener, /session-cookie-observations/); + assert.doesNotMatch(listener, /location \^~ \/api\/internal\//); + assert.doesNotMatch(listener, /api\/early-birds\/(test-login|membership)/); + assert.doesNotMatch(listener, /api\/listener\/test-login/); + assert.doesNotMatch(listener, /location \^~ \/api\/listener\/membership/); + assert.doesNotMatch(listener, /location \^~ \/early-birds\//); + + assert.match(listener, /limit_req_zone \$binary_remote_addr zone=listener_invitation_redeem:1m rate=30r\/m;/); + for (const path of ['api/listener/free/redeem', 'api/early-birds/free/redeem']) { + assert.match(listener, new RegExp( + `location = /${path} \\{[^}]*access_log off;[^}]*limit_req zone=listener_invitation_redeem burst=20 nodelay;[^}]*Cache-Control "private, no-store"[^}]*Referrer-Policy "no-referrer"[^}]*proxy_pass http://127\\.0\\.0\\.1:13000;`, + 's', + )); + } + const magicVerificationLocations = [...listener.matchAll( + /location = \/api\/early-birds\/auth\/magic-link\/verify \{([^}]*)\}/g, + )]; + assert.equal(magicVerificationLocations.length, 2, 'HTTP and HTTPS magic bearer entries must be exact'); + assert.ok(magicVerificationLocations.every((match) => ( + /access_log off;/.test(match[1]) + && /Cache-Control "private, no-store"/.test(match[1]) + && /Referrer-Policy "no-referrer"/.test(match[1]) + ))); + + const publicSensitiveEntries = [...listener.matchAll( + /location = \/(?:listener(?:\/redeem)?|early-birds(?:\/redeem)?)? \{([^}]*)\}/g, + )]; + assert.equal(publicSensitiveEntries.length, 10, 'HTTP and HTTPS must protect root and every invitation alias'); + assert.ok(publicSensitiveEntries.every((match) => /access_log off;/.test(match[1]))); + const edgeHeaderProtected = publicSensitiveEntries.filter((match) => ( + /Cache-Control "private, no-store"/.test(match[1]) + && /Referrer-Policy "no-referrer"/.test(match[1]) + )); + assert.equal(edgeHeaderProtected.length, 9, 'the proxied HTTPS root delegates no-store/no-referrer to middleware'); + + const invitationEntryLocations = [...app.matchAll( + /location = \/(?:listener|early-birds)(?:\/redeem)? \{([^}]*)\}/g, + )]; + assert.equal(invitationEntryLocations.length, 8, 'HTTP and HTTPS must protect canonical and legacy invitation entries'); + assert.ok(invitationEntryLocations.every((match) => ( + /access_log off;/.test(match[1]) + && /Cache-Control "private, no-store"/.test(match[1]) + && /Referrer-Policy "no-referrer"/.test(match[1]) + ))); + for (const path of ['early-birds/redeem', 'listener/redeem']) { + const stagingRedeemPages = [...app.matchAll(new RegExp( + `location = /${path} \\{([^}]*)\\}`, + 'g', + ))]; + assert.equal(stagingRedeemPages.length, 2, `HTTP and HTTPS /${path} must be exact`); + assert.ok(stagingRedeemPages.every((match) => ( + /access_log off;/.test(match[1]) + && /Cache-Control "private, no-store"/.test(match[1]) + && /Referrer-Policy "no-referrer"/.test(match[1]) + && /return 302 https:\/\/listen\.harmonicbeacon\.com\/listener\/redeem\$is_args\$args;/.test(match[1]) + ))); + } + + const stagingMagicVerificationLocations = [...app.matchAll( + /location = \/api\/early-birds\/auth\/magic-link\/verify \{([^}]*)\}/g, + )]; + assert.equal(stagingMagicVerificationLocations.length, 2, 'staging HTTP and HTTPS magic bearer entries must be exact'); + assert.ok(stagingMagicVerificationLocations.every((match) => ( + /access_log off;/.test(match[1]) + && /Cache-Control "private, no-store"/.test(match[1]) + && /Referrer-Policy "no-referrer"/.test(match[1]) + && /return 302 https:\/\/listen\.harmonicbeacon\.com\$request_uri;/.test(match[1]) + ))); + + for (const path of ['api/listener/free/redeem', 'api/early-birds/free/redeem']) { + const closedStagingPosts = [...app.matchAll(new RegExp( + `location = /${path} \\{([^}]*)\\}`, + 'g', + ))]; + assert.equal(closedStagingPosts.length, 2, `HTTP and HTTPS /${path} must be exact`); + assert.ok(closedStagingPosts.every((match) => ( + /access_log off;/.test(match[1]) + && /Cache-Control "private, no-store"/.test(match[1]) + && /Referrer-Policy "no-referrer"/.test(match[1]) + && /return 404;/.test(match[1]) + && !/proxy_pass/.test(match[1]) + )), `staging /${path} must fail closed without reaching the application`); + } + const stagingRoots = [...app.matchAll(/location = \/ \{([^}]*)\}/g)]; + assert.equal(stagingRoots.length, 2, 'HTTP and HTTPS staging roots must both be explicit'); + assert.ok(stagingRoots.every((match) => /access_log off;/.test(match[1]))); + for (const path of ['access-state', 'free-window', 'welcome-access']) { + assert.match(app, new RegExp(`location = /api/listener/${path.replace('/', '\\/')}`)); + } + assert.doesNotMatch(app, /location \^~ \/api\/listener\//); +}); + +test('ACME bootstrap serves only challenges and never proxies preview traffic', async () => { + const source = await readPreview('nginx/acme-bootstrap.conf.template'); + assert.match(source, /server_name earlybirds-staging\.harmonicbeacon\.com stream\.harmonicbeacon\.com/); + assert.match(source, /location \/\.well-known\/acme-challenge\//); + assert.match(source, /root \/var\/www\/html/); + assert.match(source, /location \/ \{\s*return 503;/); + assert.doesNotMatch(source, /listen 443|ssl_certificate|proxy_pass/); +}); + +test('public Listener certificate bootstrap is HTTP-only and fail closed', async () => { + const source = await readPreview('nginx/listen-acme-bootstrap.conf.template'); + assert.match(source, /server_name listen\.harmonicbeacon\.com/); + assert.match(source, /location \/\.well-known\/acme-challenge\//); + assert.match(source, /location \/ \{\s*return 503;/); + assert.doesNotMatch(source, /listen 443|ssl_certificate|proxy_pass/); + assert.doesNotMatch(source, /session-cookie-observations/); +}); + +test('production Listener HTTPS validation remains fail closed', async () => { + const streamContract = await readRepository('src/lib/early-birds/stream.ts'); + assert.match( + streamContract, + /environment\.NODE_ENV === 'production' && parsed\.protocol !== 'https:'/, + ); + const compose = await readPreview('compose.yml'); + assert.match(compose, /NODE_ENV: production/); + const env = await readPreview('preview.env.synthetic.example'); + assert.match(env, /^EARLY_BIRDS_STREAM_ORIGIN=https:\/\/stream\.harmonicbeacon\.com$/m); +}); + +test('smoke covers both probes while ordinary app rollback preserves the origin and state', async () => { + const smoke = await readRepository('scripts/early-birds-preview/health-smoke.sh'); + assert.match(smoke, /api\/health"/); + assert.match(smoke, /databaseSchemaVersion/); + assert.match(smoke, /EARLYBIRDS_PREVIEW_SCHEMA_VERSION/); + assert.match(smoke, /grep -Fq/, 'host schema check must use the POSIX host toolchain'); + assert.match(smoke, /api\/health\/ready/); + assert.match(smoke, /stream_port}\/healthz/); + assert.match(smoke, /127\.0\.0\.1:9090\/readyz/); + assert.match(smoke, /State\.ExitCode/); + + const rollback = await readRepository('scripts/early-birds-preview/rollback.sh'); + assert.match(rollback, /stop listener/); + assert.doesNotMatch(rollback, /preview_compose_command[^\n]*stop[^\n]*(postgres|beacon-stream)|\bdown\b|volume rm/); + const start = await readRepository('scripts/early-birds-preview/start.sh'); + assert.match(start, /build listener[\s\S]*up -d listener withdrawal-operator/); + assert.doesNotMatch(start, /up[^\n]*listener[^\n]*beacon-stream|up[^\n]*beacon-stream[^\n]*listener/); + const startOrigin = await readRepository('scripts/early-birds-preview/start-origin.sh'); + assert.match(startOrigin, /up -d --build --no-deps beacon-stream/); + assert.doesNotMatch(startOrigin, /\blistener\b.*\bup\b|up[^\n]*listener/); + const stop = await readRepository('scripts/early-birds-preview/stop.sh'); + assert.match(stop, /stop listener withdrawal-operator beacon-stream postgres/); + assert.doesNotMatch(stop, /\bdown\b|-v\b|volume rm/); + + const disablePublic = await readRepository('scripts/early-birds-preview/disable-public.sh'); + assert.match(disablePublic, /--dry-run\|--apply/); + assert.match(disablePublic, /flock -n 9/); + assert.match(disablePublic, /pre-disable-public/); + assert.match(disablePublic, /chmod 0600 "\$backup"/); + assert.match(disablePublic, /mv -f "\$candidate" "\$env_file"/); + assert.match(disablePublic, /sync -f "\$env_file"/); + assert.match(disablePublic, /up -d --no-deps --force-recreate --no-build listener/); + assert.match(disablePublic, /api\/health\/ready/); + assert.match(disablePublic, /api\/early-birds\/stream\/lease/); + assert.match(disablePublic, /test "\$denial_status" = 503/); + assert.match(disablePublic, /stop listener/); + assert.doesNotMatch(disablePublic, /\bdown\b|volume rm|stop (?:.* )?(postgres|beacon-stream)/); +}); + +test('canonical Free smoke keeps credentials out of argv and verifies the entitled home', async () => { + const source = await readRepository('scripts/early-birds-preview/canonical-free-smoke.sh'); + assert.match(source, /require_synthetic_env/); + assert.match(source, /--config "\$temporary\/login\.curl"/); + assert.match(source, /api\/early-birds\/free\/redeem/); + assert.match(source, /\$base_url\//); + assert.match(source, /invitation\.curl/); + assert.match(source, /trap 'rm -rf "\$temporary"'/); + assert.doesNotMatch(source, /echo[^\n]*(login_secret|invitation_token)/); +}); + +test('registered Free smoke covers weekly quota and device boundaries without exposing its bearer', async () => { + const source = await readRepository('scripts/early-birds-preview/registered-free-smoke.sh'); + assert.match(source, /require_synthetic_env/); + assert.match(source, /--config "\$temporary\/login\.curl"/); + assert.match(source, /personal-7-day-v1/); + assert.match(source, /baseAllowanceMs == 10800000/); + assert.match(source, /removed_status" = 404/); + assert.match(source, /for ordinal in 1 2 3/); + assert.match(source, /evictedAnotherDevice/); + assert.match(source, /\.reason == "displaced"/); + assert.match(source, /https:\/\/stream\\\.harmonicbeacon\\\.com\/v1\/hls/); + assert.match(source, /--config "\$temporary\/manifest\.curl"/); + assert.match(source, /--config "\$temporary\/segment\.curl"/); + assert.match(source, /removed Listener media proxy/); + assert.match(source, /leaseGeneration/); + assert.match(source, /trap 'rm -rf "\$temporary"'/); + assert.doesNotMatch(source, /echo[^\n]*login_secret/); + assert.doesNotMatch(source, /curl[^\n]*"\$(?:manifest_url|segment_url)"/); +}); + +test('Free for All quiescence is shipped as a fail-closed server-only operation', async () => { + const script = await readRepository('scripts/listener-quiesce-for-free-for-all.ts'); + const dockerfile = await readRepository('Dockerfile'); + assert.match(script, /EARLY_BIRDS_ENABLED !== '0'/); + assert.match(script, /EARLY_BIRDS_FREE_FOR_ALL !== '0'/); + assert.match(script, /quiescePersonalListenerLeasesForFreeForAll/); + assert.match(script, /MAX_BATCHES/); + assert.doesNotMatch(script, /accountId|email|deviceDigest/); + assert.match(dockerfile, /listener-quiesce-for-free-for-all\.ts/); + assert.match(dockerfile, /src\/lib\/early-birds\/quota\.ts/); + assert.match(dockerfile, /src\/lib\/early-birds\/stream\.ts/); +}); diff --git a/ops/early-birds/.gitignore b/ops/early-birds/.gitignore new file mode 100644 index 00000000..5d8d05ca --- /dev/null +++ b/ops/early-birds/.gitignore @@ -0,0 +1,3 @@ +data/ +secrets/ +runtime/ diff --git a/ops/early-birds/alertmanager/alertmanager.yml.tmpl b/ops/early-birds/alertmanager/alertmanager.yml.tmpl new file mode 100644 index 00000000..1fe68cb1 --- /dev/null +++ b/ops/early-birds/alertmanager/alertmanager.yml.tmpl @@ -0,0 +1,32 @@ +global: + resolve_timeout: 5m + +route: + receiver: telegram-warning + group_by: [service, alertname, environment] + group_wait: 5m + group_interval: 5m + repeat_interval: 1h + routes: + - receiver: telegram-critical + matchers: [severity="critical"] + group_wait: 0s + group_interval: 1m + repeat_interval: 15m + +receivers: + - name: telegram-warning + telegram_configs: + - bot_token_file: /runtime-secrets/telegram_bot_token + chat_id: __TELEGRAM_CHAT_ID__ + send_resolved: true + message: '{{ template "telegram.default.message" . }}' + - name: telegram-critical + telegram_configs: + - bot_token_file: /runtime-secrets/telegram_bot_token + chat_id: __TELEGRAM_CHAT_ID__ + send_resolved: true + message: '{{ template "telegram.default.message" . }}' + +templates: + - /etc/alertmanager/templates/*.tmpl diff --git a/ops/early-birds/alertmanager/telegram.tmpl b/ops/early-birds/alertmanager/telegram.tmpl new file mode 100644 index 00000000..adb3df7f --- /dev/null +++ b/ops/early-birds/alertmanager/telegram.tmpl @@ -0,0 +1,3 @@ +{{ define "telegram.default.message" }} +[EarlyBirds {{ .Status | toUpper }}] {{ range .Alerts }}{{ .Labels.alertname }} — {{ .Annotations.summary }} ({{ .Labels.service }}; runbook: {{ .Annotations.runbook }}){{ end }} +{{ end }} diff --git a/ops/early-birds/canary/Dockerfile b/ops/early-birds/canary/Dockerfile new file mode 100644 index 00000000..b26e59d6 --- /dev/null +++ b/ops/early-birds/canary/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22.22.0-alpine + +RUN apk add --no-cache ffmpeg +WORKDIR /srv/canary +COPY canary-exporter.mjs ./ +USER node +EXPOSE 8081 +CMD ["node", "canary-exporter.mjs"] diff --git a/ops/early-birds/canary/canary-exporter.mjs b/ops/early-birds/canary/canary-exporter.mjs new file mode 100644 index 00000000..79e8e2ce --- /dev/null +++ b/ops/early-birds/canary/canary-exporter.mjs @@ -0,0 +1,141 @@ +import http from 'node:http'; +import crypto from 'node:crypto'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const signingSecretFile = process.env.BEACON_STREAM_SIGNING_SECRET_FILE ?? '/run/secrets/beacon_stream_signing_secret'; +const publicOrigin = process.env.BEACON_STREAM_PUBLIC_ORIGIN; +const artifactId = process.env.BEACON_STREAM_ARTIFACT_ID; +const intervalMs = Number(process.env.BEACON_CANARY_INTERVAL_MS ?? 30_000); +const timeoutMs = Number(process.env.BEACON_CANARY_TIMEOUT_MS ?? 10_000); +const decoderTimeoutMs = Number(process.env.BEACON_CANARY_DECODER_TIMEOUT_MS ?? 20_000); +const port = Number(process.env.CANARY_EXPORTER_PORT ?? 8081); +const execFileAsync = promisify(execFile); + +export function parseManifest(manifest, nowMs = Date.now()) { + if (!manifest.startsWith('#EXTM3U\n')) throw new Error('not an HLS manifest'); + const segmentUrl = manifest.split('\n').find((line) => /^https?:\/\//.test(line)); + if (!segmentUrl) throw new Error('manifest has no segment URL'); + const programTimes = manifest.split('\n') + .filter((line) => line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) + .map((line) => Date.parse(line.slice('#EXT-X-PROGRAM-DATE-TIME:'.length))) + .filter(Number.isFinite); + if (!programTimes.length) throw new Error('manifest has no program date time'); + return { segmentUrl, manifestAgeSeconds: Math.max(0, (nowMs - programTimes.at(-1)) / 1000) }; +} + +function canonicalManifestPath(id) { + if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id ?? '')) throw new Error('invalid artifact ID'); + return `/v1/hls/${id}/live.m3u8`; +} + +export function mintManifestUrl({ origin, id, secret, nowMs = Date.now(), ttlSeconds = 120 }) { + if (!secret || secret.length < 32) throw new Error('invalid signing secret'); + if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 120) throw new Error('invalid token TTL'); + const pathname = canonicalManifestPath(id); + const expiresAt = Math.floor(nowMs / 1000) + ttlSeconds; + const signature = crypto.createHmac('sha256', secret).update(`GET\n${pathname}\n${expiresAt}`).digest('base64url'); + const url = new URL(pathname, origin); + url.searchParams.set('exp', String(expiresAt)); + url.searchParams.set('sig', signature); + return url.toString(); +} + +async function readSigningSecret(file) { + return (await fs.readFile(file, 'utf8')).trim(); +} + +export async function decodeManifest(manifest, run = execFileAsync) { + const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beacon-canary-')); + const manifestPath = path.join(temporaryRoot, 'probe.m3u8'); + try { + await fs.writeFile(manifestPath, manifest, { encoding: 'utf8', mode: 0o600 }); + await run('ffmpeg', [ + '-nostdin', + '-v', 'error', + '-xerror', + '-protocol_whitelist', 'file,crypto,http,https,tcp,tls', + '-allowed_extensions', 'ALL', + '-i', manifestPath, + '-t', '6', + '-vn', + '-threads', '1', + '-f', 'null', + '-', + ], { timeout: decoderTimeoutMs, maxBuffer: 64 * 1024 }); + } finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }); + } +} + +export async function probe({ + fetchImpl = fetch, + nowMs = () => Date.now(), + origin = publicOrigin, + id = artifactId, + secretFile = signingSecretFile, + decodeImpl = decodeManifest, +} = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = nowMs(); + try { + // A new <=120-second signature is minted on every probe. Origin tokens are + // intentionally short-lived, so a static signed URL is never monitored. + const manifestUrl = mintManifestUrl({ origin, id, secret: await readSigningSecret(secretFile), nowMs: nowMs() }); + const manifestResponse = await fetchImpl(manifestUrl, { cache: 'no-store', signal: controller.signal }); + if (!manifestResponse.ok) throw new Error(`manifest HTTP ${manifestResponse.status}`); + const manifest = await manifestResponse.text(); + const { segmentUrl, manifestAgeSeconds } = parseManifest(manifest, nowMs()); + const segmentResponse = await fetchImpl(segmentUrl, { cache: 'no-store', signal: controller.signal }); + if (!segmentResponse.ok) throw new Error(`segment HTTP ${segmentResponse.status}`); + const segmentBytes = (await segmentResponse.arrayBuffer()).byteLength; + if (!segmentBytes) throw new Error('empty segment'); + await decodeImpl(manifest); + return { ok: 1, manifestAgeSeconds, segmentBytes, durationSeconds: (nowMs() - startedAt) / 1000, completedAtSeconds: nowMs() / 1000 }; + } catch { + // URL and exception details may contain an HMAC. The exporter emits state only. + return { ok: 0, manifestAgeSeconds: 0, segmentBytes: 0, durationSeconds: (nowMs() - startedAt) / 1000, completedAtSeconds: nowMs() / 1000 }; + } finally { + clearTimeout(timer); + } +} + +function metrics(state) { + return [ + '# HELP beacon_stream_canary_ok 1 when the HLS canary fetched media and decoded six seconds of audio.', + '# TYPE beacon_stream_canary_ok gauge', + `beacon_stream_canary_ok ${state.ok}`, + '# HELP beacon_stream_canary_manifest_age_seconds Age of the newest HLS program date time.', + '# TYPE beacon_stream_canary_manifest_age_seconds gauge', + `beacon_stream_canary_manifest_age_seconds ${state.manifestAgeSeconds}`, + '# HELP beacon_stream_canary_segment_bytes Bytes fetched from the current canary segment.', + '# TYPE beacon_stream_canary_segment_bytes gauge', + `beacon_stream_canary_segment_bytes ${state.segmentBytes}`, + '# HELP beacon_stream_canary_probe_duration_seconds End-to-end HTTP canary duration.', + '# TYPE beacon_stream_canary_probe_duration_seconds gauge', + `beacon_stream_canary_probe_duration_seconds ${state.durationSeconds}`, + '# HELP beacon_stream_canary_last_completed_unixtime Last completed canary probe.', + '# TYPE beacon_stream_canary_last_completed_unixtime gauge', + `beacon_stream_canary_last_completed_unixtime ${state.completedAtSeconds}`, + '', + ].join('\n'); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + let state = { ok: 0, manifestAgeSeconds: 0, segmentBytes: 0, durationSeconds: 0, completedAtSeconds: 0 }; + const run = async () => { state = await probe(); }; + await run(); + setInterval(run, intervalMs).unref(); + http.createServer((request, response) => { + if (request.method !== 'GET' || request.url !== '/metrics') { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' }); + response.end(metrics(state)); + }).listen(port, '0.0.0.0'); +} diff --git a/ops/early-birds/docker-compose.yml b/ops/early-birds/docker-compose.yml new file mode 100644 index 00000000..08df920d --- /dev/null +++ b/ops/early-birds/docker-compose.yml @@ -0,0 +1,165 @@ +# Isolated EarlyBirds observability preview. No service publishes a metrics/admin port. +services: + config-init: + image: alpine:3.21.3 + restart: "no" + secrets: [telegram_chat_id] + volumes: + - ./alertmanager/alertmanager.yml.tmpl:/template/alertmanager.yml.tmpl:ro + - alertmanager-runtime:/runtime + command: + - /bin/sh + - -ec + - | + chat_id="$$(tr -d '\r\n' &2; exit 1; } + sed "s/__TELEGRAM_CHAT_ID__/$$chat_id/g" /template/alertmanager.yml.tmpl > /runtime/alertmanager.yml + networks: [observability] + + alertmanager-secret-init: + image: alpine:3.21.3 + restart: "no" + network_mode: none + secrets: [telegram_bot_token] + volumes: [alertmanager-secrets:/runtime-secrets] + command: + - /bin/sh + - -ec + - | + umask 077 + cp /run/secrets/telegram_bot_token /runtime-secrets/telegram_bot_token + chown 65534:65534 /runtime-secrets/telegram_bot_token + chmod 0400 /runtime-secrets/telegram_bot_token + + prometheus: + image: prom/prometheus:v3.4.2 + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=14d + - --storage.tsdb.retention.size=8GB + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/alerts.yml:/etc/prometheus/rules/alerts.yml:ro + - prometheus-data:/prometheus + ports: [127.0.0.1:9090:9090] + networks: [observability, ops_edge, authority_private] + deploy: + resources: + limits: { cpus: "1.0", memory: 1G } + + alertmanager: + image: prom/alertmanager:v0.28.1 + restart: unless-stopped + depends_on: + config-init: { condition: service_completed_successfully } + alertmanager-secret-init: { condition: service_completed_successfully } + command: + - --config.file=/runtime/alertmanager.yml + - --storage.path=/alertmanager + - --web.listen-address=0.0.0.0:9093 + volumes: + - alertmanager-runtime:/runtime:ro + - alertmanager-secrets:/runtime-secrets:ro + - ./alertmanager/telegram.tmpl:/etc/alertmanager/templates/telegram.tmpl:ro + - alertmanager-data:/alertmanager + ports: [127.0.0.1:9093:9093] + networks: [observability, ops_edge] + deploy: + resources: + limits: { cpus: "0.25", memory: 256M } + + node-exporter: + image: prom/node-exporter:v1.9.1 + restart: unless-stopped + command: + - --path.rootfs=/host + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --web.listen-address=0.0.0.0:9100 + - --collector.textfile.directory=/host/var/lib/harmonic-beacon/metrics + volumes: + - /:/host:ro,rslave + - /proc:/host/proc:ro + - /sys:/host/sys:ro + networks: [observability] + deploy: + resources: + limits: { cpus: "0.25", memory: 128M } + + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.52.1 + restart: unless-stopped + privileged: true + command: ["-housekeeping_interval=15s", "-docker_only=true"] + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + networks: [observability] + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + + canary-secret-init: + image: alpine:3.21.3 + restart: "no" + network_mode: none + secrets: [beacon_stream_signing_secret] + volumes: [canary-runtime:/runtime] + command: + - /bin/sh + - -ec + - | + umask 077 + cp /run/secrets/beacon_stream_signing_secret /runtime/signing_secret + chown 1000:1000 /runtime/signing_secret + chmod 0400 /runtime/signing_secret + + canary-exporter: + build: ./canary + restart: unless-stopped + depends_on: + canary-secret-init: { condition: service_completed_successfully } + environment: + BEACON_STREAM_SIGNING_SECRET_FILE: /runtime/signing_secret + BEACON_STREAM_PUBLIC_ORIGIN: ${BEACON_STREAM_PUBLIC_ORIGIN:?set in the root-owned ops env file} + BEACON_STREAM_ARTIFACT_ID: ${BEACON_STREAM_ARTIFACT_ID:?set in the root-owned ops env file} + BEACON_CANARY_INTERVAL_MS: 30000 + BEACON_CANARY_DECODER_TIMEOUT_MS: 20000 + volumes: [canary-runtime:/runtime:ro] + # The canary needs outbound HTTPS to exercise the public origin. Its + # metrics remain available only to Prometheus on `observability`. + networks: [observability, ops_edge] + deploy: + resources: + limits: { cpus: "0.5", memory: 192M } + +networks: + observability: + external: true + name: earlybirds_stream_observability + # Explicit egress plus loopback-published admin ports. No service publishes + # a wildcard host port on this bridge. + ops_edge: + name: earlybirds_observability_edge + authority_private: + external: true + name: earlybirds_authority_private + +volumes: + prometheus-data: + alertmanager-data: + alertmanager-runtime: + alertmanager-secrets: + canary-runtime: + +secrets: + telegram_bot_token: + file: ${TELEGRAM_BOT_TOKEN_FILE:?set a root-owned secret file outside Git} + telegram_chat_id: + file: ${TELEGRAM_CHAT_ID_FILE:?set a root-owned secret file outside Git} + beacon_stream_signing_secret: + file: ${BEACON_STREAM_SIGNING_SECRET_FILE:?set a root-owned secret file outside Git} diff --git a/ops/early-birds/package.json b/ops/early-birds/package.json new file mode 100644 index 00000000..35c2a2e5 --- /dev/null +++ b/ops/early-birds/package.json @@ -0,0 +1,11 @@ +{ + "name": "harmonic-beacon-early-birds-ops", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.mjs", + "check": "node --check canary/canary-exporter.mjs && node --check scripts/validate-config.mjs", + "validate": "node scripts/validate-config.mjs" + }, + "engines": { "node": ">=22" } +} diff --git a/ops/early-birds/prometheus/alerts.yml b/ops/early-birds/prometheus/alerts.yml new file mode 100644 index 00000000..80a98537 --- /dev/null +++ b/ops/early-birds/prometheus/alerts.yml @@ -0,0 +1,180 @@ +groups: + - name: listener-consumer-requests + rules: + - alert: ListenerConsumerRequestQueueWarning + expr: beacon_listener_withdrawal_oldest_open_age_seconds > 72000 and beacon_listener_withdrawal_oldest_open_age_seconds <= 86400 + for: 5m + labels: { severity: warning, service: listener-consumer-requests } + annotations: { summary: "A Listener consumer request has been open for more than 20 hours", runbook: "listener-consumer-requests" } + - alert: ListenerConsumerRequestQueueCritical + expr: beacon_listener_withdrawal_oldest_open_age_seconds > 86400 + for: 1m + labels: { severity: critical, service: listener-consumer-requests } + annotations: { summary: "A Listener consumer request has been open for more than 24 hours", runbook: "listener-consumer-requests" } + - alert: ListenerConsumerRequestMetricsStale + expr: time() - beacon_listener_withdrawal_metrics_export_unixtime > 600 and time() - beacon_listener_withdrawal_metrics_export_unixtime <= 1200 + for: 2m + labels: { severity: warning, service: listener-consumer-requests } + annotations: { summary: "Listener consumer request metrics have not refreshed for ten minutes", runbook: "listener-consumer-requests" } + - alert: ListenerConsumerRequestMetricsMissing + expr: (time() - beacon_listener_withdrawal_metrics_export_unixtime > 1200) or absent_over_time(beacon_listener_withdrawal_metrics_export_unixtime[20m]) + for: 1m + labels: { severity: critical, service: listener-consumer-requests } + annotations: { summary: "Listener consumer request metrics are missing or more than twenty minutes old", runbook: "listener-consumer-requests" } + - name: listener-paid-authority + rules: + - alert: ListenerAuthorityUnreachable + expr: up{job="listener-authority"} == 0 + for: 2m + labels: { severity: critical, service: listener-payments } + annotations: { summary: "Listener membership authority is unreachable", runbook: "listener-paid-authority" } + - alert: ListenerSandboxProviderUnavailable + expr: pmp_listener_new_sales_enabled{environment=~"sandbox|test"} == 1 and on(provider, environment) pmp_listener_provider_ready{environment=~"sandbox|test"} == 0 + for: 5m + labels: { severity: warning, service: listener-payments } + annotations: { summary: "Sandbox/test sales are enabled while a Listener provider is unavailable", runbook: "listener-paid-provider" } + - alert: ListenerLiveProviderUnavailableDuringSales + expr: pmp_listener_new_sales_enabled{environment="live"} == 1 and on(provider, environment) pmp_listener_provider_ready{environment="live"} == 0 + for: 2m + labels: { severity: critical, service: listener-payments } + annotations: { summary: "New sales are enabled while a Live provider is unavailable", runbook: "listener-paid-provider" } + - alert: ListenerPaidQueueDelayed + expr: pmp_listener_paid_queue_oldest_age_seconds > 120 + for: 5m + labels: { severity: warning, service: listener-payments } + annotations: { summary: "Listener paid lifecycle queue is more than two minutes old", runbook: "listener-paid-queue" } + - alert: ListenerPaidQueueCritical + expr: pmp_listener_paid_queue_oldest_age_seconds > 600 + for: 2m + labels: { severity: critical, service: listener-payments } + annotations: { summary: "Listener paid lifecycle queue is more than ten minutes old", runbook: "listener-paid-queue" } + - alert: ListenerPaidJobFailed + expr: sum(pmp_listener_paid_jobs_failed_recent) > 0 + for: 2m + labels: { severity: critical, service: listener-payments } + annotations: { summary: "A durable Listener paid lifecycle job failed", runbook: "listener-paid-queue" } + - alert: ListenerProjectionFailed + expr: pmp_listener_paid_jobs_failed_recent{kind="early_birds.beacon.project"} > 0 + for: 1m + labels: { severity: critical, service: listener-payments } + annotations: { summary: "A canonical Listener membership projection failed", runbook: "listener-paid-projection" } + - alert: ListenerWebhookSignatureFailures + expr: sum(increase(pmp_listener_paid_requests_total{operation="webhook",outcome="invalid_signature"}[5m])) > 5 + for: 2m + labels: { severity: warning, service: listener-payments } + annotations: { summary: "Listener webhook signature failures exceed the warning threshold", runbook: "listener-paid-webhook" } + - alert: ListenerWebhookSignatureFailuresCritical + expr: sum(increase(pmp_listener_paid_requests_total{operation="webhook",outcome="invalid_signature"}[5m])) > 20 + for: 1m + labels: { severity: critical, service: listener-payments } + annotations: { summary: "Listener webhook signature failures exceed the critical threshold", runbook: "listener-paid-webhook" } + - alert: ListenerCheckoutProviderErrors + expr: sum(increase(pmp_listener_paid_requests_total{operation="checkout",outcome="provider_error"}[5m])) > 0 + for: 2m + labels: { severity: warning, service: listener-payments } + annotations: { summary: "Listener checkout provider errors were observed", runbook: "listener-paid-provider" } + - name: early-birds-origin + rules: + - alert: EarlyBirdsOriginUnreachable + expr: up{job="beacon-stream"} == 0 + for: 2m + labels: { severity: critical, service: beacon-stream } + annotations: { summary: "EarlyBirds stream origin is unreachable", runbook: "early-birds-origin-unreachable" } + - alert: EarlyBirdsManifestStale + expr: beacon_stream_canary_manifest_age_seconds > 18 + for: 2m + labels: { severity: warning, service: beacon-stream } + annotations: { summary: "Beacon manifest edge is more than 18 seconds old", runbook: "early-birds-canary" } + - alert: EarlyBirdsManifestVeryStale + expr: beacon_stream_canary_manifest_age_seconds > 60 + for: 1m + labels: { severity: critical, service: beacon-stream } + annotations: { summary: "Beacon manifest edge is more than 60 seconds old", runbook: "early-birds-canary" } + - alert: EarlyBirdsOriginErrorRateHigh + expr: sum(rate(beacon_stream_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(beacon_stream_http_requests_total[5m])), 1) >= 0.005 + for: 5m + labels: { severity: warning, service: beacon-stream } + annotations: { summary: "Origin 5xx rate is at least 0.5%", runbook: "early-birds-origin-errors" } + - alert: EarlyBirdsOriginErrorRateCritical + expr: sum(rate(beacon_stream_http_requests_total{status=~"5.."}[2m])) / clamp_min(sum(rate(beacon_stream_http_requests_total[2m])), 1) >= 0.02 + for: 2m + labels: { severity: critical, service: beacon-stream } + annotations: { summary: "Origin 5xx rate is at least 2%", runbook: "early-birds-origin-errors" } + - alert: EarlyBirdsOriginLatencyHigh + expr: beacon_stream_http_request_duration_seconds{quantile="0.95"} > 1 + for: 5m + labels: { severity: warning, service: beacon-stream } + annotations: { summary: "Origin p95 request latency exceeds 1 second", runbook: "early-birds-origin-errors" } + - alert: EarlyBirdsCanaryFailed + expr: beacon_stream_canary_ok == 0 + for: 2m + labels: { severity: critical, service: beacon-stream } + annotations: { summary: "HLS canary cannot fetch and decode Beacon audio", runbook: "early-birds-canary" } + - alert: EarlyBirdsCanaryStalled + expr: absent(beacon_stream_canary_last_completed_unixtime) or (time() - beacon_stream_canary_last_completed_unixtime > 90) + for: 2m + labels: { severity: critical, service: beacon-stream } + annotations: { summary: "HLS decoder canary has not completed a probe for 90 seconds", runbook: "early-birds-canary" } + - name: early-birds-capacity + rules: + - alert: EarlyBirdsHostCpuPrepare + expr: 1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) > 0.50 + for: 15m + labels: { severity: warning, service: host } + annotations: { summary: "Host CPU has exceeded the 50% prepare threshold", runbook: "early-birds-capacity" } + - alert: EarlyBirdsHostCpuCritical + expr: 1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[2m])) > 0.75 + for: 2m + labels: { severity: critical, service: host } + annotations: { summary: "Host CPU has exceeded the 75% critical threshold", runbook: "early-birds-capacity" } + - alert: EarlyBirdsHostMemoryPrepare + expr: 1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.70 + for: 10m + labels: { severity: warning, service: host } + annotations: { summary: "Host memory has exceeded the 70% prepare threshold", runbook: "early-birds-capacity" } + - alert: EarlyBirdsHostMemoryCritical + expr: 1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.85 + for: 2m + labels: { severity: critical, service: host } + annotations: { summary: "Host memory has exceeded the 85% critical threshold", runbook: "early-birds-capacity" } + - alert: EarlyBirdsDiskPrepare + expr: node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} < 0.30 + for: 15m + labels: { severity: warning, service: host } + annotations: { summary: "Host root disk has {{ $value | humanizePercentage }} free (warning below 30%)", runbook: "early-birds-capacity" } + - alert: EarlyBirdsDiskCritical + expr: node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} < 0.15 + for: 2m + labels: { severity: critical, service: host } + annotations: { summary: "Host root disk has {{ $value | humanizePercentage }} free (critical below 15%)", runbook: "early-birds-capacity" } + # VPS-4 headline is 3 Gbit/s. Actual soak evidence may lower these values. + - alert: EarlyBirdsNetworkPrepare + expr: sum(rate(node_network_transmit_bytes_total{device!~"lo|docker.*|veth.*"}[30m])) * 8 > 1.5e9 + for: 30m + labels: { severity: warning, service: host } + annotations: { summary: "Host egress sustained the 50% capacity-prepare threshold", runbook: "early-birds-capacity" } + - alert: EarlyBirdsNetworkExpansion + expr: sum(rate(node_network_transmit_bytes_total{device!~"lo|docker.*|veth.*"}[5m])) * 8 > 1.8e9 + for: 5m + labels: { severity: warning, service: host } + annotations: { summary: "Host egress reached the 4,000-listener expansion threshold", runbook: "early-birds-capacity" } + - alert: EarlyBirdsNetworkCritical + expr: sum(rate(node_network_transmit_bytes_total{device!~"lo|docker.*|veth.*"}[2m])) * 8 > 2.25e9 + for: 2m + labels: { severity: critical, service: host } + annotations: { summary: "Host egress reached the 5,000-listener critical threshold", runbook: "early-birds-capacity" } + - alert: EarlyBirdsTcpRetransmitsHigh + expr: rate(node_netstat_Tcp_RetransSegs[5m]) / clamp_min(rate(node_netstat_Tcp_OutSegs[5m]), 1) >= 0.01 + for: 5m + labels: { severity: warning, service: host } + annotations: { summary: "TCP retransmit rate is at least 1%", runbook: "early-birds-capacity" } + - alert: EarlyBirdsTcpRetransmitsCritical + expr: rate(node_netstat_Tcp_RetransSegs[2m]) / clamp_min(rate(node_netstat_Tcp_OutSegs[2m]), 1) >= 0.03 + for: 2m + labels: { severity: critical, service: host } + annotations: { summary: "TCP retransmit rate is at least 3%", runbook: "early-birds-capacity" } + - alert: EarlyBirdsNetworkErrors + expr: sum(rate(node_network_transmit_errs_total{device!~"lo|docker.*|veth.*"}[5m])) + sum(rate(node_network_receive_errs_total{device!~"lo|docker.*|veth.*"}[5m])) + sum(rate(node_network_transmit_drop_total{device!~"lo|docker.*|veth.*"}[5m])) > 0 + for: 5m + labels: { severity: warning, service: host } + annotations: { summary: "Host network interface reports errors or dropped packets", runbook: "early-birds-capacity" } diff --git a/ops/early-birds/prometheus/prometheus.yml b/ops/early-birds/prometheus/prometheus.yml new file mode 100644 index 00000000..c54db2c1 --- /dev/null +++ b/ops/early-birds/prometheus/prometheus.yml @@ -0,0 +1,39 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + product: early-birds + environment: preview + +alerting: + alertmanagers: + - static_configs: + - targets: [alertmanager:9093] + +rule_files: + - /etc/prometheus/rules/*.yml + +scrape_configs: + # This is the origin's separate internal listener, never its public endpoint. + - job_name: beacon-stream + static_configs: + - targets: [beacon-stream:9090] + + - job_name: early-birds-canary + static_configs: + - targets: [canary-exporter:8081] + + # The membership authority exposes aggregate, label-bounded metrics only on + # its internal Compose network. No nginx/public route reaches this target. + - job_name: listener-authority + metrics_path: /metrics + static_configs: + - targets: [pmp-myth-api:8765] + + - job_name: node + static_configs: + - targets: [node-exporter:9100] + + - job_name: cadvisor + static_configs: + - targets: [cadvisor:8080] diff --git a/ops/early-birds/runbook/README.md b/ops/early-birds/runbook/README.md new file mode 100644 index 00000000..a2108136 --- /dev/null +++ b/ops/early-birds/runbook/README.md @@ -0,0 +1,161 @@ +# EarlyBirds preview operations + +This stack is separate from the event compose project. It observes the +EarlyBirds origin through its private metrics listener and exposes Prometheus, +Alertmanager and node-exporter only on host loopback. Access is through a +ZeroTier/admin tunnel; do not add a public nginx location for metrics or admin. + +## Bootstrap and secrets + +Create the private Telegram group **Harmonic Beacon · Ops**, create a dedicated +bot, add it to the group, and store each value in a separate root-owned `0600` +file outside Git. `TELEGRAM_BOT_TOKEN_FILE`, `TELEGRAM_CHAT_ID_FILE` and +`BEACON_STREAM_SIGNING_SECRET_FILE` point to those files at Compose runtime. +The bot token is consumed by Alertmanager as a Docker secret; the chat ID is +validated as an integer by the short-lived config initializer. No credential, +signed URL, email, account identifier, request path or raw webhook is included +in an alert. + +Bring up the bounded preview services only after the stream compose created the +private `earlybirds_stream_observability` network: + +```bash +docker compose --project-name earlybirds-observability \ + --env-file /etc/harmonic-beacon/earlybirds-ops.env up -d --build +``` + +`npm run validate` validates Compose, Prometheus rules/config and Alertmanager +config with generated fake secrets; it never contacts Telegram. + +The included canary reads the HMAC secret from its mounted file and mints a +fresh, <=120-second manifest URL for every probe using the same canonical GET +path contract as the origin. It verifies the HLS manifest, fetches a non-empty +signed segment and asks FFmpeg to decode six seconds of the complete fMP4 +playlist. Signed URLs and decoder errors are suppressed from logs; the exporter +publishes only success, duration, byte count and manifest age. This proves that +the deployed artifact is continuously decodable, not subjective listening +quality. Move the same bounded canary to an independent VPS before a production +capacity claim so it also exercises an external network path. + +## Alert behavior and immediate action + +Warnings wait five minutes, group by service/alert/environment and repeat every +hour. Critical alerts notify immediately and repeat every 15 minutes. All +receivers set `send_resolved: true`, so recovery messages are mandatory. + +| Signal | Warning | Critical | Immediate action | +| --- | --- | --- | --- | +| Origin/canary | manifest age >18s | origin unavailable, decode failed, no completed probe for 90s, age >60s | Check private `/readyz`; stop only the EarlyBird origin if it affects host safety. | +| Origin quality | 5xx ≥0.5%, p95 >1s | 5xx ≥2% | Inspect origin logs without copying signed URLs; verify artifact and source state. | +| Host | CPU >50%, memory >70%, disk <30% | CPU >75%, memory >85%, disk <15% | Prepare/move capacity; never reclaim event volumes during an incident. | +| Network | sustained egress >1.5 Gbit/s, expansion >1.8 Gbit/s for 5m, retransmits ≥1% or interface errors | egress >2.25 Gbit/s for 2m or retransmits ≥3% | Activate the prepared Bunny pull distribution, then verify cache/origin error rates. | + +The planning envelope is 450 kbit/s per listener: 3,000 committed (~1.35 +Gbit/s), 4,000 expansion (~1.8 Gbit/s), and 5,000 critical (~2.25 Gbit/s). +Measured external soak throughput replaces these thresholds before launch. A +Bunny activation is justified by either the 4,000 expansion threshold, the +critical threshold, persistent 5xx/rebuffer evidence, retransmits ≥1%, or a +healthy origin whose direct egress remains the bottleneck. It is not activated +solely from an advertised NIC speed. + +## Paid Listener authority + +Prometheus joins the authority's existing private Docker network and scrapes +`pmp-myth-api:8765/metrics`. The authority port remains loopback/private and no +nginx location exposes metrics. Exported payment labels are fixed provider, +environment, operation, outcome, job kind and job status values; no account, +email, provider subscription ID, checkout URL, webhook body or signature is +exported. + +Operational signals cover authority reachability, provider readiness while +new sales are enabled, the oldest durable paid job, failed lifecycle/projection +jobs, invalid webhook signatures and checkout provider errors. The request +counters are process-local; `pmp_listener_paid_observer_process_start_time_seconds` +separates restart epochs. Database queue gauges remain durable across API +restarts. Queue age includes only due, immediate jobs; scheduled renewal locks +and checkout-expiry recovery do not page before their `available_at`. Failed-job +alerts use a rolling 15-minute window, so historical pre-release failures stay +auditable without remaining permanently active. + +Immediate actions: + +- **authority/provider:** turn off new sales in Listener and authority, but + leave webhooks, reconciliation and existing membership access running; +- **queue/projection:** inspect only aggregate job status first, retry or + reconcile through the durable authority path, and never infer access from a + browser redirect; +- **webhook signatures:** verify the exact provider environment and registered + endpoint before changing a secret; do not log or paste webhook bodies; +- **recovery:** wait for the matching resolved Telegram notification and a + green authority target before reopening sales. + +Fault injection uses a synthetic Alertmanager alert with fixed labels and an +explicit end time, followed by a resolved update. It must never disable the +origin or any event container. A deliberately missed sandbox webhook is +repaired by the provider reconciliation worker, then the canonical Listener +projection is verified before the drill is considered complete. + +## Per-container restart/OOM observability blocker + +Per-container start, restart and OOM continuity for the isolated Listener and +origin is a hard prerequisite for the Listener external smoke (see +`docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md`). The original cAdvisor-backed +`container_start_time_seconds` and `container_oom_events_total` design remains +unusable on `mona`: Prometheus exposes only the root cgroup and cAdvisor logs +that it cannot find +`/rootfs/var/lib/docker/image/overlayfs/layerdb/mounts/.../mount-id`. + +An earlier change blamed missing recursive slave propagation on the cAdvisor +`/:/rootfs` bind and was **reverted as incorrect**: an independent audit showed +the running cAdvisor container already has `/` -> `/rootfs` with +`Propagation=rslave` and still hits the same errors. Docker 29.6.2 on `mona` +uses the containerd image store (`driver-type=io.containerd.snapshotter.v1`, +`Driver=overlayfs`); `/var/lib/docker/image` has no legacy `layerdb` and +`docker inspect .GraphDriver` is null. The real cause is that the current +cAdvisor is incompatible with Docker's containerd image store for these +per-container series. + +**Recreating or restarting cAdvisor is not a fix and must never be proposed or +treated as one** — no mount propagation flag changes this. + +The reviewed code path is now a root-owned host observer: +`scripts/listener_container_observer.py` and the +`harmonic-beacon-listener-container-observer` systemd timer. It accepts no +caller-controlled target/path, performs only one fixed `docker inspect` for the +isolated Listener and origin, verifies exact Compose labels, stores a durable +root-only epoch/counter state and exports fixed-role metrics through the +existing node-exporter textfile directory. Private networking, AF_UNIX-only and +strict filesystem controls bound the unit; no Docker socket is mounted into an +application container. + +This implementation remains **not installed by code merge**. Installation on +`mona` requires a separate operational review because Docker read access is +root-equivalent. It must not restart Docker, cAdvisor, Listener, origin or any +event service. Until the unit is explicitly installed and all observer +health/freshness/epoch/start/restart/OOM queries return exactly one finite +series, the ten-client smoke remains runtime-blocked. Empty, duplicated, stale +or reset series are a hard blocker, never a reason to proceed. Exact install, +verification and revocation commands live in +`docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md`. + +The bounded ten-client wrapper also requires its fixed local lock at +`/tmp/harmonic-beacon-listener-smoke-10-network-run.lock`. The path has no CLI +or environment override. A pre-existing lock refuses the run; verify no +wrapper is active before removing a stale one, and never manipulate it during +a run. This serializes one trusted Unix account on one generator only; it does +not enforce a global limit across hosts. + +## Stop switch and rollback + +To stop only the EarlyBird stream origin: + +```bash +ops/early-birds/scripts/stop-stream.sh /etc/harmonic-beacon/earlybirds-stream.env +``` + +It pins `--project-name earlybirds-preview` and the isolated stream compose +file; it cannot target the event stack. Restore with the same env file and +`up -d beacon-stream` only after the canary and `/readyz` recover. The Listener +entry feature flag is owned by the application lane and must be disabled there +for a truthful public unavailable state; this ops slice never changes event +routes or data. diff --git a/ops/early-birds/scripts/stop-stream.sh b/ops/early-birds/scripts/stop-stream.sh new file mode 100755 index 00000000..6990f749 --- /dev/null +++ b/ops/early-birds/scripts/stop-stream.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu + +environment_file=${1:?usage: ops/early-birds/scripts/stop-stream.sh /secure/earlybirds-stream.env} +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) + +# This project name and compose file are intentionally EarlyBirds-only. It +# cannot stop the event compose project, LiveKit or playlist-bot. +exec docker compose --project-name earlybirds-preview --env-file "$environment_file" \ + -f "$repository_root/services/beacon-stream/docker-compose.yml" stop beacon-stream diff --git a/ops/early-birds/scripts/validate-config.mjs b/ops/early-birds/scripts/validate-config.mjs new file mode 100644 index 00000000..c17577d9 --- /dev/null +++ b/ops/early-birds/scripts/validate-config.mjs @@ -0,0 +1,41 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'earlybirds-ops-')); +const secret = async (name, contents) => { + const file = path.join(temporary, name); + await fs.writeFile(file, contents, { mode: 0o600 }); + return file; +}; + +try { + const botToken = await secret('telegram_bot_token', 'not-a-real-token'); + const chatId = await secret('telegram_chat_id', '-1000000000000'); + const signingSecret = await secret('beacon_stream_signing_secret', 'not-a-real-32-character-or-longer-secret'); + const environment = path.join(temporary, 'preview.env'); + await fs.writeFile(environment, [ + 'BEACON_STREAM_ARTIFACTS_HOST_PATH=.', + 'BEACON_STREAM_MEDIA_ROOT=/media/artifacts', + 'BEACON_STREAM_ARTIFACT_ID=approved-artifact-id', + 'BEACON_STREAM_PUBLIC_ORIGIN=https://stream.example.invalid', + 'BEACON_STREAM_SIGNING_SECRET=not-a-real-32-character-or-longer-secret', + `TELEGRAM_BOT_TOKEN_FILE=${botToken}`, + `TELEGRAM_CHAT_ID_FILE=${chatId}`, + `BEACON_STREAM_SIGNING_SECRET_FILE=${signingSecret}`, + '', + ].join('\n'), { mode: 0o600 }); + const generatedAlertmanager = path.join(temporary, 'alertmanager.yml'); + const template = await fs.readFile(path.join(root, 'alertmanager/alertmanager.yml.tmpl'), 'utf8'); + await fs.writeFile(generatedAlertmanager, template.replaceAll('__TELEGRAM_CHAT_ID__', '-1000000000000')); + const run = (args) => execFileSync('docker', args, { cwd: root, stdio: 'inherit' }); + run(['compose', '--env-file', environment, 'config', '--quiet']); + run(['run', '--rm', '--entrypoint=promtool', '-v', `${path.join(root, 'prometheus')}:/etc/prometheus:ro`, 'prom/prometheus:v3.4.2', 'check', 'config', '/etc/prometheus/prometheus.yml']); + run(['run', '--rm', '--entrypoint=promtool', '-v', `${path.join(root, 'prometheus/alerts.yml')}:/rules.yml:ro`, 'prom/prometheus:v3.4.2', 'check', 'rules', '/rules.yml']); + run(['run', '--rm', '--entrypoint=amtool', '-v', `${generatedAlertmanager}:/config/alertmanager.yml:ro`, 'prom/alertmanager:v0.28.1', 'check-config', '/config/alertmanager.yml']); + console.log('EarlyBirds observability configuration is valid.'); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/ops/early-birds/systemd/harmonic-beacon-listener-container-observer.service b/ops/early-birds/systemd/harmonic-beacon-listener-container-observer.service new file mode 100644 index 00000000..c0763793 --- /dev/null +++ b/ops/early-birds/systemd/harmonic-beacon-listener-container-observer.service @@ -0,0 +1,30 @@ +[Unit] +Description=Export fixed Listener container restart and OOM metrics +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +User=root +Group=root +ExecStart=/usr/bin/python3 /usr/local/libexec/harmonic-beacon/listener_container_observer.py +NoNewPrivileges=yes +PrivateDevices=yes +PrivateNetwork=yes +PrivateTmp=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +ReadOnlyPaths=/var/run/docker.sock +ReadWritePaths=/var/lib/harmonic-beacon/metrics /var/lib/harmonic-beacon/listener-container-observer +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +UMask=0077 diff --git a/ops/early-birds/systemd/harmonic-beacon-listener-container-observer.timer b/ops/early-birds/systemd/harmonic-beacon-listener-container-observer.timer new file mode 100644 index 00000000..dc8b2aea --- /dev/null +++ b/ops/early-birds/systemd/harmonic-beacon-listener-container-observer.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Refresh fixed Listener container restart and OOM metrics + +[Timer] +OnBootSec=30s +OnUnitActiveSec=5s +AccuracySec=1s +Persistent=false +Unit=harmonic-beacon-listener-container-observer.service + +[Install] +WantedBy=timers.target diff --git a/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-metrics.service b/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-metrics.service new file mode 100644 index 00000000..27fcada2 --- /dev/null +++ b/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-metrics.service @@ -0,0 +1,17 @@ +[Unit] +Description=Export privacy-bounded Listener consumer request metrics +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +Group=root +EnvironmentFile=/etc/harmonic-beacon/listener-withdrawal-ops.env +ExecStart=/usr/local/libexec/harmonic-beacon/listener-withdrawal-export-metrics.sh +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/var/lib/harmonic-beacon/metrics +UMask=0077 diff --git a/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-metrics.timer b/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-metrics.timer new file mode 100644 index 00000000..a65e8413 --- /dev/null +++ b/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-metrics.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Refresh Listener consumer request metrics every five minutes + +[Timer] +OnBootSec=2m +OnUnitActiveSec=5m +RandomizedDelaySec=20s +Persistent=true +Unit=harmonic-beacon-listener-withdrawal-metrics.service + +[Install] +WantedBy=timers.target diff --git a/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-prune.service b/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-prune.service new file mode 100644 index 00000000..c2a7b1cb --- /dev/null +++ b/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-prune.service @@ -0,0 +1,16 @@ +[Unit] +Description=Prune expired Listener consumer request throttle buckets +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +Group=root +EnvironmentFile=/etc/harmonic-beacon/listener-withdrawal-ops.env +ExecStart=/usr/local/libexec/harmonic-beacon/listener-withdrawal-prune-throttles.sh +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only +UMask=0077 diff --git a/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-prune.timer b/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-prune.timer new file mode 100644 index 00000000..ae4272af --- /dev/null +++ b/ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-prune.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Prune Listener consumer request throttle buckets daily + +[Timer] +OnCalendar=daily +RandomizedDelaySec=15m +Persistent=true +Unit=harmonic-beacon-listener-withdrawal-prune.service + +[Install] +WantedBy=timers.target diff --git a/ops/early-birds/test/canary-exporter.test.mjs b/ops/early-birds/test/canary-exporter.test.mjs new file mode 100644 index 00000000..f0a908be --- /dev/null +++ b/ops/early-birds/test/canary-exporter.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import test from 'node:test'; +import { decodeManifest, mintManifestUrl, parseManifest } from '../canary/canary-exporter.mjs'; + +test('extracts a signed segment and measures the newest manifest edge age', () => { + const result = parseManifest([ + '#EXTM3U', + '#EXT-X-PROGRAM-DATE-TIME:2026-08-06T00:00:12.000Z', + '#EXTINF:6.000,', + 'https://stream.example.test/v1/hls/a/segments/00002.m4s?exp=1&sig=opaque', + '', + ].join('\n'), Date.parse('2026-08-06T00:00:18.000Z')); + assert.equal(result.segmentUrl.startsWith('https://stream.example.test/'), true); + assert.equal(result.manifestAgeSeconds, 6); +}); + +test('does not accept a response that only happens to be HTTP text', () => { + assert.throws(() => parseManifest('not a manifest\n'), /not an HLS manifest/); +}); + +test('mints a fresh manifest URL using the exact origin HMAC canonical contract', () => { + const secret = 'x'.repeat(32); + const nowMs = Date.parse('2026-08-06T00:00:00.000Z'); + const url = new URL(mintManifestUrl({ origin: 'https://stream.example.test', id: 'approved-v1', secret, nowMs })); + const expiresAt = Number(url.searchParams.get('exp')); + assert.equal(expiresAt, Math.floor(nowMs / 1000) + 120); + const expected = crypto.createHmac('sha256', secret) + .update(`GET\n/v1/hls/approved-v1/live.m3u8\n${expiresAt}`).digest('base64url'); + assert.equal(url.searchParams.get('sig'), expected); + assert.throws(() => mintManifestUrl({ origin: 'https://stream.example.test', id: 'approved-v1', secret, nowMs, ttlSeconds: 121 }), /token TTL/); +}); + +test('hands the private manifest to a bounded decoder and removes it afterwards', async () => { + let temporaryManifest = ''; + await decodeManifest('#EXTM3U\n#EXT-X-ENDLIST\n', async (command, args, options) => { + assert.equal(command, 'ffmpeg'); + assert.equal(args.includes('-xerror'), true); + assert.equal(args.includes('-threads'), true); + assert.equal(options.timeout > 0, true); + temporaryManifest = args[args.indexOf('-i') + 1]; + assert.equal(await fs.readFile(temporaryManifest, 'utf8'), '#EXTM3U\n#EXT-X-ENDLIST\n'); + }); + await assert.rejects(fs.stat(temporaryManifest), { code: 'ENOENT' }); +}); diff --git a/ops/early-birds/test/config.test.mjs b/ops/early-birds/test/config.test.mjs new file mode 100644 index 00000000..a3ccb770 --- /dev/null +++ b/ops/early-birds/test/config.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); +const read = (file) => fs.readFile(path.join(root, file), 'utf8'); + +test('keeps all metrics and Alertmanager listeners off public interfaces', async () => { + const compose = await read('docker-compose.yml'); + assert.match(compose, /127\.0\.0\.1:9090:9090/); + assert.match(compose, /127\.0\.0\.1:9093:9093/); + assert.doesNotMatch(compose, /network_mode: host/); + assert.match(compose, /--path\.procfs=\/host\/proc/); + assert.match(compose, /--path\.sysfs=\/host\/sys/); + assert.match(compose, /networks: \[observability\]/); + assert.match(compose, /networks: \[observability, ops_edge\]/g); + assert.match(compose, /networks: \[observability, ops_edge, authority_private\]/); + assert.match(compose, /ops_edge:\s+name: earlybirds_observability_edge/); + assert.match(compose, /authority_private:\s+external: true\s+name: earlybirds_authority_private/); + assert.doesNotMatch(compose, /--web\.enable-lifecycle=false/); + // Alertmanager may bind inside its private Docker network, but host-published + // admin/metrics ports must remain loopback-only. + assert.doesNotMatch(compose, /ports:\s*\[0\.0\.0\.0:909[0-3]/); +}); + +test('references Telegram and canary credentials as mounted secret files only', async () => { + const compose = await read('docker-compose.yml'); + const alertmanager = await read('alertmanager/alertmanager.yml.tmpl'); + assert.match(compose, /TELEGRAM_BOT_TOKEN_FILE/); + assert.match(compose, /TELEGRAM_CHAT_ID_FILE/); + assert.match(compose, /BEACON_STREAM_SIGNING_SECRET_FILE/); + assert.match(compose, /alertmanager-secret-init:[\s\S]*network_mode: none/); + assert.match(compose, /chown 65534:65534 \/runtime-secrets\/telegram_bot_token/); + assert.match(compose, /chmod 0400 \/runtime-secrets\/telegram_bot_token/); + assert.match(compose, /alertmanager-secret-init: \{ condition: service_completed_successfully \}/); + assert.match(compose, /canary-secret-init:[\s\S]*network_mode: none/); + assert.match(compose, /chown 1000:1000 \/runtime\/signing_secret/); + assert.match(compose, /canary-secret-init: \{ condition: service_completed_successfully \}/); + assert.match(compose, /BEACON_STREAM_SIGNING_SECRET_FILE: \/runtime\/signing_secret/); + assert.match(compose, /BEACON_STREAM_PUBLIC_ORIGIN/); + assert.match(compose, /BEACON_STREAM_ARTIFACT_ID/); + assert.doesNotMatch(compose, /TELEGRAM_BOT_TOKEN:\s*[^$]/); + assert.match(alertmanager, /bot_token_file: \/runtime-secrets\/telegram_bot_token/g); + assert.match(alertmanager, /send_resolved: true/g); + assert.ok(compose.includes("grep -Eq '^-?[0-9]+$$'")); + assert.doesNotMatch(compose, /case "\$\$chat_id" in/); +}); + +test('scrapes node-exporter by the internal Docker DNS name', async () => { + const prometheus = await read('prometheus/prometheus.yml'); + const compose = await read('docker-compose.yml'); + assert.match(prometheus, /targets: \[node-exporter:9100\]/); + assert.match(compose, /--collector\.textfile\.directory=\/host\/var\/lib\/harmonic-beacon\/metrics/); + assert.match(prometheus, /job_name: listener-authority[\s\S]*targets: \[pmp-myth-api:8765\]/); + assert.doesNotMatch(prometheus, /host\.docker\.internal/); +}); + +test('exports fixed Listener container safety metrics from a hardened host timer', async () => { + const observer = await fs.readFile( + path.join(root, '../../scripts/listener_container_observer.py'), + 'utf8', + ); + const service = await read('systemd/harmonic-beacon-listener-container-observer.service'); + const timer = await read('systemd/harmonic-beacon-listener-container-observer.timer'); + assert.match(observer, /earlybirds-preview-listener-1/); + assert.match(observer, /earlybirds-preview-beacon-stream-1/); + assert.match(observer, /com\.docker\.compose\.project.*earlybirds-preview/s); + assert.match(observer, /subprocess\.run\([\s\S]*DOCKER_BINARY,[\s\S]*--host=unix:\/\/\/var\/run\/docker\.sock[\s\S]*"inspect"/); + assert.match(observer, /"DOCKER_CONFIG": "\/nonexistent"/); + assert.doesNotMatch(observer, /docker exec|docker events|curl|https?:\/\//); + assert.match(observer, /beacon_listener_container_observer_up 0/); + assert.match(observer, /beacon_listener_container_observer_epoch_start_time_seconds/); + assert.match(observer, /role="\{role\}"/); + assert.doesNotMatch(observer, /name="\{target\['name'\]\}"/); + assert.match(service, /User=root/); + assert.match(service, /PrivateNetwork=yes/); + assert.match(service, /RestrictAddressFamilies=AF_UNIX/); + assert.match(service, /ReadOnlyPaths=\/var\/run\/docker\.sock/); + assert.match(service, /ReadWritePaths=\/var\/lib\/harmonic-beacon\/metrics \/var\/lib\/harmonic-beacon\/listener-container-observer/); + assert.match(timer, /OnUnitActiveSec=5s/); + assert.match(timer, /Persistent=false/); +}); + +test('alerts on the private consumer request age metric without PII', async () => { + const alerts = await read('prometheus/alerts.yml'); + assert.match(alerts, /ListenerConsumerRequestQueueWarning[\s\S]*> 72000/); + assert.match(alerts, /ListenerConsumerRequestQueueCritical[\s\S]*> 86400/); + assert.match(alerts, /ListenerConsumerRequestMetricsStale[\s\S]*> 600/); + assert.match(alerts, /ListenerConsumerRequestMetricsMissing[\s\S]*absent_over_time/); + assert.doesNotMatch( + alerts.slice(alerts.indexOf('listener-consumer-requests'), alerts.indexOf('listener-paid-authority')), + /email|receipt|provider_id|request_id/, + ); +}); + +test('schedules an atomic private metric export and out-of-band throttle pruning', async () => { + const exporter = await fs.readFile(path.join(root, '../../scripts/listener-withdrawal-export-metrics.sh'), 'utf8'); + const metricService = await read('systemd/harmonic-beacon-listener-withdrawal-metrics.service'); + const metricTimer = await read('systemd/harmonic-beacon-listener-withdrawal-metrics.timer'); + const pruneService = await read('systemd/harmonic-beacon-listener-withdrawal-prune.service'); + const pruneTimer = await read('systemd/harmonic-beacon-listener-withdrawal-prune.timer'); + const prune = await fs.readFile(path.join(root, '../../scripts/listener-withdrawal-prune-throttles.sh'), 'utf8'); + assert.match(exporter, /mktemp[\s\S]*listener-withdrawal-operator\.ts metrics[\s\S]*metrics_export_unixtime[\s\S]*mv -f/); + assert.match(exporter, /docker exec --user root/); + assert.match(exporter, /earlybirds-preview-withdrawal-operator-1/); + assert.doesNotMatch(exporter, /earlybirds-preview-listener-1/); + assert.match(exporter, /State\.Health[\s\S]*true healthy/); + assert.match(metricService, /EnvironmentFile=\/etc\/harmonic-beacon\/listener-withdrawal-ops\.env/); + assert.match(metricTimer, /OnUnitActiveSec=5m/); + assert.match(pruneService, /\/usr\/local\/libexec\/harmonic-beacon\/listener-withdrawal-prune-throttles\.sh/); + assert.match(prune, /prune-throttles 48/); + assert.match(prune, /earlybirds-preview-withdrawal-operator-1/); + assert.match(prune, /State\.Health[\s\S]*true healthy/); + assert.match(pruneTimer, /OnCalendar=daily/); + assert.doesNotMatch(exporter, /curl|https?:\/\//); + const dockerfile = await fs.readFile(path.join(root, '../../Dockerfile'), 'utf8'); + assert.match(dockerfile, /listener-withdrawal-operator\.ts/); + assert.match(dockerfile, /consumer-withdrawal\.ts/); +}); + +test('alerts on paid authority failures without account or provider identifiers', async () => { + const alerts = await read('prometheus/alerts.yml'); + assert.match(alerts, /ListenerAuthorityUnreachable/); + assert.match(alerts, /ListenerPaidQueueDelayed/); + assert.match(alerts, /ListenerPaidQueueCritical/); + assert.match(alerts, /ListenerPaidJobFailed/); + assert.match(alerts, /ListenerProjectionFailed/); + assert.match(alerts, /ListenerWebhookSignatureFailuresCritical/); + assert.match(alerts, /ListenerCheckoutProviderErrors/); + assert.doesNotMatch(alerts, /account_id|email|subscription_id|approval_url/); +}); + +test('alerts on unavailable payment providers only while their sales lane is enabled', async () => { + const alerts = await read('prometheus/alerts.yml'); + const sandboxRule = alerts.slice( + alerts.indexOf('- alert: ListenerSandboxProviderUnavailable'), + alerts.indexOf('- alert: ListenerLiveProviderUnavailableDuringSales'), + ); + assert.match(sandboxRule, /pmp_listener_new_sales_enabled\{environment=~"sandbox\|test"\} == 1/); + assert.match(sandboxRule, /pmp_listener_provider_ready\{environment=~"sandbox\|test"\} == 0/); + assert.match(sandboxRule, /on\(provider, environment\)/); +}); + +test('routes warnings hourly and critical alerts immediately every fifteen minutes', async () => { + const alertmanager = await read('alertmanager/alertmanager.yml.tmpl'); + assert.match(alertmanager, /group_wait: 5m[\s\S]*repeat_interval: 1h/); + assert.match(alertmanager, /matchers: \[severity="critical"\][\s\S]*group_wait: 0s[\s\S]*repeat_interval: 15m/); +}); + +test('disk alerts report their measured free-space percentage', async () => { + const alerts = await read('prometheus/alerts.yml'); + assert.match(alerts, /EarlyBirdsDiskPrepare[\s\S]*\{\{ \$value \| humanizePercentage \}\} free \(warning below 30%\)/); + assert.match(alerts, /EarlyBirdsDiskCritical[\s\S]*\{\{ \$value \| humanizePercentage \}\} free \(critical below 15%\)/); +}); diff --git a/ops/listener-account-production/README.md b/ops/listener-account-production/README.md new file mode 100644 index 00000000..5a059f9b --- /dev/null +++ b/ops/listener-account-production/README.md @@ -0,0 +1,40 @@ +# Listener production Account preparation + +This package prepares the production Listener relying-party secret without +changing the running Listener or enabling Account. It deliberately precedes +the coordinated Account/Listener cutover. + +1. Build the exact reviewed `early-birds` SHA as + `harmonic-beacon/earlybirds-preview-listener:`, with + `EARLYBIRDS_PREVIEW_SCHEMA_VERSION` equal to the exact + `BEACON_ACCOUNT_SCHEMA_VERSION` production coordinate. Activation rejects a + correctly tagged image carrying an older schema label. +2. Run `sudo scripts/listener-account-production/prepare.sh ` on Mona. + It reads the root-only Account and Listener env files in a networkless, + read-only candidate container. The resulting two-key bundle is installed at + `/etc/harmonic-beacon/listener-account-production.env` as root:root 0600. +3. Do not copy the bundle into the running Listener env and do not set + `BEACON_LISTENER_ACCOUNT_ENABLED=1` yet. +4. After Account production has a reviewed public TLS edge and is fully ready, + run `sudo scripts/listener-account-production/preflight.sh `. It + exposes only the dedicated RP client secret to a bounded egress probe and + proves readiness, discovery, JWKS, Basic authentication and session-status. +5. After fresh Listener DB/env backups, run + `sudo scripts/listener-account-production/activate.sh `. It generates + the Account-on env inside the exact networkless image, atomically installs + it, recreates only the Listener app, and keeps rollback active through local + and public login smokes. It does not rebuild/restart the stream origin, + PostgreSQL, withdrawal worker, payments, LiveKit or event services. + Subsequent immutable Listener releases use this same command while Account + is already on. The generator then requires the active production RP secrets + to match the protected bundle exactly and rejects any reintroduced legacy + Google, Apple or magic-link credential before replacing the app. +6. The printed root-only activation directory is the only accepted argument to + `rollback.sh`. Rollback restores the exact prior Account mode, env and image; + it never downgrades the shared database. + +The first Account production migration revokes legacy Listener authentication +sessions. Therefore Account migration, Listener env activation and the public +edge change belong to one maintenance boundary with fresh backups and rollback. +This preparatory package does not perform that boundary and does not require a +DNS record. diff --git a/ops/listener-account-production/package.json b/ops/listener-account-production/package.json new file mode 100644 index 00000000..990868cf --- /dev/null +++ b/ops/listener-account-production/package.json @@ -0,0 +1,9 @@ +{ + "name": "@harmonic-beacon/listener-account-production-ops", + "private": true, + "type": "module", + "scripts": { + "check": "node --check validate.mjs && node --check ../../scripts/listener-account-production/sync-secret.mjs && node --check ../../scripts/listener-account-production/preflight.mjs && node --check ../../scripts/listener-account-production/activate-env.mjs && sh -n ../../scripts/listener-account-production/prepare.sh ../../scripts/listener-account-production/preflight.sh ../../scripts/listener-account-production/activate.sh ../../scripts/listener-account-production/rollback.sh ../../scripts/listener-account-production/health-smoke.sh", + "test": "node --test test/contract.test.mjs" + } +} diff --git a/ops/listener-account-production/test/contract.test.mjs b/ops/listener-account-production/test/contract.test.mjs new file mode 100644 index 00000000..42e02c8e --- /dev/null +++ b/ops/listener-account-production/test/contract.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { buildProductionBundle } from '../../../scripts/listener-account-production/sync-secret.mjs'; +import { buildProductionActivation } from '../../../scripts/listener-account-production/activate-env.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const source = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8'); +const client = 'c'.repeat(64); +const state = 's'.repeat(64); +const account = `BEACON_ACCOUNT_BASE_URL=https://account.harmonicbeacon.com\nBEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER=${client}\n`; +const listener = 'EARLY_BIRDS_AUTH_BASE_URL=https://listen.harmonicbeacon.com\nBEACON_LISTENER_ACCOUNT_ENABLED=0\n'; + +const activationListener = [ + 'EARLYBIRDS_PREVIEW_IMAGE_TAG=old', + 'EARLYBIRDS_PREVIEW_GIT_SHA=old', + 'EARLYBIRDS_PREVIEW_BUILD_TIME=old', + 'EARLYBIRDS_PREVIEW_SCHEMA_VERSION=20260813190000_listener_withdrawal_request', + 'EARLY_BIRDS_AUTH_BASE_URL=https://listen.harmonicbeacon.com', + 'EARLY_BIRDS_TRUSTED_ORIGINS=https://listen.harmonicbeacon.com,https://earlybirds-staging.harmonicbeacon.com', + 'EARLY_BIRDS_GOOGLE_CLIENT_ID=legacy-google', + 'EARLY_BIRDS_GOOGLE_CLIENT_SECRET=legacy-google-secret', + 'EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL=https://legacy.example.invalid', + 'EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN=legacy-mail-token', + 'EARLY_BIRDS_MAGIC_LINK_RATE_SECRET=legacy-rate-secret', + 'BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=1', + 'BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED=1', + 'BEACON_LISTENER_ACCOUNT_ENABLED=0', + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET=', + '', +].join('\n'); + +test('builds the exact two-key production bundle and preserves state', () => { + const bundle = buildProductionBundle({ + accountContents: account, + listenerContents: listener, + currentContents: `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}\n`, + }); + assert.equal(bundle, `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}\n`); +}); + +test('refuses enabled, staging and unexpected secret states', () => { + assert.throws(() => buildProductionBundle({ + accountContents: account, + listenerContents: listener.replace('=0', '=1'), + }), /must remain disabled/); + assert.throws(() => buildProductionBundle({ + accountContents: account, + listenerContents: `${listener}BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING=${state}\n`, + }), /STAGING must be absent/); + const rotated = buildProductionBundle({ + accountContents: account, + listenerContents: listener, + currentContents: `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${'x'.repeat(64)}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}\n`, + }); + assert.equal(rotated, `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}\n`); + assert.throws(() => buildProductionBundle({ + accountContents: account, + listenerContents: listener, + currentContents: `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}\nEXTRA=value\n`, + }), /unexpected keys/); +}); + +test('builds a production-only Listener activation without changing unrelated values', () => { + const sha = 'a'.repeat(40); + const activated = buildProductionActivation({ + listenerContents: `${activationListener}UNRELATED=preserved\n`, + bundleContents: `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}\n`, + expectedSha: sha, + buildTime: '2026-08-19T06:00:00Z', + expectedSchema: '20260818010000_beacon_account_authority', + }); + assert.match(activated, new RegExp(`EARLYBIRDS_PREVIEW_IMAGE_TAG=${sha}`)); + assert.match(activated, new RegExp(`EARLYBIRDS_PREVIEW_GIT_SHA=${sha}`)); + assert.match(activated, /EARLYBIRDS_PREVIEW_BUILD_TIME=2026-08-19T06:00:00Z/); + assert.match(activated, /EARLYBIRDS_PREVIEW_SCHEMA_VERSION=20260818010000_beacon_account_authority/); + assert.match(activated, /BEACON_LISTENER_ACCOUNT_ENABLED=1/); + assert.match(activated, new RegExp(`BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}`)); + assert.match(activated, new RegExp(`BEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}`)); + assert.match(activated, /EARLY_BIRDS_GOOGLE_CLIENT_ID=\n/); + assert.match(activated, /EARLY_BIRDS_GOOGLE_CLIENT_SECRET=\n/); + assert.match(activated, /BEACON_LISTENER_APPLE_ENABLED=0/); + assert.match(activated, /BEACON_LISTENER_APPLE_CLIENT_ID=\n/); + assert.match(activated, /BEACON_LISTENER_APPLE_CLIENT_SECRET=\n/); + assert.match(activated, /EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL=\n/); + assert.match(activated, /EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN=\n/); + assert.match(activated, /EARLY_BIRDS_MAGIC_LINK_RATE_SECRET=\n/); + assert.match(activated, /BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=1/); + assert.match(activated, /BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED=1/); + assert.match(activated, /UNRELATED=preserved/); + + const redeployed = buildProductionActivation({ + listenerContents: activated, + bundleContents: `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}\n`, + expectedSha: 'b'.repeat(40), + buildTime: '2026-08-20T20:00:00Z', + expectedSchema: '20260818010000_beacon_account_authority', + }); + assert.match(redeployed, new RegExp(`EARLYBIRDS_PREVIEW_IMAGE_TAG=${'b'.repeat(40)}`)); + assert.match(redeployed, /BEACON_LISTENER_ACCOUNT_ENABLED=1/); + assert.match(redeployed, /BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=1/); + assert.match(redeployed, /BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED=1/); + assert.match(redeployed, /UNRELATED=preserved/); +}); + +test('activation refuses drifted enabled, cross-environment, ambiguous and invalid input', () => { + const input = { + listenerContents: activationListener, + bundleContents: `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${state}\n`, + expectedSha: 'a'.repeat(40), + buildTime: '2026-08-19T06:00:00Z', + expectedSchema: '20260818010000_beacon_account_authority', + }; + assert.throws(() => buildProductionActivation({ + ...input, + listenerContents: activationListener.replace('ACCOUNT_ENABLED=0', 'ACCOUNT_ENABLED=2'), + }), /must be 0 or 1/); + const withoutFlag = buildProductionActivation({ + ...input, + listenerContents: activationListener.replace('BEACON_LISTENER_ACCOUNT_ENABLED=0\n', ''), + }); + assert.match(withoutFlag, /BEACON_LISTENER_ACCOUNT_ENABLED=1/); + const active = buildProductionActivation(input); + assert.throws(() => buildProductionActivation({ + ...input, + listenerContents: active.replace( + `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}`, + `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${'x'.repeat(64)}`, + ), + }), /enabled Listener environment BEACON_LISTENER_ACCOUNT_CLIENT_SECRET mismatch/); + assert.throws(() => buildProductionActivation({ + ...input, + listenerContents: active.replace('EARLY_BIRDS_GOOGLE_CLIENT_ID=', 'EARLY_BIRDS_GOOGLE_CLIENT_ID=drifted'), + }), /enabled Listener environment EARLY_BIRDS_GOOGLE_CLIENT_ID mismatch/); + assert.throws(() => buildProductionActivation({ + ...input, + listenerContents: `${activationListener}BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING=${state}\n`, + }), /STAGING must be empty/); + assert.throws(() => buildProductionActivation({ + ...input, + bundleContents: `${input.bundleContents}EXTRA=value\n`, + }), /unexpected keys/); + assert.throws(() => buildProductionActivation({ + ...input, + bundleContents: `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${client}\nBEACON_LISTENER_ACCOUNT_STATE_SECRET=${client}\n`, + }), /must differ/); + assert.throws(() => buildProductionActivation({ ...input, expectedSha: 'latest' }), /sha40/); + assert.throws(() => buildProductionActivation({ ...input, expectedSchema: 'latest' }), /schema migration/); +}); + +test('host wrappers constrain secrets, networking, provenance and arguments', () => { + const prepare = source('scripts/listener-account-production/prepare.sh'); + const preflight = source('scripts/listener-account-production/preflight.sh'); + const activate = source('scripts/listener-account-production/activate.sh'); + const rollback = source('scripts/listener-account-production/rollback.sh'); + const health = source('scripts/listener-account-production/health-smoke.sh'); + assert.match(prepare, /id -u/); + assert.match(preflight, /id -u/); + assert.match(prepare, /--network none/); + assert.match(prepare, /--read-only/); + assert.match(prepare, /--cap-drop ALL/); + assert.match(prepare, /--security-opt no-new-privileges/); + assert.match(prepare, /BEACON_GIT_SHA/); + assert.match(prepare, /root:root:600/); + assert.doesNotMatch(prepare, /docker inspect .*Config\.Env/); + assert.match(preflight, /earlybirds_preview_listener_egress/); + assert.doesNotMatch(preflight, /account\.production\.env|earlybirds-preview\.env/); + assert.match(activate, /--network none/); + assert.match(activate, /--read-only/); + assert.match(activate, /--cap-drop ALL/); + assert.match(activate, /--security-opt no-new-privileges/); + assert.match(activate, /preflight\.sh["']? "?\$expected_sha/); + assert.ok( + activate.indexOf('preflight.sh" "$expected_sha"') < activate.indexOf('install -d -o root -g root -m 0700 "$state"'), + 'public Account preflight must precede persistent activation state', + ); + assert.match(activate, /previous\.env/); + assert.match(activate, /--no-deps --force-recreate --no-build listener/); + assert.match(activate, /health-smoke\.sh"[\s\\\n]+"\$expected_sha" 1 "\$expected_schema"/); + assert.match(activate, /candidate image schema provenance mismatch/); + assert.match(activate, /previous-schema\.txt/); + assert.match(activate, /previous-account-mode\.txt/); + assert.match(activate, /health-smoke\.sh"[\s\\\n]+"\$previous_sha" "\$previous_account_mode" "\$previous_schema"/); + assert.match(activate, /BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED/); + assert.match(activate, /protected-containers\.before/); + assert.match(activate, /protected-containers\.after/); + assert.match(activate, /cmp -s "\$state\/protected-env\.before" "\$state\/protected-env\.after"/); + assert.ok( + activate.indexOf('\ncutover_started=1\n') < activate.lastIndexOf('mv -T "$temporary" "$listener_env"'), + 'rollback must become active before replacing the production env', + ); + assert.ok( + activate.lastIndexOf('health-smoke.sh"') < activate.lastIndexOf('cutover_started=0'), + 'rollback must stay active through the external acceptance smoke', + ); + assert.match(activate, /trap 'exit 130' HUP INT TERM/); + assert.match(activate, /rm -rf "\$state"/); + assert.doesNotMatch(activate, /require_synthetic_env/); + assert.doesNotMatch(activate, /cat [^\n]*(?:account|listener).*\.env/); + assert.match(rollback, /listener-account-production\/activation-/); + assert.match(rollback, /running Listener does not match this rollback candidate/); + assert.match(rollback, /--no-deps --force-recreate --no-build listener/); + assert.match(rollback, /database was not downgraded/); + assert.match(rollback, /previous-account-mode\.txt/); + assert.match(rollback, /health-smoke\.sh"[\s\\\n]+"\$previous_sha" "\$previous_account_mode" "\$previous_schema"/); + assert.match(rollback, /trap '' HUP INT TERM/); + assert.doesNotMatch(rollback, /require_synthetic_env/); + assert.match(health, /--connect-timeout 3 --max-time 8/); + assert.match(health, /--proto '=https'/); + assert.match(health, /api\/account\/login\/extra/); + assert.match(health, /EARLY_BIRDS_GOOGLE_CLIENT_SECRET/); + assert.match(health, /EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN/); + assert.match(health, /checks\.listenerAccount == "ok"/); + assert.match(health, /databaseSchemaVersion == \$schema/); + assert.match(health, /trap 'exit 130' HUP INT TERM/); +}); + +test('preflight pins the production issuer and frozen OIDC contract', () => { + const preflight = source('scripts/listener-account-production/preflight.mjs'); + assert.match(preflight, /https:\/\/account\.harmonicbeacon\.com/); + assert.match(preflight, /hb-listener/); + assert.match(preflight, /client_secret_basic/); + assert.match(preflight, /JSON\.stringify\(\['client_secret_basic'\]\)/); + assert.match(preflight, /S256/); + assert.match(preflight, /Ed25519/); + assert.match(preflight, /session-status/); + assert.match(preflight, /AbortSignal\.timeout\(8_000\)/); +}); + +test('Docker image contains only the required preparation scripts', () => { + const dockerfile = source('Dockerfile'); + assert.match(dockerfile, /scripts\/listener-account-production\/sync-secret\.mjs/); + assert.match(dockerfile, /scripts\/listener-account-production\/preflight\.mjs/); + assert.match(dockerfile, /scripts\/listener-account-production\/activate-env\.mjs/); + assert.match(dockerfile, /ops\/listener-account-production\/validate\.mjs/); +}); diff --git a/ops/listener-account-production/validate.mjs b/ops/listener-account-production/validate.mjs new file mode 100755 index 00000000..209f9c3a --- /dev/null +++ b/ops/listener-account-production/validate.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +const requiredFiles = [ + '/app/scripts/listener-account-production/sync-secret.mjs', + '/app/scripts/listener-account-production/preflight.mjs', + '/app/scripts/listener-account-production/activate-env.mjs', +]; + +for (const file of requiredFiles) { + const metadata = fs.statSync(file); + if (!metadata.isFile()) throw new Error('Listener production Account lifecycle file is missing from the image'); +} +process.stdout.write('Listener production Account image contract is present.\n'); diff --git a/ops/listener-identity-staging/README.md b/ops/listener-identity-staging/README.md new file mode 100644 index 00000000..43509066 --- /dev/null +++ b/ops/listener-identity-staging/README.md @@ -0,0 +1,164 @@ +# Listener identity staging + +This stack replaces the disposable `listener-ui-dev` process behind +`earlybirds-staging.harmonicbeacon.com` with an immutable, exact-SHA Listener +and a dedicated PostgreSQL database. It does not share the production Listener +database, volume or database network. + +## Frozen topology + +- Compose project: `listener-identity-staging` +- App: `listener-identity-staging-app`, loopback `127.0.0.1:13001` +- PostgreSQL: `listener-identity-staging-postgres`, no published port +- Database network: `listener_identity_staging_database`, internal +- Database volume: `listener-identity-staging-postgres` +- Egress network: `listener_identity_staging_egress`, app only +- Existing control-plane networks: `earlybirds_stream_control_internal` and + `earlybirds_authority_private`, app only +- Image: `harmonic-beacon/listener-identity-staging:` +- Protected files: + - `/etc/harmonic-beacon/listener-identity-staging.deploy.env` + - `/etc/harmonic-beacon/listener-identity-staging.env` + - `/etc/harmonic-beacon/listener-identity-staging-database.env` +- Approved intros are selected only by the two exact container paths in + `app.env.example`. Their immutable hashes are pinned in + `intro-artifacts.sha256` and verified on the host and inside the read-only + `/media/artifacts` mount before acceptance. No audio is copied, transcoded or + changed by this lifecycle. + +The active Nginx vhost may predate the three exact Account RP routes. The +lifecycle therefore backs up the active staging vhost, installs the reviewed +version from +`ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template`, +checks its SHA-256, runs `nginx -t`, reloads only Nginx and restores the prior +file automatically if validation, reload or edge smoke fails. The template +sends this hostname to loopback port `13001`; production Listener remains on +`13000`. Account staging is prepared as confidential client +`hb-listener-staging`. It defaults off; a reviewed second cutover may set +`BEACON_LISTENER_ACCOUNT_ENABLED=1` only after the staging authority, mail and +RP secrets pass their independent gates. Production Account secrets are always +forbidden in the staging environment. + +The canonical membership authority remains an external service, but this stack +does not reuse its production Listener credential. Before Free/membership +acceptance, provision the dedicated key ID `listener-identity-staging-v1` in +`pmp-myth-api` with a unique outbound token for membership read and invitation +redemption. Provision a second, distinct token under the same ID for authenticated +membership projections into the unique private alias +`http://listener-identity-staging:3000`. Paid-provider flags remain off. The +root-owned application env carries only those staging tokens; copying either +production Listener token is forbidden. Infrastructure health can pass before +this supervised authority seam is exercised, but Free/quota acceptance cannot +be claimed until both directions have been tested. + +## Prepare + +Use a clean checkout at the exact reviewed commit. Install root-owned copies +of the three example files and replace every placeholder with staging-only +values. Do not copy values from Listener production or Account production. + +```bash +sudo install -d -o root -g root -m 0755 /etc/harmonic-beacon +sudo install -o root -g root -m 0600 \ + ops/listener-identity-staging/deploy.env.example \ + /etc/harmonic-beacon/listener-identity-staging.deploy.env +sudo install -o root -g root -m 0600 \ + ops/listener-identity-staging/app.env.example \ + /etc/harmonic-beacon/listener-identity-staging.env +sudo install -o root -g root -m 0600 \ + ops/listener-identity-staging/database.env.example \ + /etc/harmonic-beacon/listener-identity-staging-database.env +``` + +Set image tag and Git SHA to the same lowercase 40-character commit, build time +to UTC ISO-8601 and schema version to the newest reviewed migration. Listener +itself is enabled for public staging; leave Account off for the first cutover, +and always leave Free For All, payments, withdrawal and test access off. Mona +intentionally has no host Node runtime. +The lifecycle builds the exact reviewed image, verifies its embedded SHA, and +uses that image in a networkless/read-only container to validate all three +root-owned files before it starts PostgreSQL or changes runtime state. + +For an existing installation whose intro keys are empty, install the exact +reviewed paths atomically before the candidate rollout. This command validates +the already-mounted files and their pinned hashes; it never receives an +operator-supplied filename and does not restart the runtime: + +```bash +sudo scripts/listener-identity-staging/configure-intros.sh \ + /etc/harmonic-beacon/listener-identity-staging.deploy.env +``` + +The subsequent `start.sh` captures the prior running container's ES/EN values +before cutover, so an automatic or explicit rollback restores them before +recreating the previous image. + +Confirm `earlybirds_stream_control_internal` and +`earlybirds_authority_private` are internal bridges. Capture the current IDs of +`earlybirds-preview-listener-1`, `earlybirds-preview-postgres-1` and all event +containers. The lifecycle script repeats and compares those fingerprints. + +## First cutover + +Port `13001` is currently owned by the disposable staging-only +`listener-ui-dev`. Keep that container for rollback. The lifecycle script leaves +it serving throughout validation, image build, database startup and backup; it +stops it only at the final reversible boundary immediately before starting the +new app. Any subsequent failure restores it automatically. Do not stop or +recreate the accepted production Listener on `13000`. + +```bash +sudo docker inspect listener-ui-dev --format '{{.Id}} {{.Config.Image}} {{.State.Status}}' +sudo scripts/listener-identity-staging/start.sh \ + /etc/harmonic-beacon/listener-identity-staging.deploy.env +``` + +The start script builds one exact-SHA image, starts only the dedicated database, +creates and verifies a pre-migration custom-format backup, stops the retained +disposable staging process, runs `prisma migrate deploy`, then starts the app +only if migration exits zero. It verifies image and health provenance and proves +protected production/event container IDs did not change. Only after the local +app is healthy does it install the reviewed staging vhost, validate and reload +Nginx, then exercise the public edge. The exact Account login, callback and +front-channel logout routes are unlogged; unknown Account suffixes fail closed. + +After cutover, verify from the host and externally: + +```bash +sudo scripts/listener-identity-staging/health-smoke.sh \ + /etc/harmonic-beacon/listener-identity-staging.deploy.env +sudo scripts/listener-identity-staging/edge-smoke.sh \ + /etc/harmonic-beacon/listener-identity-staging.deploy.env +``` + +The public response must report the exact SHA. Readiness must report the +dedicated database and Listener runtime healthy. With Account off it must omit +the Account check; with Account on it must report `listenerAccount=ok`. The edge +smoke verifies either the fail-closed off behavior or an exact redirect to the +staging issuer, while unknown suffixes remain 404 and synthetic callback values +never appear in Nginx access logs. Google, Account, payments and synthetic +login are separate supervised acceptance gates; never turn them on merely to +make this infrastructure smoke pass. + +## Rollback + +Rollback restores the backed-up staging vhost before changing the staging app +and atomically restores the prior accepted Account enablement flag before it +recreates the previous immutable app. It also restores the exact ES/EN intro +paths observed in the previously healthy container, so enabling the mounted +intros is independently reversible. Secrets remain in the protected app env +and are never copied into rollback state. Rollback never downgrades the +database. On the first cutover, with no previous +immutable staging image, it stops the new app and restarts the retained +`listener-ui-dev` container. + +```bash +sudo scripts/listener-identity-staging/rollback.sh \ + /etc/harmonic-beacon/listener-identity-staging.deploy.env +``` + +The pre-migration backup path is recorded under +`/var/lib/harmonic-beacon/listener-identity-staging/last-backup`. Restoring data +is a separate explicit staging maintenance operation after stopping only the +new staging app. Never run `compose down -v`, remove the dedicated volume, reuse +`earlybirds_preview_db_internal`, or point these scripts at port `13000`. diff --git a/ops/listener-identity-staging/app.env.example b/ops/listener-identity-staging/app.env.example new file mode 100644 index 00000000..3f7912b0 --- /dev/null +++ b/ops/listener-identity-staging/app.env.example @@ -0,0 +1,55 @@ +# Root-only Listener staging runtime. Account defaults OFF. A reviewed cutover +# may flip only BEACON_LISTENER_ACCOUNT_ENABLED after installing the two +# staging-only secrets and proving the Account staging authority healthy. +NODE_ENV=production +DATABASE_URL=postgresql://listener_identity_staging:replace-listener-identity-staging-database-password@listener-identity-staging-postgres:5432/listener_identity_staging?schema=public + +BEACON_LISTENER_ENABLED=1 +BEACON_LISTENER_FREE_FOR_ALL=0 +BEACON_LISTENER_AUTH_BASE_URL=https://earlybirds-staging.harmonicbeacon.com +BEACON_LISTENER_TRUSTED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com +BEACON_LISTENER_AUTH_SECRET=replace-listener-staging-auth-secret-at-least-32-characters +BEACON_LISTENER_GOOGLE_CLIENT_ID= +BEACON_LISTENER_GOOGLE_CLIENT_SECRET= +BEACON_LISTENER_APPLE_ENABLED=0 +BEACON_LISTENER_APPLE_CLIENT_ID= +BEACON_LISTENER_APPLE_CLIENT_SECRET= +BEACON_LISTENER_TEST_ACCESS_ENABLED=0 +BEACON_LISTENER_TEST_LOGIN_SECRET=replace-listener-staging-test-secret-at-least-32-characters +BEACON_LISTENER_STAGING_TEAM_ENTRY_ENABLED=0 +BEACON_LISTENER_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com + +BEACON_LISTENER_ACCOUNT_ENABLED=0 +BEACON_LISTENER_ACCOUNT_ENVIRONMENT=staging +BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING= +BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING= + +EARLY_BIRDS_AUTHORITY_BASE_URL=http://pmp-myth-api:8765 +EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID=listener-identity-staging-v1 +EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN=replace-listener-staging-authority-token-at-least-43-characters +EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID=listener-identity-staging-v1 +EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT=replace-listener-staging-beacon-token-at-least-43-characters +EARLY_BIRDS_STREAM_ORIGIN=https://stream.harmonicbeacon.com +EARLY_BIRDS_STREAM_CONTROL_ORIGIN=http://beacon-stream:8080 +EARLY_BIRDS_STREAM_ARTIFACT_ID=beacon-luz-20260624-2hs-aac320-v2 +EARLY_BIRDS_STREAM_SIGNING_SECRET=replace-listener-staging-stream-secret-at-least-32-characters +EARLY_BIRDS_DEVICE_PEPPER=replace-listener-staging-device-pepper-at-least-32-characters +EARLY_BIRDS_DROPIN_ES_PATH=/media/artifacts/drop-ins/amara-sol-es-r2-approved-aac320-v1.m4a +EARLY_BIRDS_DROPIN_EN_PATH=/media/artifacts/drop-ins/amara-sol-en-r2-approved-aac320-v1.m4a + +EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL= +EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN= +EARLY_BIRDS_MAGIC_LINK_RATE_SECRET= +BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED=0 +LISTENER_WITHDRAWAL_ENABLED=0 +LISTENER_WITHDRAWAL_SECRET= +BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED=0 +BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED=0 +BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=0 +BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED=0 +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED=0 +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID= +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER= +BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET= +BEACON_LISTENER_GEOIP_DB_PATH=/data/geoip/dbip-country-lite.mmdb +TRUSTED_PROXY_HOPS=1 diff --git a/ops/listener-identity-staging/compose.yml b/ops/listener-identity-staging/compose.yml new file mode 100644 index 00000000..3afd68f0 --- /dev/null +++ b/ops/listener-identity-staging/compose.yml @@ -0,0 +1,95 @@ +name: listener-identity-staging + +services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + container_name: listener-identity-staging-postgres + restart: unless-stopped + env_file: + - ${LISTENER_IDENTITY_STAGING_DATABASE_ENV_FILE:?set_root_owned_database_env_file} + volumes: + - listener-identity-staging-postgres:/var/lib/postgresql/data + networks: + database: + aliases: [listener-identity-staging-postgres] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 3s + retries: 12 + security_opt: [no-new-privileges:true] + logging: &listener_staging_logging + driver: json-file + options: { max-size: 10m, max-file: "3" } + + migrate: + image: harmonic-beacon/listener-identity-staging:${LISTENER_IDENTITY_STAGING_IMAGE_TAG:?exact_sha40_required} + container_name: listener-identity-staging-migrate + restart: "no" + command: ["npx", "prisma", "migrate", "deploy"] + env_file: + - ${LISTENER_IDENTITY_STAGING_APP_ENV_FILE:?set_root_owned_app_env_file} + networks: [database] + depends_on: + postgres: { condition: service_healthy } + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + tmpfs: ["/tmp:size=32m,mode=1777"] + logging: *listener_staging_logging + + app: + image: harmonic-beacon/listener-identity-staging:${LISTENER_IDENTITY_STAGING_IMAGE_TAG:?exact_sha40_required} + build: + context: ../.. + dockerfile: Dockerfile + target: runner + args: + NEXT_PUBLIC_LIVEKIT_URL: https://livekit.example.invalid + BEACON_GIT_SHA: ${LISTENER_IDENTITY_STAGING_GIT_SHA:?exact_sha40_required} + BEACON_BUILD_TIME: ${LISTENER_IDENTITY_STAGING_BUILD_TIME:?iso_timestamp_required} + BEACON_DATABASE_SCHEMA_VERSION: ${LISTENER_IDENTITY_STAGING_SCHEMA_VERSION:?migration_name_required} + container_name: listener-identity-staging-app + restart: unless-stopped + init: true + env_file: + - ${LISTENER_IDENTITY_STAGING_APP_ENV_FILE:?set_root_owned_app_env_file} + ports: + - "127.0.0.1:${LISTENER_IDENTITY_STAGING_APP_PORT:-13001}:3000" + volumes: + - ${BEACON_STREAM_ARTIFACTS_HOST_PATH:?set_root_owned_artifact_path}:/media/artifacts:ro + - ${BEACON_LISTENER_GEOIP_HOST_PATH:?set_reviewed_geoip_path}:/data/geoip/dbip-country-lite.mmdb:ro + networks: + database: {} + app_egress: {} + stream_control: {} + authority_private: + aliases: [listener-identity-staging] + depends_on: + migrate: { condition: service_completed_successfully } + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:3000/api/health/ready"] + interval: 15s + timeout: 5s + retries: 12 + start_period: 20s + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + tmpfs: ["/tmp:size=64m,mode=1777"] + logging: *listener_staging_logging + +networks: + database: + name: listener_identity_staging_database + internal: true + app_egress: + name: listener_identity_staging_egress + stream_control: + external: true + name: earlybirds_stream_control_internal + authority_private: + external: true + name: earlybirds_authority_private + +volumes: + listener-identity-staging-postgres: + name: listener-identity-staging-postgres diff --git a/ops/listener-identity-staging/database.env.example b/ops/listener-identity-staging/database.env.example new file mode 100644 index 00000000..0bc6bb01 --- /dev/null +++ b/ops/listener-identity-staging/database.env.example @@ -0,0 +1,5 @@ +# Root-only staging database bootstrap. Values must match DATABASE_URL in the +# application environment. Never reuse preview or production credentials. +POSTGRES_USER=listener_identity_staging +POSTGRES_PASSWORD=replace-listener-identity-staging-database-password +POSTGRES_DB=listener_identity_staging diff --git a/ops/listener-identity-staging/deploy.env.example b/ops/listener-identity-staging/deploy.env.example new file mode 100644 index 00000000..2529dd48 --- /dev/null +++ b/ops/listener-identity-staging/deploy.env.example @@ -0,0 +1,14 @@ +# Non-secret deployment coordinates. Install a root:root 0600 copy at +# /etc/harmonic-beacon/listener-identity-staging.deploy.env. +COMPOSE_PROJECT_NAME=listener-identity-staging +LISTENER_IDENTITY_STAGING_IMAGE_TAG=0000000000000000000000000000000000000000 +LISTENER_IDENTITY_STAGING_GIT_SHA=0000000000000000000000000000000000000000 +LISTENER_IDENTITY_STAGING_BUILD_TIME=2026-08-18T00:00:00Z +LISTENER_IDENTITY_STAGING_SCHEMA_VERSION=20260818010000_beacon_account_authority +LISTENER_IDENTITY_STAGING_APP_PORT=13001 +LISTENER_IDENTITY_STAGING_APP_ENV_FILE=/etc/harmonic-beacon/listener-identity-staging.env +LISTENER_IDENTITY_STAGING_DATABASE_ENV_FILE=/etc/harmonic-beacon/listener-identity-staging-database.env +LISTENER_IDENTITY_STAGING_BACKUP_DIR=/mnt/beacon-data/listener-identity-staging/backups +LISTENER_IDENTITY_STAGING_STATE_DIR=/var/lib/harmonic-beacon/listener-identity-staging +BEACON_STREAM_ARTIFACTS_HOST_PATH=/mnt/beacon-data/listener/artifacts +BEACON_LISTENER_GEOIP_HOST_PATH=/mnt/beacon-data/listener/geoip/dbip-country-lite-2026-07.mmdb diff --git a/ops/listener-identity-staging/intro-artifacts.sha256 b/ops/listener-identity-staging/intro-artifacts.sha256 new file mode 100644 index 00000000..63f5cf1d --- /dev/null +++ b/ops/listener-identity-staging/intro-artifacts.sha256 @@ -0,0 +1,2 @@ +86ce75249b506277651e632a671787827ddfc394a9777c56d9f3987d4fb7cd59 drop-ins/amara-sol-en-r2-approved-aac320-v1.m4a +4d4b0ecf472a8a1d50468d2e673521b2974c7989d3c6dabe43705e1b68007c5d drop-ins/amara-sol-es-r2-approved-aac320-v1.m4a diff --git a/ops/listener-identity-staging/package.json b/ops/listener-identity-staging/package.json new file mode 100644 index 00000000..2496c417 --- /dev/null +++ b/ops/listener-identity-staging/package.json @@ -0,0 +1,10 @@ +{ + "name": "harmonic-beacon-listener-identity-staging", + "private": true, + "type": "module", + "scripts": { + "check": "node --check validate.mjs && sh -n ../../scripts/listener-identity-staging/lib.sh ../../scripts/listener-identity-staging/start.sh ../../scripts/listener-identity-staging/health-smoke.sh ../../scripts/listener-identity-staging/edge-smoke.sh ../../scripts/listener-identity-staging/rollback.sh ../../scripts/listener-identity-staging/configure-intros.sh", + "test": "node --test test/*.test.mjs", + "validate:example": "node validate.mjs --allow-placeholders deploy.env.example app.env.example database.env.example" + } +} diff --git a/ops/listener-identity-staging/test/contract.test.mjs b/ops/listener-identity-staging/test/contract.test.mjs new file mode 100644 index 00000000..10363968 --- /dev/null +++ b/ops/listener-identity-staging/test/contract.test.mjs @@ -0,0 +1,297 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +import { validateFiles, validateSharedStreamSecret } from '../validate.mjs'; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const REPO = path.resolve(ROOT, '../..'); +const DEPLOY = path.join(ROOT, 'deploy.env.example'); +const APP = path.join(ROOT, 'app.env.example'); +const DATABASE = path.join(ROOT, 'database.env.example'); + +function source(file) { + return fs.readFileSync(file, 'utf8'); +} + +function mutated(file, from, to) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'listener-identity-staging-')); + const target = path.join(directory, path.basename(file)); + const contents = source(file); + assert.ok(contents.includes(from), `fixture must contain ${from}`); + fs.writeFileSync(target, contents.replace(from, to)); + return target; +} + +test('example contract is internally consistent and explicitly placeholder-only', () => { + assert.doesNotThrow(() => validateFiles(DEPLOY, APP, DATABASE, true)); + assert.throws(() => validateFiles(DEPLOY, APP, DATABASE), /placeholder/); +}); + +test('shared preview stream secret accepts only the deployed random synthetic form', () => { + const strongSharedSecret = `synthetic-${'a1'.repeat(32)}`; + assert.equal(validateSharedStreamSecret(strongSharedSecret), strongSharedSecret); + assert.throws( + () => validateSharedStreamSecret('synthetic-preview-stream-signing-secret-at-least-32-characters'), + /64 random hex/, + ); + assert.throws(() => validateSharedStreamSecret(`synthetic-${'a'.repeat(63)}`), /64 random hex/); + assert.throws(() => validateSharedStreamSecret(`synthetic-${'z'.repeat(64)}`), /64 random hex/); + assert.throws( + () => validateSharedStreamSecret('replace-listener-staging-stream-secret-at-least-32-characters'), + /placeholder/, + ); +}); + +test('validator supports a reviewed Account staging cutover and rejects unsafe modes', () => { + const withClientSecret = mutated( + APP, + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING=', + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING=replace-staging-account-client-secret-at-least-32-characters', + ); + const withAccountSecrets = mutated( + withClientSecret, + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING=', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING=replace-staging-account-state-secret-at-least-32-characters', + ); + const accountEnabled = mutated( + withAccountSecrets, + 'BEACON_LISTENER_ACCOUNT_ENABLED=0', + 'BEACON_LISTENER_ACCOUNT_ENABLED=1', + ); + assert.doesNotThrow(() => validateFiles(DEPLOY, accountEnabled, DATABASE, true)); + const cases = [ + [DEPLOY, 'LISTENER_IDENTITY_STAGING_APP_PORT=13001', 'LISTENER_IDENTITY_STAGING_APP_PORT=13000', /must be 13001/], + [APP, '@listener-identity-staging-postgres:', '@earlybirds-preview-postgres:', /dedicated staging PostgreSQL/], + [APP, 'BEACON_LISTENER_ACCOUNT_ENABLED=0', 'BEACON_LISTENER_ACCOUNT_ENABLED=2', /must be 0 or 1/], + [APP, 'BEACON_LISTENER_FREE_FOR_ALL=0', 'BEACON_LISTENER_FREE_FOR_ALL=1', /must be 0/], + [APP, 'BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=0', 'BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED=1', /must be 0/], + ]; + for (const [file, from, to, message] of cases) { + const changed = mutated(file, from, to); + assert.throws(() => validateFiles( + file === DEPLOY ? changed : DEPLOY, + file === APP ? changed : APP, + file === DATABASE ? changed : DATABASE, + true, + ), message); + } + const withProductionSecret = mutated( + APP, + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING=', + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=forbidden-production-secret\nBEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING=', + ); + assert.throws(() => validateFiles(DEPLOY, withProductionSecret, DATABASE, true), /production secret must be absent/); + + const enabledWithoutSecrets = mutated( + APP, + 'BEACON_LISTENER_ACCOUNT_ENABLED=0', + 'BEACON_LISTENER_ACCOUNT_ENABLED=1', + ); + assert.throws( + () => validateFiles(DEPLOY, enabledWithoutSecrets, DATABASE, true), + /secrets are required when Account is enabled/, + ); +}); + +test('staging selects only the two approved intros from the mounted immutable artifact set', () => { + const app = source(APP); + const manifest = source(path.join(ROOT, 'intro-artifacts.sha256')); + assert.match(app, /EARLY_BIRDS_DROPIN_EN_PATH=\/media\/artifacts\/drop-ins\/amara-sol-en-r2-approved-aac320-v1\.m4a/); + assert.match(app, /EARLY_BIRDS_DROPIN_ES_PATH=\/media\/artifacts\/drop-ins\/amara-sol-es-r2-approved-aac320-v1\.m4a/); + assert.match(manifest, /^86ce75249b506277651e632a671787827ddfc394a9777c56d9f3987d4fb7cd59 drop-ins\/amara-sol-en-r2-approved-aac320-v1\.m4a$/m); + assert.match(manifest, /^4d4b0ecf472a8a1d50468d2e673521b2974c7989d3c6dabe43705e1b68007c5d drop-ins\/amara-sol-es-r2-approved-aac320-v1\.m4a$/m); + + const unapproved = mutated( + APP, + '/media/artifacts/drop-ins/amara-sol-en-r2-approved-aac320-v1.m4a', + '/media/artifacts/drop-ins/amara-sol-en-r2-candidate-aac320-v1.m4a', + ); + assert.throws( + () => validateFiles(DEPLOY, unapproved, DATABASE, true), + /must select its approved mounted intro artifact/, + ); + const sameLanguage = mutated( + APP, + '/media/artifacts/drop-ins/amara-sol-es-r2-approved-aac320-v1.m4a', + '/media/artifacts/drop-ins/amara-sol-en-r2-approved-aac320-v1.m4a', + ); + assert.throws( + () => validateFiles(DEPLOY, sameLanguage, DATABASE, true), + /must select its approved mounted intro artifact/, + ); +}); + +test('compose has a private database plane and exact immutable application boundary', () => { + const compose = source(path.join(ROOT, 'compose.yml')); + const dockerfile = source(path.join(REPO, 'Dockerfile')); + assert.match(compose, /postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777/); + assert.match(compose, /name: listener_identity_staging_database\n\s+internal: true/); + assert.match(compose, /name: listener-identity-staging-postgres/); + assert.match(compose, /127\.0\.0\.1:\$\{LISTENER_IDENTITY_STAGING_APP_PORT:-13001\}:3000/); + assert.match(compose, /harmonic-beacon\/listener-identity-staging:\$\{LISTENER_IDENTITY_STAGING_IMAGE_TAG:\?exact_sha40_required\}/); + assert.match(compose, /migrate:[\s\S]*command: \["npx", "prisma", "migrate", "deploy"\]/); + assert.match(compose, /depends_on:\n\s+migrate: \{ condition: service_completed_successfully \}/); + assert.match(compose, /earlybirds_stream_control_internal/); + assert.match(compose, /earlybirds_authority_private/); + assert.match(compose, /aliases: \[listener-identity-staging\]/); + const authorityAttachment = compose.match(/app:[\s\S]*?authority_private:\n\s+aliases: \[listener-identity-staging\]/); + assert.ok(authorityAttachment, 'only the app must advertise the dedicated authority callback alias'); + const beforeApp = compose.slice(0, compose.indexOf('\n app:')); + assert.doesNotMatch(beforeApp, /authority_private|listener-identity-staging\]/); + assert.doesNotMatch(compose, /earlybirds_preview_db_internal|127\.0\.0\.1:13000|livekit:|playlist|tapestry|commerce/); + assert.match(dockerfile, /ops\/listener-identity-staging\/intro-artifacts\.sha256/); +}); + +test('compose renders from examples without reading a production file', () => { + const result = spawnSync('docker', [ + 'compose', '--project-name', 'listener-identity-staging', + '--env-file', DEPLOY, '-f', path.join(ROOT, 'compose.yml'), 'config', '--format', 'json', + ], { + cwd: REPO, + env: { + ...process.env, + LISTENER_IDENTITY_STAGING_APP_ENV_FILE: APP, + LISTENER_IDENTITY_STAGING_DATABASE_ENV_FILE: DATABASE, + }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + const rendered = JSON.parse(result.stdout); + assert.deepEqual(rendered.services.migrate.tmpfs, ['/tmp:size=32m,mode=1777']); + assert.deepEqual(rendered.services.app.tmpfs, ['/tmp:size=64m,mode=1777']); +}); + +test('lifecycle is forward-only, provenance checked and scoped away from production', () => { + const lib = source(path.join(REPO, 'scripts/listener-identity-staging/lib.sh')); + const start = source(path.join(REPO, 'scripts/listener-identity-staging/start.sh')); + const smoke = source(path.join(REPO, 'scripts/listener-identity-staging/health-smoke.sh')); + const edge = source(path.join(REPO, 'scripts/listener-identity-staging/edge-smoke.sh')); + const rollback = source(path.join(REPO, 'scripts/listener-identity-staging/rollback.sh')); + const configureIntros = source(path.join(REPO, 'scripts/listener-identity-staging/configure-intros.sh')); + assert.match(start, /build app/); + assert.match(start, /listener_staging_validate_image/); + assert.ok(start.indexOf('listener_staging_validate_image') < start.indexOf('up -d postgres')); + assert.ok(start.indexOf('listener_staging_validate_image') < start.indexOf('listener_staging_capture_previous')); + assert.match(start, /up -d postgres/); + assert.match(start, /listener_staging_wait_postgres/); + assert.ok(start.indexOf('listener_staging_wait_postgres') < start.indexOf('listener_staging_backup')); + assert.match(lib, /listener_staging_wait_postgres\(\)/); + assert.match(lib, /listener-identity-staging-postgres/); + assert.match(start, /listener_staging_backup/); + assert.ok(start.indexOf('listener_staging_backup') < start.indexOf('docker stop listener-ui-dev')); + assert.match(start, /up -d app/); + assert.match(start, /protected_before=.*listener_staging_fingerprint_protected/); + assert.match(start, /test "\$protected_before" = "\$protected_after"/); + assert.match(lib, /image provenance does not match its immutable tag/); + assert.doesNotMatch(lib.match(/listener_staging_load\(\) \{[\s\S]*?\n\}/)?.[0] ?? '', /\bnode\b/); + assert.match(lib, /listener_staging_validate_image\(\)/); + assert.match(lib, /--user 0:0/); + assert.match(lib, /--network none/); + assert.match(lib, /--read-only/); + assert.match(lib, /--cap-drop ALL/); + assert.match(lib, /--security-opt no-new-privileges/); + assert.match(lib, /\/app\/ops\/listener-identity-staging\/validate\.mjs/); + assert.match(lib, /rm -f "\$LISTENER_IDENTITY_STAGING_STATE_DIR\/previous-image"/); + assert.match(lib, /previous-account-enabled/); + assert.match(lib, /previous-drop-ins/); + assert.match(lib, /listener_staging_intro_manifest/); + assert.match(lib, /approved intro artifact checksum mismatch/); + assert.match(lib, /accepted staging app has an invalid Account mode/); + assert.match(lib, /listener_staging_restore_account_enabled\(\)/); + assert.match(lib, /mv "\$temporary" "\$LISTENER_IDENTITY_STAGING_APP_ENV_FILE"/); + assert.match(lib, /test "\$running" = true && test "\$health" = healthy/); + assert.match(lib, /earlybirds-preview-listener-1 earlybirds-preview-postgres-1/); + assert.match(lib, /database network exists outside the reviewed project/); + assert.match(lib, /PostgreSQL volume exists outside the reviewed project/); + assert.match(smoke, /listener-identity-staging-migrate/); + assert.match(smoke, /listener_staging_account_enabled/); + assert.match(smoke, /\.checks\.listenerAccount == "ok"/); + assert.match(smoke, /account_origin=https:\/\/account-staging\.harmonicbeacon\.com/); + assert.match(smoke, /\$account_origin\/\.well-known\/openid-configuration/); + assert.match(smoke, /\.jwks_uri == \(\$issuer \+ "\/\.well-known\/jwks\.json"\)/); + assert.match(smoke, /\.code_challenge_methods_supported == \["S256"\]/); + assert.match(smoke, /Account staging JWKS has no usable verification key/); + assert.match(smoke, /has\("listenerAccount"\) \| not/); + assert.match(smoke, /\.checks\.listenerRuntime == "ok"/); + assert.match(smoke, /jq --exit-status/); + assert.match(edge, /jq --exit-status/); + assert.match(edge, /account-staging\\\.harmonicbeacon\\\.com/); + assert.match(edge, /Account-on front-channel logout accepted an unsigned request/); + assert.doesNotMatch(`${smoke}\n${edge}`, /\bnode\b/); + assert.match(start, /listener_staging_install_edge/); + assert.match(start, /edge-smoke\.sh/); + assert.match(lib, /nginx-previous\.conf/); + assert.match(lib, /nginx-previous\.sha256/); + assert.match(lib, /nginx-current\.sha256/); + assert.match(lib, /nginx -t/); + assert.match(lib, /systemctl reload nginx/); + assert.match(rollback, /listener_staging_restore_edge/); + assert.match(rollback, /listener_staging_restore_account_enabled/); + assert.match(rollback, /listener_staging_restore_drop_ins/); + assert.ok(rollback.indexOf('listener_staging_restore_account_enabled') < rollback.indexOf('compose up')); + assert.ok(rollback.indexOf('listener_staging_restore_drop_ins') < rollback.indexOf('compose up')); + assert.match(smoke, /sha256sum -cs \/app\/ops\/listener-identity-staging\/intro-artifacts\.sha256/); + assert.match(configureIntros, /listener_staging_assert_dependencies/); + assert.match(configureIntros, /manifest_entry es/); + assert.match(configureIntros, /manifest_entry en/); + assert.match(configureIntros, /mv "\$temporary" "\$LISTENER_IDENTITY_STAGING_APP_ENV_FILE"/); + assert.doesNotMatch(configureIntros, /docker (?:restart|stop|rm)|compose up|EARLY_BIRDS_DROPIN_.*\$2/); + assert.match(edge, /active staging vhost is not the reviewed template/); + assert.match(edge, /sentinel/); + assert.match(edge, /\/var\/log\/nginx/); + assert.doesNotMatch(`${lib}\n${start}\n${smoke}\n${rollback}`, /compose down|down -v|volume rm|docker (?:system )?prune|migrate reset|migrate down/); + assert.doesNotMatch(`${start}\n${rollback}`, /docker (?:stop|rm|restart) earlybirds-preview|docker (?:stop|rm|restart) beacon-/); + assert.doesNotMatch(rollback, /prisma|migrate/); +}); + +test('existing public staging vhost targets only the staging app and authority', () => { + const nginx = source(path.join(REPO, 'ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template')); + const targets = [...nginx.matchAll(/proxy_pass http:\/\/127\.0\.0\.1:(\d+);/g)].map((match) => match[1]); + assert.ok(targets.filter((port) => port === '13001').length >= 10); + assert.ok(targets.every((port) => ['13001', '18876'].includes(port))); + assert.doesNotMatch(nginx, /127\.0\.0\.1:13000/); + for (const route of ['login', 'callback', 'frontchannel-logout']) { + const exact = nginx.match(new RegExp(`location = /api/account/${route} \\{([\\s\\S]*?)\\n \\}`)); + assert.ok(exact, `missing exact Account route ${route}`); + assert.match(exact[1], /access_log off;/); + assert.match(exact[1], /proxy_pass http:\/\/127\.0\.0\.1:13001;/); + assert.match(exact[1], /Cache-Control "private, no-store"/); + assert.match(exact[1], /Referrer-Policy "no-referrer"/); + } + const wildcard = nginx.match(/location \^~ \/api\/account\/ \{([\s\S]*?)\n \}/); + assert.ok(wildcard, 'unknown Account suffixes need an explicit fail-closed prefix'); + assert.match(wildcard[1], /access_log off;/); + assert.match(wildcard[1], /return 404;/); + const membershipPages = [...nginx.matchAll(/location = \/listener\/membership \{([\s\S]*?)\n \}/g)]; + assert.equal(membershipPages.length, 2, 'HTTP and HTTPS membership pages must be exact'); + assert.ok(membershipPages.every((match) => ( + /request_method != GET/.test(match[1]) + && /access_log off;/.test(match[1]) + && /Cache-Control "private, no-store"/.test(match[1]) + && /Referrer-Policy "no-referrer"/.test(match[1]) + ))); + assert.match(membershipPages[1][1], /proxy_pass http:\/\/127\.0\.0\.1:13001;/); + assert.doesNotMatch(nginx, /location \^~ \/listener\/|location \/listener\/membership/); +}); + +test('authority seam uses a dedicated staging identity in both directions', () => { + const app = source(APP); + const runbook = source(path.join(ROOT, 'README.md')); + assert.match(app, /EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID=listener-identity-staging-v1/); + assert.match(app, /EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID=listener-identity-staging-v1/); + assert.match(runbook, /http:\/\/listener-identity-staging:3000/); + assert.match(runbook, /does not reuse its production Listener credential/); +}); + +test('shell entrypoints are executable and parse in POSIX sh', () => { + for (const name of ['lib.sh', 'start.sh', 'health-smoke.sh', 'edge-smoke.sh', 'rollback.sh', 'configure-intros.sh']) { + const file = path.join(REPO, 'scripts/listener-identity-staging', name); + assert.ok((fs.statSync(file).mode & 0o111) !== 0, `${name} must be executable`); + const parsed = spawnSync('sh', ['-n', file], { encoding: 'utf8' }); + assert.equal(parsed.status, 0, parsed.stderr); + } +}); diff --git a/ops/listener-identity-staging/validate.mjs b/ops/listener-identity-staging/validate.mjs new file mode 100755 index 00000000..ec05b5b0 --- /dev/null +++ b/ops/listener-identity-staging/validate.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../..'); +const SHA40 = /^[0-9a-f]{40}$/; +const MIGRATION = /^[0-9]{14}_[a-z0-9_]+$/; +const STAGING_ORIGIN = 'https://earlybirds-staging.harmonicbeacon.com'; +const INTRO_MANIFEST = path.join(ROOT, 'ops/listener-identity-staging/intro-artifacts.sha256'); + +function approvedIntroPaths() { + const entries = fs.readFileSync(INTRO_MANIFEST, 'utf8').trim().split('\n').map((line) => { + const match = line.match(/^([0-9a-f]{64}) (drop-ins\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.m4a)$/); + if (!match) throw new Error('intro artifact manifest is invalid'); + const language = match[2].match(/-([a-z]{2})-r[0-9]+-/)?.[1]; + if (language !== 'es' && language !== 'en') throw new Error('intro artifact language is invalid'); + return [language, `/media/artifacts/${match[2]}`]; + }); + const result = Object.fromEntries(entries); + if (entries.length !== 2 || Object.keys(result).length !== 2 || !result.es || !result.en) { + throw new Error('intro artifact manifest must contain exactly one ES and one EN artifact'); + } + return result; +} + +export function parseEnvFile(file) { + const result = new Map(); + for (const [index, raw] of fs.readFileSync(file, 'utf8').split(/\r?\n/).entries()) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const separator = line.indexOf('='); + if (separator < 1) throw new Error(`${path.basename(file)}:${index + 1}: invalid assignment`); + const key = line.slice(0, separator); + if (!/^[A-Z][A-Z0-9_]*$/.test(key) || result.has(key)) { + throw new Error(`${path.basename(file)}:${index + 1}: duplicate or invalid key`); + } + result.set(key, line.slice(separator + 1)); + } + return result; +} + +function required(env, key, minimum = 1) { + const value = env.get(key) ?? ''; + if (value.length < minimum) throw new Error(`${key} is missing or too short`); + return value; +} + +function exact(env, key, expected) { + if (required(env, key) !== expected) throw new Error(`${key} must be ${expected}`); +} + +function secret(env, key, minimum, allowPlaceholders) { + const value = required(env, key, minimum); + if (!allowPlaceholders && /^(?:replace|example|synthetic|changeme)(?:-|_|$)/i.test(value)) { + throw new Error(`${key} is still a placeholder`); + } + return value; +} + +export function validateSharedStreamSecret(value, allowPlaceholders = false) { + if (value.length < 32) throw new Error('EARLY_BIRDS_STREAM_SIGNING_SECRET is missing or too short'); + if (allowPlaceholders) return value; + if (value.startsWith('synthetic-')) { + if (!/^synthetic-[0-9a-f]{64}$/.test(value)) { + throw new Error('EARLY_BIRDS_STREAM_SIGNING_SECRET synthetic prefix requires 64 random hex characters'); + } + return value; + } + if (/^(?:replace|example|changeme)(?:-|_|$)/i.test(value)) { + throw new Error('EARLY_BIRDS_STREAM_SIGNING_SECRET is still a placeholder'); + } + return value; +} + +function exactRootPath(env, key, expected) { + const value = required(env, key); + if (value !== expected || !path.isAbsolute(value)) throw new Error(`${key} must be ${expected}`); +} + +function validateDeploy(env) { + exact(env, 'COMPOSE_PROJECT_NAME', 'listener-identity-staging'); + const tag = required(env, 'LISTENER_IDENTITY_STAGING_IMAGE_TAG'); + const sha = required(env, 'LISTENER_IDENTITY_STAGING_GIT_SHA'); + if (!SHA40.test(tag) || tag !== sha) throw new Error('image tag and git SHA must be the same lowercase sha40'); + const builtAt = required(env, 'LISTENER_IDENTITY_STAGING_BUILD_TIME'); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(builtAt) || !Number.isFinite(Date.parse(builtAt))) { + throw new Error('LISTENER_IDENTITY_STAGING_BUILD_TIME must be an ISO UTC timestamp'); + } + const schema = required(env, 'LISTENER_IDENTITY_STAGING_SCHEMA_VERSION'); + if (!MIGRATION.test(schema) || !fs.existsSync(path.join(ROOT, 'prisma/migrations', schema, 'migration.sql'))) { + throw new Error('schema version must name a checked-in migration'); + } + exact(env, 'LISTENER_IDENTITY_STAGING_APP_PORT', '13001'); + exactRootPath(env, 'LISTENER_IDENTITY_STAGING_APP_ENV_FILE', '/etc/harmonic-beacon/listener-identity-staging.env'); + exactRootPath(env, 'LISTENER_IDENTITY_STAGING_DATABASE_ENV_FILE', '/etc/harmonic-beacon/listener-identity-staging-database.env'); + exactRootPath(env, 'LISTENER_IDENTITY_STAGING_BACKUP_DIR', '/mnt/beacon-data/listener-identity-staging/backups'); + exactRootPath(env, 'LISTENER_IDENTITY_STAGING_STATE_DIR', '/var/lib/harmonic-beacon/listener-identity-staging'); + const artifacts = required(env, 'BEACON_STREAM_ARTIFACTS_HOST_PATH'); + if (!path.isAbsolute(artifacts) || artifacts === '/') throw new Error('artifact path must be a bounded absolute path'); + const geoip = required(env, 'BEACON_LISTENER_GEOIP_HOST_PATH'); + if (!path.isAbsolute(geoip) || !geoip.endsWith('/dbip-country-lite-2026-07.mmdb')) { + throw new Error('GeoIP path must be the reviewed July 2026 country database'); + } +} + +function validateDatabase(env, allowPlaceholders) { + exact(env, 'POSTGRES_USER', 'listener_identity_staging'); + exact(env, 'POSTGRES_DB', 'listener_identity_staging'); + return secret(env, 'POSTGRES_PASSWORD', 24, allowPlaceholders); +} + +function validatePair(env, first, second, label) { + const a = env.get(first) ?? ''; + const b = env.get(second) ?? ''; + if (Boolean(a) !== Boolean(b)) throw new Error(`${label} values must be configured together`); + return [a, b]; +} + +function validateApplication(env, databasePassword, allowPlaceholders) { + exact(env, 'NODE_ENV', 'production'); + const database = new URL(required(env, 'DATABASE_URL')); + if (!['postgres:', 'postgresql:'].includes(database.protocol) || + database.hostname !== 'listener-identity-staging-postgres' || + database.pathname !== '/listener_identity_staging' || + decodeURIComponent(database.username) !== 'listener_identity_staging' || + decodeURIComponent(database.password) !== databasePassword || + database.searchParams.get('schema') !== 'public') { + throw new Error('DATABASE_URL must use only the dedicated staging PostgreSQL service'); + } + + exact(env, 'BEACON_LISTENER_ENABLED', '1'); + exact(env, 'BEACON_LISTENER_FREE_FOR_ALL', '0'); + exact(env, 'BEACON_LISTENER_AUTH_BASE_URL', STAGING_ORIGIN); + exact(env, 'BEACON_LISTENER_TRUSTED_ORIGINS', STAGING_ORIGIN); + secret(env, 'BEACON_LISTENER_AUTH_SECRET', 32, allowPlaceholders); + validatePair(env, 'BEACON_LISTENER_GOOGLE_CLIENT_ID', 'BEACON_LISTENER_GOOGLE_CLIENT_SECRET', 'Google OAuth'); + exact(env, 'BEACON_LISTENER_APPLE_ENABLED', '0'); + const apple = validatePair(env, 'BEACON_LISTENER_APPLE_CLIENT_ID', 'BEACON_LISTENER_APPLE_CLIENT_SECRET', 'Apple OAuth'); + if (apple.some(Boolean)) throw new Error('Apple must remain empty while its staging provider is disabled'); + exact(env, 'BEACON_LISTENER_TEST_ACCESS_ENABLED', '0'); + secret(env, 'BEACON_LISTENER_TEST_LOGIN_SECRET', 32, allowPlaceholders); + exact(env, 'BEACON_LISTENER_STAGING_TEAM_ENTRY_ENABLED', '0'); + exact(env, 'BEACON_LISTENER_STAGING_TEAM_ENTRY_HOSTS', 'earlybirds-staging.harmonicbeacon.com'); + + const accountEnabled = required(env, 'BEACON_LISTENER_ACCOUNT_ENABLED'); + if (!['0', '1'].includes(accountEnabled)) { + throw new Error('BEACON_LISTENER_ACCOUNT_ENABLED must be 0 or 1'); + } + exact(env, 'BEACON_LISTENER_ACCOUNT_ENVIRONMENT', 'staging'); + for (const forbidden of [ + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', + ]) { + if (env.has(forbidden)) throw new Error(`${forbidden} production secret must be absent from staging`); + } + const account = validatePair( + env, + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING', + 'staging Account RP', + ); + if (accountEnabled === '1' && account.some((value) => !value)) { + throw new Error('staging Account RP secrets are required when Account is enabled'); + } + if (account.some((value) => value && value.length < 32) || (account[0] && account[0] === account[1])) { + throw new Error('staging Account RP secrets must be distinct and at least 32 characters'); + } + if (!allowPlaceholders) account.filter(Boolean).forEach((value) => { + if (/^(?:replace|example|synthetic|changeme)(?:-|_|$)/i.test(value)) { + throw new Error('staging Account RP secret is still a placeholder'); + } + }); + + exact(env, 'EARLY_BIRDS_AUTHORITY_BASE_URL', 'http://pmp-myth-api:8765'); + exact(env, 'EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID', 'listener-identity-staging-v1'); + const authorityToken = secret(env, 'EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN', 43, allowPlaceholders); + exact(env, 'EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID', 'listener-identity-staging-v1'); + const inboundToken = secret(env, 'EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT', 43, allowPlaceholders); + if (authorityToken === inboundToken) throw new Error('outbound and inbound authority tokens must differ'); + exact(env, 'EARLY_BIRDS_STREAM_ORIGIN', 'https://stream.harmonicbeacon.com'); + exact(env, 'EARLY_BIRDS_STREAM_CONTROL_ORIGIN', 'http://beacon-stream:8080'); + if (![ + 'beacon-luz-20260624-aac320-v1', + 'beacon-luz-20260624-2hs-aac320-v2', + ].includes(required(env, 'EARLY_BIRDS_STREAM_ARTIFACT_ID'))) { + throw new Error('stream artifact is not an approved Listener artifact'); + } + validateSharedStreamSecret(required(env, 'EARLY_BIRDS_STREAM_SIGNING_SECRET'), allowPlaceholders); + secret(env, 'EARLY_BIRDS_DEVICE_PEPPER', 32, allowPlaceholders); + const intros = approvedIntroPaths(); + for (const [key, language] of [ + ['EARLY_BIRDS_DROPIN_ES_PATH', 'es'], + ['EARLY_BIRDS_DROPIN_EN_PATH', 'en'], + ]) { + if (required(env, key) !== intros[language]) { + throw new Error(`${key} must select its approved mounted intro artifact`); + } + } + exact(env, 'BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED', '0'); + exact(env, 'LISTENER_WITHDRAWAL_ENABLED', '0'); + exact(env, 'BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED', '0'); + exact(env, 'BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED', '0'); + exact(env, 'BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED', '0'); + exact(env, 'BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED', '0'); + exact(env, 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED', '0'); + for (const key of [ + 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID', + 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER', + 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET', + ]) { + if (env.get(key)) throw new Error(`${key} must be empty`); + } + exact(env, 'BEACON_LISTENER_GEOIP_DB_PATH', '/data/geoip/dbip-country-lite.mmdb'); + exact(env, 'TRUSTED_PROXY_HOPS', '1'); +} + +export function validateFiles(deployFile, appFile, databaseFile, allowPlaceholders = false) { + const deploy = parseEnvFile(deployFile); + const app = parseEnvFile(appFile); + const database = parseEnvFile(databaseFile); + validateDeploy(deploy); + const password = validateDatabase(database, allowPlaceholders); + validateApplication(app, password, allowPlaceholders); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) { + const args = process.argv.slice(2); + const allowPlaceholders = args[0] === '--allow-placeholders'; + const offset = allowPlaceholders ? 1 : 0; + const deployFile = args[offset]; + const appFile = args[offset + 1]; + const databaseFile = args[offset + 2]; + if (!deployFile || !appFile || !databaseFile || args.length !== offset + 3) { + throw new Error('usage: validate.mjs [--allow-placeholders] DEPLOY_ENV APP_ENV DATABASE_ENV'); + } + validateFiles(deployFile, appFile, databaseFile, allowPlaceholders); +} diff --git a/package-lock.json b/package-lock.json index 57ab0795..21d91984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,14 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "@better-auth/oauth-provider": "1.6.30", + "@maxmind/geoip2-node": "^7.1.0", "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", + "better-auth": "1.6.30", "dotenv": "^17.2.4", + "hls.js": "1.6.17", + "jose": "6.1.3", "livekit-client": "^2.17.0", "livekit-server-sdk": "^2.15.0", "next": "16.2.12", @@ -21,12 +26,13 @@ "react": "19.2.3", "react-dom": "19.2.3", "sonner": "^2.0.7", - "tsx": "^4.21.0" + "tsx": "^4.23.8" }, "devDependencies": { "@axe-core/playwright": "4.12.1", "@playwright/test": "1.61.0", "@tailwindcss/postcss": "^4", + "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -34,7 +40,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9", "eslint-config-next": "16.2.12", "husky": "^9.1.7", @@ -42,7 +48,7 @@ "lint-staged": "^16.2.7", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^4.0.18" + "vitest": "^4.1.10" } }, "node_modules/@acemir/cssom": { @@ -400,6 +406,157 @@ "node": ">=18" } }, + "node_modules/@better-auth/core": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.0.tgz", + "integrity": "sha512-tUYocrJx6vYyf0k9CTAgAYtUFFRZIrcwNIhG3+WSUQGTPrcyl/hbo29Y9ynF2LwOZOwIwWCiis1A6Iqej4jC/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.41.1", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.4.0", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.30.tgz", + "integrity": "sha512-k8cRfUViD++xzK51MmzvtcTZCurss8Oq4iheak6sqlMbqgRgmaeD85M+HXitOlZAb27uLz6gsMh9fcQwW0E8aA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.30", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.30.tgz", + "integrity": "sha512-4+1vFYBnUYW4JUnGAbHOmRs4No2/bFWXkC1Rge//S0yeWGVD+Mm4oHSn5esVYry/PaXzmPbk1H1xu6Kb9ZRvaQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.30", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.30.tgz", + "integrity": "sha512-lHmx6Geyn6YY2YmnTNm1WqUdMRNb875l35S1dBRh5/iAwcA8NAa+1nDU9C1yS4R7bXPPi6JbvFG4ragcbJhFNw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.30", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.30.tgz", + "integrity": "sha512-4vhNyz2WkrYd96oqRumEYTBukx2070XH6tq2mhDHZAeIT4WjIScZWlckZfwoEYY0QLn/N0Cn4cb+IDT39R3l3Q==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.30", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/oauth-provider": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/@better-auth/oauth-provider/-/oauth-provider-1.6.30.tgz", + "integrity": "sha512-hdEjmITIjicmGsdTl/aCaGlLlu/AiEls+1QLaS3CtXSpxKSZiod4dw91hs6BiSmtIwqWgNdpAxfrHGNT0MpJYw==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/core": "^1.6.30", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "better-auth": "^1.6.30", + "better-call": "1.4.0" + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.30.tgz", + "integrity": "sha512-C4XfyWHbO8oLj5R9ws5ZSrLQir6D995HPOzfgsElvnqr4LoPoqL5H2Aryt9XChvO/JhnyBg3puagMVPRF3Hamw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.30", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.30.tgz", + "integrity": "sha512-wvY+/rWsHsGHw7agr2VU7jasWtSIA6obsHjJGS4BwiZ82FRbM9Q1ngzDldhn3No0ZOlrJ82BGFtMQnNVMXpxrw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.30", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" + }, "node_modules/@bufbuild/protobuf": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", @@ -598,803 +755,387 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], + "node_modules/@exodus/bytes": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.12.0.tgz", + "integrity": "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ - "x64" + "arm64" ], - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ - "arm64" + "x64" ], - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "netbsd" + "darwin" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "license": "MIT", + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", "optional": true, "os": [ - "netbsd" + "freebsd" ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "openbsd" + "darwin" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "openbsd" + "darwin" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ - "arm64" + "arm" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "openharmony" + "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ - "x64" + "arm64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "sunos" + "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ - "arm64" + "ppc64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "win32" + "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ - "ia32" + "riscv64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "win32" + "linux" ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.12.0.tgz", - "integrity": "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-s390x": { @@ -1765,7 +1506,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1794,6 +1535,18 @@ "@bufbuild/protobuf": "^1.10.0" } }, + "node_modules/@maxmind/geoip2-node": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@maxmind/geoip2-node/-/geoip2-node-7.1.0.tgz", + "integrity": "sha512-tOXoITRIPLQdUPf9JEU5ZRsFjO1guKba7kkhoUnZUsDGpS6gX3xcJLQBQgeW4nXL6Ko0LACdbL1aAGY5sgPMmg==", + "license": "Apache-2.0", + "dependencies": { + "maxmind": "^5.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1951,6 +1704,30 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz", + "integrity": "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1999,6 +1776,25 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@playwright/test": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", @@ -2381,24 +2177,10 @@ } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -2407,12 +2189,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -2421,12 +2206,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -2435,26 +2223,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -2463,152 +2240,83 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ - "ppc64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ - "riscv64" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], @@ -2617,12 +2325,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], @@ -2631,12 +2342,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], @@ -2645,26 +2359,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -2673,12 +2376,15 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -2687,26 +2393,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -2715,21 +2410,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -2976,6 +2667,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -3030,7 +2781,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3051,7 +2801,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -3141,14 +2890,13 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -3237,7 +2985,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/dom-mediacapture-record": { @@ -3251,7 +2999,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/geojson": { @@ -3999,29 +3747,29 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.18", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.18", - "vitest": "4.0.18" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4041,32 +3789,39 @@ "source-map-js": "^1.2.1" } }, - "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "node_modules/@vitest/coverage-v8/node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "devOptional": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", - "dev": true, + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4075,7 +3830,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -4087,26 +3842,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", - "dev": true, + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "devOptional": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", - "dev": true, + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -4114,13 +3869,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", - "dev": true, + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4129,24 +3885,25 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", - "dev": true, + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", - "dev": true, + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4428,7 +4185,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -4442,9 +4199,9 @@ "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", "dev": true, "license": "MIT", "dependencies": { @@ -4515,23 +4272,186 @@ "node": ">= 0.4" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", - "integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", + "integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-auth": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.30.tgz", + "integrity": "sha512-+fmPXSZFE7MDmLcppkmzUGMtQxzZXsJbKi5rUunmIYtSQZIgNCZHNSQFwK/ywx10uczkzbtVkrHm75i4o7+gpQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.30", + "@better-auth/drizzle-adapter": "1.6.30", + "@better-auth/kysely-adapter": "1.6.30", + "@better-auth/memory-adapter": "1.6.30", + "@better-auth/mongo-adapter": "1.6.30", + "@better-auth/prisma-adapter": "1.6.30", + "@better-auth/telemetry": "1.6.30", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.4.0", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/core": { + "version": "1.6.30", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.30.tgz", + "integrity": "sha512-Xhe7D7Zdms8FaVbdT1brYqo8biZiMEF1GIzrelhXYP4skth52RjHrW5X9HyuwIm51rOd0dmXN9IL27UMIazpkg==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.4.0", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" } }, "node_modules/better-result": { @@ -4750,7 +4670,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -4887,7 +4807,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -5266,7 +5186,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5305,8 +5224,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "17.4.2", @@ -5544,10 +5462,10 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -5610,47 +5528,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6092,7 +5969,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -6128,7 +6005,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -6527,6 +6404,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -6729,6 +6607,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.6.17", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz", + "integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==", + "license": "Apache-2.0" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -7545,6 +7429,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.5", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz", + "integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8049,7 +7942,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8058,7 +7950,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -8115,6 +8007,20 @@ "node": ">= 0.4" } }, + "node_modules/maxmind": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.7.tgz", + "integrity": "sha512-+w637dwfv01MKjkrp4sKDBTEKHLPvWLYb647QTjiz3wG/teSemqudIKNShaS6eqZ7ffxC9oZlQQgIqY0rGojog==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "3.0.3", + "tiny-lru": "13.0.0" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -8192,6 +8098,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mmdb-lib": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.3.tgz", + "integrity": "sha512-xQPoBXcNjjHiOvOraFBKtA++uNWF6aCVHL9dRKFXEov8eI3QJwtgiw3qApsonFT5SpoqsEVISUTg3HIDs2DiXw==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8245,9 +8161,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -8262,6 +8178,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.1.tgz", + "integrity": "sha512-DNIX+HyFpo14fKGe0NsX9/aPzdKGiSZwX5xEMpDwQrDdo2iTiUYunOCCQLYwJrziKVe8ZOtVvcv0dEVI8lMx2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -8500,7 +8431,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -8766,13 +8697,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -8860,10 +8791,10 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "devOptional": true, "funding": [ { "type": "opencollective", @@ -8880,7 +8811,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8956,7 +8887,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8972,7 +8902,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -8983,7 +8912,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8996,8 +8924,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prisma": { "version": "7.9.1", @@ -9268,192 +9195,458 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, "engines": { - "node": ">=4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" + "xmlchars": "^2.2.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=v12.22.7" } }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/sdp": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz", + "integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", "license": "MIT", - "engines": { - "node": ">=10" + "bin": { + "sdp-verify": "checker.js" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "engines": { - "node": ">= 4" + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, - "license": "MIT" - }, - "node_modules/robust-predicates": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "license": "Unlicense" + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" }, - "bin": { - "rollup": "dist/bin/rollup" + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.1.0" + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { - "node": ">=0.4" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-push-apply": { + "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "isarray": "^2.0.5" + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -9462,16 +9655,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "is-regex": "^1.2.1" + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -9480,285 +9674,380 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { - "ret": "~0.5.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "devOptional": true, + "license": "ISC" }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", "dependencies": { - "xmlchars": "^2.2.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=v12.22.7" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, "license": "MIT" }, - "node_modules/sdp": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz", - "integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "devOptional": true, "license": "MIT" }, - "node_modules/sdp-transform": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", - "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "license": "MIT", - "bin": { - "sdp-verify": "checker.js" + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", + "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", - "license": "Apache-2.0", - "optional": true, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=20.9.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sharp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/shebang-regex": { + "node_modules/strip-indent": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "client-only": "0.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, "engines": { "node": ">= 0.4" }, @@ -9766,597 +10055,688 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, "engines": { - "node": ">=18" + "node": ">=6" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", + "node_modules/tiny-lru": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-13.0.0.tgz", + "integrity": "sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==", + "license": "BSD-3-Clause", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=14" } }, - "node_modules/sonner": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", - "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, "license": "MIT", - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + "engines": { + "node": ">=18" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=0.10.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "devOptional": true, + "license": "MIT", "engines": { - "node": ">= 10.x" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=14.0.0" } }, - "node_modules/stable-hash": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", - "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "dev": true, "license": "MIT" }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "license": "MIT" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" + "is-number": "^7.0.0" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" + "node": ">=8.0" } }, - "node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "tldts": "^7.0.5" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=16" } }, - "node_modules/string.prototype.includes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" + "punycode": "^2.3.1" }, "engines": { - "node": ">= 0.4" + "node": ">=20" } }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=18.12" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "node_modules/ts-debounce": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", + "integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==", + "license": "MIT" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "license": "MIT", "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "minimist": "^1.2.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bin": { + "json5": "lib/cli.js" } }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">= 0.4" + "node": ">=18.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } + "node": ">=18" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, - "license": "MIT" + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=18" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=18" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">=18" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=18" } }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, - "node_modules/tldts": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", - "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.23" - }, - "bin": { - "tldts": "bin/cli.js" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/tldts-core": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", - "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", - "dev": true, - "license": "MIT" + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=8.0" + "node": ">=18" } }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=20" + "node": ">=18" } }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" + "node": ">=18" } }, - "node_modules/ts-debounce": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", - "integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==", - "license": "MIT" + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, "bin": { - "tsx": "dist/cli.mjs" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" }, "optionalDependencies": { - "fsevents": "~2.3.3" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/type-check": { @@ -10635,18 +11015,17 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "devOptional": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -10662,9 +11041,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -10677,13 +11057,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -10709,63 +11092,293 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12.0.0" + "node": ">= 12.0.0" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -10781,12 +11394,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -10807,6 +11423,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -10815,21 +11437,18 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "node_modules/vitest/node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "devOptional": true, + "license": "MIT" }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", @@ -11000,7 +11619,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -11152,10 +11771,9 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 88a1e193..1ac7c068 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "next dev", "build": "prisma generate && next build --webpack", + "audit:production": "tsx scripts/audit-production-dependencies.ts", "start": "next start", "lint": "eslint", "prepare": "husky", @@ -17,6 +18,7 @@ "db:fixture:load": "node scripts/load-test-fixture.mjs", "event:stabilize": "tsx scripts/weekend-stabilize.ts", "contract:commerce:verify": "python3 scripts/verify-commerce-contract.py", + "contract:early-birds:verify": "python3 scripts/verify-early-bird-contracts.py", "commerce:reconcile": "tsx scripts/commerce-media-worker.ts", "db:studio": "prisma studio", "postinstall": "prisma generate", @@ -26,12 +28,20 @@ "test:e2e": "playwright test", "test:e2e:install": "playwright install chromium", "test:e2e:update-snapshots": "playwright test --update-snapshots", - "load:livekit": "node scripts/livekit-load-harness.mjs" + "load:livekit": "node scripts/livekit-load-harness.mjs", + "account:provision": "tsx scripts/provision-account-authority.ts", + "account:mail-outbox": "tsx scripts/process-account-mail-outbox.ts", + "account:mail-worker": "tsx scripts/process-account-mail-outbox.ts --watch" }, "dependencies": { + "@better-auth/oauth-provider": "1.6.30", + "@maxmind/geoip2-node": "^7.1.0", "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", + "better-auth": "1.6.30", "dotenv": "^17.2.4", + "hls.js": "1.6.17", + "jose": "6.1.3", "livekit-client": "^2.17.0", "livekit-server-sdk": "^2.15.0", "next": "16.2.12", @@ -40,7 +50,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "sonner": "^2.0.7", - "tsx": "^4.21.0" + "tsx": "^4.23.8" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -54,6 +64,7 @@ "@axe-core/playwright": "4.12.1", "@playwright/test": "1.61.0", "@tailwindcss/postcss": "^4", + "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -61,7 +72,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9", "eslint-config-next": "16.2.12", "husky": "^9.1.7", @@ -69,13 +80,15 @@ "lint-staged": "^16.2.7", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^4.0.18" + "vitest": "^4.1.10" }, "overrides": { + "@sveltejs/vite-plugin-svelte": "6.2.4", "playwright-core": "1.61.0", "next": { "postcss": "8.5.25", "sharp": "0.35.3" - } + }, + "picomatch": "4.0.5" } } diff --git a/playwright.config.ts b/playwright.config.ts index 192d02e7..d9fea516 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,6 +24,8 @@ const BASE_URL = process.env.E2E_BASE_URL ?? `http://localhost:${PORT}`; // talk to the same throwaway database. const DATABASE_URL = process.env.E2E_DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/beacon_test'; +const LISTENER_ACCOUNT_SWITCH_GATE = process.env.E2E_LISTENER_ACCOUNT_SWITCH_GATE === '1'; +const LISTENER_NETWORK_GATE = process.env.E2E_LISTENER_NETWORK_GATE === '1'; assertSafeFixtureDatabaseUrl(DATABASE_URL); if (!process.env.E2E_BASE_URL) { process.env.E2E_DATABASE_URL = DATABASE_URL; @@ -32,6 +34,7 @@ if (!process.env.E2E_BASE_URL) { /** Functional suites run once; responsive/visual suites run per width. */ const PER_WIDTH = /(responsive|visual)\.spec\.ts/; const MEDIA_CONTINUITY = /media-continuity\.spec\.ts/; +const LISTENER_NETWORK_RESILIENCE = /listener-network-resilience\.spec\.ts/; const WEBKIT_ATTENDEE_CONTINUITY = /attendee controls without capture/; export default defineConfig({ @@ -54,7 +57,11 @@ export default defineConfig({ baseURL: BASE_URL, locale: 'es-CR', timezoneId: 'America/Costa_Rica', - trace: 'retain-on-failure', + // The network gate serves hundreds of immutable media fragments. A + // retained trace embeds every response body and can itself exhaust + // Firefox/Playwright while reporting a failure. The gate has a bounded + // purpose-built diagnostic ring and screenshots instead. + trace: LISTENER_NETWORK_GATE ? 'off' : 'retain-on-failure', screenshot: 'only-on-failure', launchOptions: { // Deterministic fake mic/camera for media-continuity tests. @@ -73,7 +80,7 @@ export default defineConfig({ // not replace the physical Android check in the rehearsal sheet. name: 'android-chrome', use: { ...devices['Pixel 7'] }, - testMatch: MEDIA_CONTINUITY, + testMatch: [MEDIA_CONTINUITY, LISTENER_NETWORK_RESILIENCE], grepInvert: WEBKIT_ATTENDEE_CONTINUITY, }, { @@ -107,8 +114,8 @@ export default defineConfig({ // browser permissions through Playwright. launchOptions: { args: [] }, }, - testMatch: MEDIA_CONTINUITY, - grep: WEBKIT_ATTENDEE_CONTINUITY, + testMatch: [MEDIA_CONTINUITY, LISTENER_NETWORK_RESILIENCE], + grep: /attendee controls without capture|preserves the filled buffer/i, }, { name: 'w1440', @@ -166,6 +173,25 @@ export default defineConfig({ LIVEKIT_API_KEY: process.env.E2E_LIVEKIT_API_KEY ?? 'devkey', LIVEKIT_API_SECRET: process.env.E2E_LIVEKIT_API_SECRET ?? 'secret', LIVEKIT_ROOM_NAME: 'beacon', + EARLY_BIRDS_ENABLED: '1', + EARLY_BIRDS_FREE_FOR_ALL: LISTENER_NETWORK_GATE ? '1' : '0', + EARLY_BIRDS_AUTH_SECRET: 'early-birds-e2e-auth-secret-not-for-production', + EARLY_BIRDS_AUTH_BASE_URL: BASE_URL, + EARLY_BIRDS_TRUSTED_ORIGINS: BASE_URL, + EARLY_BIRDS_TEST_ACCESS_ENABLED: '1', + EARLY_BIRDS_TEST_LOGIN_SECRET: 'early-birds-e2e-login-secret-not-for-production', + EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED: '1', + EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS: `localhost:${PORT}`, + // This opt-in gate gets a separate server process so the + // regular visual suite keeps its existing Account-off UI. + ...(LISTENER_ACCOUNT_SWITCH_GATE ? { + BEACON_LISTENER_ACCOUNT_ENABLED: '1', + BEACON_LISTENER_ACCOUNT_ENVIRONMENT: 'production', + BEACON_LISTENER_ACCOUNT_CLIENT_SECRET: + 'listener-account-e2e-client-secret-not-for-production', + BEACON_LISTENER_ACCOUNT_STATE_SECRET: + 'listener-account-e2e-state-secret-not-for-production', + } : {}), }, }, }); diff --git a/prisma/migrations/20260806040000_early_birds_listener/migration.sql b/prisma/migrations/20260806040000_early_birds_listener/migration.sql new file mode 100644 index 00000000..c04ac51c --- /dev/null +++ b/prisma/migrations/20260806040000_early_birds_listener/migration.sql @@ -0,0 +1,129 @@ +-- EarlyBirds is additive and deliberately isolated from weekend identities, +-- sessions, tickets, LiveKit participants, and contributions. +CREATE TYPE "EarlyBirdMembershipState" AS ENUM ( + 'PENDING', + 'ACTIVE', + 'GRACE', + 'CANCELLED_PENDING_END', + 'EXPIRED', + 'REFUNDED', + 'REVOKED' +); + +CREATE TYPE "EarlyBirdMembershipSource" AS ENUM ( + 'FREE', + 'PAYPAL', + 'MERCADO_PAGO' +); + +CREATE TABLE "early_bird_users" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "email_verified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "early_bird_users_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "early_bird_identities" ( + "id" TEXT NOT NULL, + "provider_id" TEXT NOT NULL, + "account_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "access_token" TEXT, + "refresh_token" TEXT, + "id_token" TEXT, + "access_token_expires_at" TIMESTAMP(3), + "refresh_token_expires_at" TIMESTAMP(3), + "scope" TEXT, + "password" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "early_bird_identities_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "early_bird_auth_sessions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "ip_address" TEXT, + "user_agent" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "early_bird_auth_sessions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "early_bird_verifications" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "early_bird_verifications_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "early_bird_membership_projections" ( + "id" UUID NOT NULL, + "account_id" TEXT NOT NULL, + "revision" INTEGER NOT NULL, + "command_hash" CHAR(64) NOT NULL, + "state" "EarlyBirdMembershipState" NOT NULL, + "source" "EarlyBirdMembershipSource", + "offer_code" TEXT, + "offer_revision" INTEGER, + "effective_at" TIMESTAMP(3) NOT NULL, + "paid_through" TIMESTAMP(3), + "grace_until" TIMESTAMP(3), + "provider" TEXT, + "amount_minor" INTEGER, + "currency" VARCHAR(3), + "reason_code" VARCHAR(64) NOT NULL, + "synthetic" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "early_bird_membership_projections_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "early_bird_stream_leases" ( + "id" UUID NOT NULL, + "account_id" TEXT NOT NULL, + "device_digest" CHAR(64) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3) NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "evicted_at" TIMESTAMP(3), + CONSTRAINT "early_bird_stream_leases_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "early_bird_users_email_key" ON "early_bird_users"("email"); +CREATE UNIQUE INDEX "early_bird_identities_provider_id_account_id_key" ON "early_bird_identities"("provider_id", "account_id"); +CREATE INDEX "early_bird_identities_user_id_idx" ON "early_bird_identities"("user_id"); +CREATE UNIQUE INDEX "early_bird_auth_sessions_token_key" ON "early_bird_auth_sessions"("token"); +CREATE INDEX "early_bird_auth_sessions_user_id_idx" ON "early_bird_auth_sessions"("user_id"); +CREATE INDEX "early_bird_auth_sessions_expires_at_idx" ON "early_bird_auth_sessions"("expires_at"); +CREATE INDEX "early_bird_verifications_identifier_idx" ON "early_bird_verifications"("identifier"); +CREATE INDEX "early_bird_verifications_expires_at_idx" ON "early_bird_verifications"("expires_at"); +CREATE UNIQUE INDEX "early_bird_membership_projections_account_id_key" ON "early_bird_membership_projections"("account_id"); +CREATE INDEX "early_bird_membership_projections_state_paid_through_idx" ON "early_bird_membership_projections"("state", "paid_through"); +CREATE UNIQUE INDEX "early_bird_stream_leases_account_id_device_digest_key" ON "early_bird_stream_leases"("account_id", "device_digest"); +CREATE INDEX "early_bird_stream_leases_account_id_evicted_at_expires_at_last_seen_at_idx" ON "early_bird_stream_leases"("account_id", "evicted_at", "expires_at", "last_seen_at"); + +ALTER TABLE "early_bird_identities" + ADD CONSTRAINT "early_bird_identities_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "early_bird_auth_sessions" + ADD CONSTRAINT "early_bird_auth_sessions_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "early_bird_membership_projections" + ADD CONSTRAINT "early_bird_membership_projections_account_id_fkey" + FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "early_bird_stream_leases" + ADD CONSTRAINT "early_bird_stream_leases_account_id_fkey" + FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260807070000_early_bird_free_schedule/migration.sql b/prisma/migrations/20260807070000_early_bird_free_schedule/migration.sql new file mode 100644 index 00000000..930613c2 --- /dev/null +++ b/prisma/migrations/20260807070000_early_bird_free_schedule/migration.sql @@ -0,0 +1,29 @@ +CREATE TABLE "early_bird_free_schedules" ( + "account_id" TEXT NOT NULL, + "time_zone" VARCHAR(64) NOT NULL, + "local_start_minute" INTEGER NOT NULL, + "selected_at" TIMESTAMP(3) NOT NULL, + "change_allowed_at" TIMESTAMP(3) NOT NULL, + "selection_request_id" VARCHAR(64) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "early_bird_free_schedules_pkey" PRIMARY KEY ("account_id"), + CONSTRAINT "early_bird_free_schedules_local_start_minute_check" + CHECK ("local_start_minute" >= 0 AND "local_start_minute" < 1440), + CONSTRAINT "early_bird_free_schedules_revision_check" + CHECK ("revision" >= 1), + CONSTRAINT "early_bird_free_schedules_change_after_selection_check" + CHECK ("change_allowed_at" >= "selected_at") +); + +CREATE UNIQUE INDEX "early_bird_free_schedules_selection_request_id_key" + ON "early_bird_free_schedules"("selection_request_id"); +CREATE INDEX "early_bird_free_schedules_change_allowed_at_idx" + ON "early_bird_free_schedules"("change_allowed_at"); + +ALTER TABLE "early_bird_free_schedules" + ADD CONSTRAINT "early_bird_free_schedules_account_id_fkey" + FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260807090000_early_bird_magic_link_throttles/migration.sql b/prisma/migrations/20260807090000_early_bird_magic_link_throttles/migration.sql new file mode 100644 index 00000000..7f82b75f --- /dev/null +++ b/prisma/migrations/20260807090000_early_bird_magic_link_throttles/migration.sql @@ -0,0 +1,15 @@ +CREATE TABLE "early_bird_magic_link_throttles" ( + "key" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "window_started_at" TIMESTAMPTZ NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "blocked_until" TIMESTAMPTZ, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "early_bird_magic_link_throttles_pkey" PRIMARY KEY ("key"), + CONSTRAINT "early_bird_magic_link_throttles_attempts_check" CHECK ("attempts" >= 0), + CONSTRAINT "early_bird_magic_link_throttles_kind_check" CHECK ("kind" IN ('email', 'origin_ip')) +); + +CREATE INDEX "early_bird_magic_link_throttles_updated_at_idx" + ON "early_bird_magic_link_throttles"("updated_at"); diff --git a/prisma/migrations/20260807100000_early_bird_welcome_access/migration.sql b/prisma/migrations/20260807100000_early_bird_welcome_access/migration.sql new file mode 100644 index 00000000..9385ef4f --- /dev/null +++ b/prisma/migrations/20260807100000_early_bird_welcome_access/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "early_bird_welcome_accesses" ( + "account_id" TEXT NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL, + "ends_at" TIMESTAMP(3) NOT NULL, + "activation_request_id" VARCHAR(64) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "early_bird_welcome_accesses_pkey" PRIMARY KEY ("account_id"), + CONSTRAINT "early_bird_welcome_accesses_duration_check" + CHECK ("ends_at" = "started_at" + INTERVAL '30 minutes') +); + +CREATE UNIQUE INDEX "early_bird_welcome_accesses_activation_request_id_key" + ON "early_bird_welcome_accesses"("activation_request_id"); +CREATE INDEX "early_bird_welcome_accesses_ends_at_idx" + ON "early_bird_welcome_accesses"("ends_at"); + +ALTER TABLE "early_bird_welcome_accesses" + ADD CONSTRAINT "early_bird_welcome_accesses_account_id_fkey" + FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260807200000_listener_regional_presence/migration.sql b/prisma/migrations/20260807200000_listener_regional_presence/migration.sql new file mode 100644 index 00000000..b9c9a07b --- /dev/null +++ b/prisma/migrations/20260807200000_listener_regional_presence/migration.sql @@ -0,0 +1,19 @@ +CREATE TYPE "ListenerMacroRegion" AS ENUM ( + 'NORTH_AMERICA', + 'LATIN_AMERICA', + 'EUROPE', + 'AFRICA', + 'ASIA', + 'OCEANIA', + 'UNKNOWN' +); + +CREATE TYPE "ListenerPresenceState" AS ENUM ('IDLE', 'LISTENING'); + +ALTER TABLE "early_bird_stream_leases" + ADD COLUMN "presence" "ListenerPresenceState" NOT NULL DEFAULT 'IDLE', + ADD COLUMN "macro_region" "ListenerMacroRegion" NOT NULL DEFAULT 'UNKNOWN', + ADD COLUMN "presence_updated_at" TIMESTAMP(3); + +CREATE INDEX "early_bird_stream_leases_presence_presence_updated_at_expires_idx" + ON "early_bird_stream_leases"("presence", "presence_updated_at", "expires_at"); diff --git a/prisma/migrations/20260808090000_early_bird_founder_eligibility_projection/migration.sql b/prisma/migrations/20260808090000_early_bird_founder_eligibility_projection/migration.sql new file mode 100644 index 00000000..d1475292 --- /dev/null +++ b/prisma/migrations/20260808090000_early_bird_founder_eligibility_projection/migration.sql @@ -0,0 +1,35 @@ +CREATE TABLE "early_bird_founder_eligibility_projections" ( + "account_id" TEXT NOT NULL, + "offer_code" VARCHAR(128) NOT NULL, + "offer_revision" INTEGER NOT NULL, + "currency" CHAR(3) NOT NULL, + "amount_minor" INTEGER NOT NULL, + "billing_period" VARCHAR(32) NOT NULL, + "granted_at" TIMESTAMP(3) NOT NULL, + "eligibility_hash" CHAR(64) NOT NULL, + "observed_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "early_bird_founder_eligibility_projections_pkey" PRIMARY KEY ("account_id"), + CONSTRAINT "early_bird_founder_eligibility_offer_code_check" + CHECK ("offer_code" = 'EARLY_BIRDS_FOUNDERS_V1'), + CONSTRAINT "early_bird_founder_eligibility_offer_revision_check" + CHECK ("offer_revision" >= 1), + CONSTRAINT "early_bird_founder_eligibility_currency_check" + CHECK ("currency" = 'USD'), + CONSTRAINT "early_bird_founder_eligibility_amount_check" + CHECK ("amount_minor" = 200), + CONSTRAINT "early_bird_founder_eligibility_period_check" + CHECK ("billing_period" = 'MONTHLY'), + CONSTRAINT "early_bird_founder_eligibility_hash_check" + CHECK ("eligibility_hash" ~ '^[0-9a-f]{64}$') +); + +CREATE INDEX "early_bird_founder_eligibility_projections_granted_at_idx" + ON "early_bird_founder_eligibility_projections"("granted_at"); + +ALTER TABLE "early_bird_founder_eligibility_projections" + ADD CONSTRAINT "early_bird_founder_eligibility_projections_account_id_fkey" + FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260808150000_listener_weekly_policy_bridge/migration.sql b/prisma/migrations/20260808150000_listener_weekly_policy_bridge/migration.sql new file mode 100644 index 00000000..af7d269c --- /dev/null +++ b/prisma/migrations/20260808150000_listener_weekly_policy_bridge/migration.sql @@ -0,0 +1,15 @@ +-- Rollback bridge installed before the personal weekly quota cutover. The +-- legacy Listener image accepts only legacy-daily-v1; the weekly migration +-- atomically advances this row so every bridge image fails closed thereafter. +CREATE TABLE "early_bird_listener_authority_policy" ( + "id" INTEGER NOT NULL, + "policy_version" VARCHAR(32) NOT NULL, + "activated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "early_bird_listener_authority_policy_pkey" PRIMARY KEY ("id"), + CONSTRAINT "early_bird_listener_authority_policy_singleton_check" CHECK ("id" = 1), + CONSTRAINT "early_bird_listener_authority_policy_version_check" + CHECK ("policy_version" IN ('legacy-daily-v1', 'personal-7-day-v1')) +); + +INSERT INTO "early_bird_listener_authority_policy" ("id", "policy_version") +VALUES (1, 'legacy-daily-v1'); diff --git a/prisma/migrations/20260808160000_listener_weekly_quota/migration.sql b/prisma/migrations/20260808160000_listener_weekly_quota/migration.sql new file mode 100644 index 00000000..a52ce151 --- /dev/null +++ b/prisma/migrations/20260808160000_listener_weekly_quota/migration.sql @@ -0,0 +1,131 @@ +-- personal-7-day-v1 is a forward-only authorization cutover. Legacy Free +-- schedule/welcome rows remain inert migration history, but no runtime reads +-- them and all extant stream leases are evicted so every client reauthorizes. +UPDATE "early_bird_listener_authority_policy" +SET "policy_version" = 'personal-7-day-v1', + "activated_at" = clock_timestamp() +WHERE "id" = 1 AND "policy_version" = 'legacy-daily-v1'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "early_bird_listener_authority_policy" + WHERE "id" = 1 AND "policy_version" = 'personal-7-day-v1' + ) THEN + RAISE EXCEPTION 'listener weekly policy marker is missing or incompatible'; + END IF; +END; +$$; + +ALTER TABLE "early_bird_stream_leases" + ADD COLUMN "generation" INTEGER NOT NULL DEFAULT 1, + ADD COLUMN "presence_sequence" INTEGER NOT NULL DEFAULT 0, + ADD CONSTRAINT "early_bird_stream_leases_generation_check" CHECK ("generation" > 0), + ADD CONSTRAINT "early_bird_stream_leases_presence_sequence_check" CHECK ("presence_sequence" >= 0); +CREATE INDEX "early_bird_stream_leases_account_id_presence_evicted_at_expires_at_idx" + ON "early_bird_stream_leases"("account_id", "presence", "evicted_at", "expires_at"); + +CREATE TABLE "early_bird_listening_quota_cursors" ( + "account_id" TEXT NOT NULL, + "policy_version" VARCHAR(32) NOT NULL, + "cycle_anchor_at" TIMESTAMP(3), + "cycle_started_at" TIMESTAMP(3), + "cycle_ends_at" TIMESTAMP(3), + "base_consumed_ms" INTEGER NOT NULL DEFAULT 0, + "settled_through" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "early_bird_listening_quota_cursors_pkey" PRIMARY KEY ("account_id"), + CONSTRAINT "early_bird_listening_quota_policy_check" CHECK ("policy_version" = 'personal-7-day-v1'), + CONSTRAINT "early_bird_listening_quota_base_check" CHECK ("base_consumed_ms" BETWEEN 0 AND 10800000), + CONSTRAINT "early_bird_listening_quota_cycle_check" CHECK ( + ("cycle_anchor_at" IS NULL AND "cycle_started_at" IS NULL AND "cycle_ends_at" IS NULL AND "settled_through" IS NULL AND "base_consumed_ms" = 0) + OR + ("cycle_anchor_at" IS NOT NULL AND "cycle_started_at" IS NOT NULL AND "cycle_ends_at" = "cycle_started_at" + INTERVAL '7 days' AND "settled_through" IS NOT NULL) + ) +); + +CREATE INDEX "early_bird_listening_quota_cursors_cycle_ends_at_idx" + ON "early_bird_listening_quota_cursors"("cycle_ends_at"); + +ALTER TABLE "early_bird_listening_quota_cursors" + ADD CONSTRAINT "early_bird_listening_quota_cursors_account_id_fkey" + FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE FUNCTION "early_bird_quota_cursor_immutable_anchor"() RETURNS trigger AS $$ +BEGIN + IF OLD."policy_version" <> NEW."policy_version" + OR (OLD."cycle_anchor_at" IS NOT NULL AND OLD."cycle_anchor_at" IS DISTINCT FROM NEW."cycle_anchor_at") THEN + RAISE EXCEPTION 'listener quota policy/anchor is immutable'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER "early_bird_quota_cursor_immutable_anchor_trigger" + BEFORE UPDATE ON "early_bird_listening_quota_cursors" + FOR EACH ROW EXECUTE FUNCTION "early_bird_quota_cursor_immutable_anchor"(); + +CREATE TABLE "early_bird_listening_bonus_grants" ( + "id" UUID NOT NULL, + "account_id" TEXT NOT NULL, + "amount_ms" INTEGER NOT NULL, + "consumed_ms" INTEGER NOT NULL DEFAULT 0, + "fully_consumed" BOOLEAN NOT NULL DEFAULT false, + "issuer_code" VARCHAR(32) NOT NULL, + "source_code" VARCHAR(32) NOT NULL, + "reason_code" VARCHAR(32) NOT NULL, + "idempotency_key" VARCHAR(128) NOT NULL, + "request_hash" CHAR(64) NOT NULL, + "granted_at" TIMESTAMP(3) NOT NULL, + "available_from" TIMESTAMP(3) NOT NULL, + "expires_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "early_bird_listening_bonus_grants_pkey" PRIMARY KEY ("id"), + CONSTRAINT "early_bird_listening_bonus_amount_check" CHECK ("amount_ms" BETWEEN 1 AND 604800000), + CONSTRAINT "early_bird_listening_bonus_consumed_check" CHECK ("consumed_ms" BETWEEN 0 AND "amount_ms"), + CONSTRAINT "early_bird_listening_bonus_fully_consumed_check" CHECK (NOT "fully_consumed" OR "consumed_ms" = "amount_ms"), + CONSTRAINT "early_bird_listening_bonus_expiry_check" CHECK ("expires_at" IS NULL OR "expires_at" > "available_from"), + CONSTRAINT "early_bird_listening_bonus_issuer_check" CHECK ("issuer_code" IN ('SUPPORT', 'OPERATIONS', 'MIGRATION', 'QUEST_SYSTEM')), + CONSTRAINT "early_bird_listening_bonus_source_check" CHECK ("source_code" IN ('MANUAL_REMEDIATION', 'SERVICE_RECOVERY', 'POLICY_MIGRATION', 'COLLABORATION_QUEST')), + CONSTRAINT "early_bird_listening_bonus_reason_check" CHECK ("reason_code" IN ('RESTORE_ACCESS', 'SERVICE_INTERRUPTION', 'CUTOVER_ADJUSTMENT', 'QUEST_COMPLETED')) +); + +CREATE UNIQUE INDEX "early_bird_listening_bonus_grants_account_id_issuer_code_idempotency_key_key" + ON "early_bird_listening_bonus_grants"("account_id", "issuer_code", "idempotency_key"); +CREATE INDEX "early_bird_listening_bonus_grants_account_id_fully_consumed_expires_at_available_from_idx" + ON "early_bird_listening_bonus_grants"("account_id", "fully_consumed", "expires_at", "available_from"); + +ALTER TABLE "early_bird_listening_bonus_grants" + ADD CONSTRAINT "early_bird_listening_bonus_grants_account_id_fkey" + FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE FUNCTION "early_bird_quota_grant_immutable_facts"() RETURNS trigger AS $$ +BEGIN + IF OLD."id" IS DISTINCT FROM NEW."id" + OR OLD."account_id" IS DISTINCT FROM NEW."account_id" + OR OLD."amount_ms" IS DISTINCT FROM NEW."amount_ms" + OR OLD."issuer_code" IS DISTINCT FROM NEW."issuer_code" + OR OLD."source_code" IS DISTINCT FROM NEW."source_code" + OR OLD."reason_code" IS DISTINCT FROM NEW."reason_code" + OR OLD."idempotency_key" IS DISTINCT FROM NEW."idempotency_key" + OR OLD."request_hash" IS DISTINCT FROM NEW."request_hash" + OR OLD."granted_at" IS DISTINCT FROM NEW."granted_at" + OR OLD."available_from" IS DISTINCT FROM NEW."available_from" + OR OLD."expires_at" IS DISTINCT FROM NEW."expires_at" + OR OLD."created_at" IS DISTINCT FROM NEW."created_at" + OR NEW."consumed_ms" < OLD."consumed_ms" + OR (OLD."fully_consumed" AND NOT NEW."fully_consumed") THEN + RAISE EXCEPTION 'listener quota grant facts are immutable'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER "early_bird_quota_grant_immutable_facts_trigger" + BEFORE UPDATE ON "early_bird_listening_bonus_grants" + FOR EACH ROW EXECUTE FUNCTION "early_bird_quota_grant_immutable_facts"(); + +UPDATE "early_bird_stream_leases" +SET "evicted_at" = clock_timestamp(), + "presence" = 'IDLE', + "presence_updated_at" = clock_timestamp() +WHERE "evicted_at" IS NULL; diff --git a/prisma/migrations/20260809170000_listener_founder_price_usd5/migration.sql b/prisma/migrations/20260809170000_listener_founder_price_usd5/migration.sql new file mode 100644 index 00000000..34bbd181 --- /dev/null +++ b/prisma/migrations/20260809170000_listener_founder_price_usd5/migration.sql @@ -0,0 +1,13 @@ +-- No public Founder subscription existed at the previous experimental price. Keep one canonical +-- offer by migrating any synthetic projection and replacing the constraint forward-only. +ALTER TABLE "early_bird_founder_eligibility_projections" + DROP CONSTRAINT "early_bird_founder_eligibility_amount_check"; + +UPDATE "early_bird_founder_eligibility_projections" +SET "amount_minor" = 500 +WHERE "currency" = 'USD' + AND "amount_minor" = 200; + +ALTER TABLE "early_bird_founder_eligibility_projections" + ADD CONSTRAINT "early_bird_founder_eligibility_amount_check" + CHECK ("amount_minor" = 500); diff --git a/prisma/migrations/20260810223000_listener_founder_continuity/migration.sql b/prisma/migrations/20260810223000_listener_founder_continuity/migration.sql new file mode 100644 index 00000000..a7d558ea --- /dev/null +++ b/prisma/migrations/20260810223000_listener_founder_continuity/migration.sql @@ -0,0 +1,93 @@ +-- The positive-only eligibility projection encoded an unreleased experimental +-- policy. Founder price now survives only while one canonical service episode +-- remains uninterrupted, so current continuity travels atomically with the +-- membership revision. Existing rows are synthetic and intentionally do not +-- grandfather an account into the replacement policy. +CREATE TYPE "EarlyBirdFounderContinuityState" AS ENUM ( + 'ACTIVE', + 'CANCELLED_PENDING_END', + 'GRACE', + 'ENDED' +); + +ALTER TABLE "early_bird_membership_projections" + ADD COLUMN "founder_continuity_episode_id" UUID, + ADD COLUMN "founder_continuity_revision" INTEGER, + ADD COLUMN "founder_continuity_state" "EarlyBirdFounderContinuityState", + ADD COLUMN "founder_continuity_offer_code" VARCHAR(128), + ADD COLUMN "founder_continuity_offer_revision" INTEGER, + ADD COLUMN "founder_continuity_currency" CHAR(3), + ADD COLUMN "founder_continuity_amount_minor" INTEGER, + ADD COLUMN "founder_continuity_billing_period" VARCHAR(32), + ADD COLUMN "founder_continuity_activated_at" TIMESTAMP(3), + ADD COLUMN "founder_continuity_service_through" TIMESTAMP(3), + ADD COLUMN "founder_continuity_ended_at" TIMESTAMP(3), + ADD COLUMN "founder_continuity_terminal_reason" VARCHAR(64); + +ALTER TABLE "early_bird_membership_projections" + ADD CONSTRAINT "early_bird_founder_continuity_complete_check" CHECK ( + ( + "founder_continuity_episode_id" IS NULL + AND "founder_continuity_revision" IS NULL + AND "founder_continuity_state" IS NULL + AND "founder_continuity_offer_code" IS NULL + AND "founder_continuity_offer_revision" IS NULL + AND "founder_continuity_currency" IS NULL + AND "founder_continuity_amount_minor" IS NULL + AND "founder_continuity_billing_period" IS NULL + AND "founder_continuity_activated_at" IS NULL + AND "founder_continuity_service_through" IS NULL + AND "founder_continuity_ended_at" IS NULL + AND "founder_continuity_terminal_reason" IS NULL + ) OR ( + "founder_continuity_episode_id" IS NOT NULL + AND "founder_continuity_revision" >= 1 + AND "founder_continuity_state" IS NOT NULL + AND "founder_continuity_offer_code" = 'EARLY_BIRDS_FOUNDERS_V1' + AND "founder_continuity_offer_revision" >= 1 + AND "founder_continuity_currency" = 'USD' + AND "founder_continuity_amount_minor" = 500 + AND "founder_continuity_billing_period" = 'MONTHLY' + AND "founder_continuity_activated_at" IS NOT NULL + AND ( + ( + "founder_continuity_state" = 'ENDED' + AND "founder_continuity_ended_at" IS NOT NULL + AND "founder_continuity_terminal_reason" IS NOT NULL + ) OR ( + "founder_continuity_state" <> 'ENDED' + AND "founder_continuity_service_through" IS NOT NULL + AND "founder_continuity_ended_at" IS NULL + AND "founder_continuity_terminal_reason" IS NULL + ) + ) + ) + ); + +CREATE INDEX "early_bird_membership_projections_founder_continuity_state_service_through_idx" + ON "early_bird_membership_projections"("founder_continuity_state", "founder_continuity_service_through"); + +-- No public Listener membership exists yet. Retire every v1 command hash and +-- revision before command.v2 starts: otherwise a command.v2 delivery using +-- the same membership_revision would correctly conflict with the old bytes and +-- could never converge. Preserve the complete pre-cutover row for audit, then +-- leave the runtime projection empty so its first command.v2 is authoritative. +CREATE TABLE "early_bird_retired_membership_projection_audit" + AS TABLE "early_bird_membership_projections" WITH NO DATA; + +INSERT INTO "early_bird_retired_membership_projection_audit" +SELECT * FROM "early_bird_membership_projections"; + +DELETE FROM "early_bird_membership_projections"; + +COMMENT ON TABLE "early_bird_retired_membership_projection_audit" IS + 'Pre-release membership command.v1 projections; audit only, never runtime authority'; + +-- Retain the synthetic pre-release rows strictly as audit history. The renamed +-- table has no Prisma model or runtime reader/writer, so it cannot authorize or +-- price a Listener while still documenting what the experiment projected. +ALTER TABLE "early_bird_founder_eligibility_projections" + RENAME TO "early_bird_retired_founder_eligibility_audit"; + +COMMENT ON TABLE "early_bird_retired_founder_eligibility_audit" IS + 'Retired experimental positive-only Founder eligibility; audit only, never authority'; diff --git a/prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql b/prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql new file mode 100644 index 00000000..02edef18 --- /dev/null +++ b/prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql @@ -0,0 +1,74 @@ +-- Public, no-login consumer withdrawal queue. This is additive and has no +-- relation to event, staff, playback or canonical payment tables. +CREATE TYPE "ListenerWithdrawalProvider" AS ENUM ('PAYPAL', 'MERCADO_PAGO', 'OTHER'); +CREATE TYPE "ListenerWithdrawalStatus" AS ENUM ('RECEIVED', 'ACKNOWLEDGED', 'RESOLVED'); +CREATE TYPE "ListenerConsumerRequestKind" AS ENUM ('WITHDRAWAL', 'SERVICE_CANCELLATION'); + +CREATE TABLE "listener_withdrawal_requests" ( + "id" UUID NOT NULL, + "receipt_digest" CHAR(64) NOT NULL, + "receipt_last_four" CHAR(4) NOT NULL, + "idempotency_key" UUID NOT NULL, + "request_hash" CHAR(64) NOT NULL, + "contact_email" VARCHAR(254) NOT NULL, + "request_kind" "ListenerConsumerRequestKind" NOT NULL, + "provider" "ListenerWithdrawalProvider" NOT NULL, + "purchase_date" DATE, + "locale" CHAR(2) NOT NULL, + "status" "ListenerWithdrawalStatus" NOT NULL DEFAULT 'RECEIVED', + "acknowledged_at" TIMESTAMP(3), + "acknowledged_by" VARCHAR(64), + "resolved_at" TIMESTAMP(3), + "resolved_by" VARCHAR(64), + "resolution_code" VARCHAR(64), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "listener_withdrawal_requests_pkey" PRIMARY KEY ("id"), + CONSTRAINT "listener_withdrawal_requests_receipt_digest_check" + CHECK ("receipt_digest" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "listener_withdrawal_requests_receipt_last_four_check" + CHECK ("receipt_last_four" ~ '^[0-9A-F]{4}$'), + CONSTRAINT "listener_withdrawal_requests_request_hash_check" + CHECK ("request_hash" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "listener_withdrawal_requests_email_check" + CHECK (length("contact_email") BETWEEN 3 AND 254 AND "contact_email" = lower("contact_email")), + CONSTRAINT "listener_withdrawal_requests_locale_check" + CHECK ("locale" IN ('es', 'en')), + CONSTRAINT "listener_withdrawal_requests_ack_check" CHECK ( + ("status" = 'RECEIVED' AND "acknowledged_at" IS NULL AND "acknowledged_by" IS NULL + AND "resolved_at" IS NULL AND "resolved_by" IS NULL AND "resolution_code" IS NULL) + OR + ("status" = 'ACKNOWLEDGED' AND "acknowledged_at" IS NOT NULL AND "acknowledged_by" IS NOT NULL + AND "resolved_at" IS NULL AND "resolved_by" IS NULL AND "resolution_code" IS NULL) + OR + ("status" = 'RESOLVED' AND "acknowledged_at" IS NOT NULL AND "acknowledged_by" IS NOT NULL + AND "resolved_at" IS NOT NULL AND "resolved_by" IS NOT NULL AND "resolution_code" IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX "listener_withdrawal_requests_receipt_digest_key" + ON "listener_withdrawal_requests"("receipt_digest"); +CREATE UNIQUE INDEX "listener_withdrawal_requests_idempotency_key_key" + ON "listener_withdrawal_requests"("idempotency_key"); +CREATE INDEX "listener_withdrawal_requests_status_created_at_idx" + ON "listener_withdrawal_requests"("status", "created_at"); +CREATE INDEX "listener_withdrawal_requests_created_at_idx" + ON "listener_withdrawal_requests"("created_at"); + +CREATE TABLE "listener_withdrawal_throttles" ( + "key" VARCHAR(72) NOT NULL, + "window_started_at" TIMESTAMP(3) NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "blocked_until" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "listener_withdrawal_throttles_pkey" PRIMARY KEY ("key"), + CONSTRAINT "listener_withdrawal_throttles_key_check" + CHECK ("key" = 'global' OR "key" ~ '^(network|email):[0-9a-f]{64}$'), + CONSTRAINT "listener_withdrawal_throttles_attempts_check" + CHECK ("attempts" >= 0) +); + +CREATE INDEX "listener_withdrawal_throttles_updated_at_idx" + ON "listener_withdrawal_throttles"("updated_at"); diff --git a/prisma/migrations/20260818010000_beacon_account_authority/migration.sql b/prisma/migrations/20260818010000_beacon_account_authority/migration.sql new file mode 100644 index 00000000..cf818ac7 --- /dev/null +++ b/prisma/migrations/20260818010000_beacon_account_authority/migration.sql @@ -0,0 +1,381 @@ +-- Central Account authority is forward-only. Existing opaque account IDs and +-- every Listener foreign key remain unchanged. +ALTER TABLE "early_bird_users" + ADD COLUMN "security_revision" INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE "early_bird_auth_sessions" + ADD COLUMN "security_revision" INTEGER NOT NULL DEFAULT 1, + ADD COLUMN "authority_environment" TEXT NOT NULL DEFAULT 'legacy'; + +CREATE TABLE "beacon_account_authority_environment" ( + "id" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "beacon_account_authority_environment_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "beacon_account_authority_environment_issuer_key" + ON "beacon_account_authority_environment"("issuer"); + +CREATE TABLE "beacon_profiles" ( + "account_id" TEXT NOT NULL, + "display_name" VARCHAR(60) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "beacon_profiles_pkey" PRIMARY KEY ("account_id"), + CONSTRAINT "beacon_profiles_display_name_check" CHECK ( + char_length(btrim("display_name")) BETWEEN 1 AND 60 + AND "display_name" !~ '[[:cntrl:]]' + AND "display_name" !~ U&'[\00AD\061C\180E\200B-\200F\202A-\202E\2060-\206F\FEFF]' + ), + CONSTRAINT "beacon_profiles_revision_check" CHECK ("revision" >= 1), + CONSTRAINT "beacon_profiles_account_id_fkey" FOREIGN KEY ("account_id") + REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Seed the private profile once. Provider profile changes never update it. +INSERT INTO "beacon_profiles" ("account_id", "display_name", "revision", "created_at", "updated_at") +SELECT + "id", + CASE + WHEN char_length(btrim("name")) BETWEEN 1 AND 60 + AND btrim("name") !~ '[[:cntrl:]]' + AND btrim("name") !~ U&'[\00AD\061C\180E\200B-\200F\202A-\202E\2060-\206F\FEFF]' + THEN btrim("name") + ELSE 'Beacon Listener' + END, + 1, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "early_bird_users"; + +-- Every canonical account has a profile even if an auth hook/process crashes +-- after inserting the account. Application hooks remain idempotent helpers; +-- this database invariant is the authority. +CREATE FUNCTION "beacon_profile_after_account_insert"() RETURNS trigger AS $$ +BEGIN + INSERT INTO "beacon_profiles" ( + "account_id", "display_name", "revision", "created_at", "updated_at" + ) VALUES ( + NEW."id", + CASE + WHEN char_length(btrim(NEW."name")) BETWEEN 1 AND 60 + AND btrim(NEW."name") !~ '[[:cntrl:]]' + AND btrim(NEW."name") !~ U&'[\00AD\061C\180E\200B-\200F\202A-\202E\2060-\206F\FEFF]' + THEN btrim(NEW."name") + ELSE 'Beacon Listener' + END, + 1, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) ON CONFLICT ("account_id") DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER "beacon_profile_after_account_insert_trigger" +AFTER INSERT ON "early_bird_users" +FOR EACH ROW EXECUTE FUNCTION "beacon_profile_after_account_insert"(); + +-- This is a pre-public authority cutover, not a rolling session migration. +-- Provider/account rows and all product FKs survive, while every legacy +-- browser session and one-use artifact is invalidated atomically. +DELETE FROM "early_bird_auth_sessions"; +DELETE FROM "early_bird_verifications"; +DELETE FROM "early_bird_magic_link_throttles"; + +-- Accounts keep the access method with which they were first established. +-- Pre-public linking experiments are collapsed deterministically to the +-- earliest identity while retaining the opaque account row and product FKs. +DELETE FROM "early_bird_identities" candidate +USING "early_bird_identities" keeper +WHERE candidate."user_id" = keeper."user_id" + AND (candidate."created_at", candidate."id") > (keeper."created_at", keeper."id"); +DROP INDEX IF EXISTS "early_bird_identities_user_id_idx"; +CREATE UNIQUE INDEX "early_bird_identities_user_id_key" ON "early_bird_identities"("user_id"); + +-- Provider bearer/refresh/ID tokens are never authority data. Erase any +-- pre-public residue and enforce the boundary below the ORM hooks. +UPDATE "early_bird_identities" +SET "access_token" = NULL, + "refresh_token" = NULL, + "id_token" = NULL, + "access_token_expires_at" = NULL, + "refresh_token_expires_at" = NULL, + "scope" = NULL; +ALTER TABLE "early_bird_identities" + ADD CONSTRAINT "early_bird_identities_no_provider_tokens_check" CHECK ( + "access_token" IS NULL + AND "refresh_token" IS NULL + AND "id_token" IS NULL + AND "access_token_expires_at" IS NULL + AND "refresh_token_expires_at" IS NULL + AND "scope" IS NULL + ); + +CREATE TABLE "beacon_account_action_tokens" ( + "id" UUID NOT NULL, + "token_digest" CHAR(64) NOT NULL, + "purpose" TEXT NOT NULL, + "account_id" TEXT NOT NULL, + "target_email" TEXT, + "locale" TEXT NOT NULL DEFAULT 'en', + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "beacon_account_action_tokens_pkey" PRIMARY KEY ("id"), + CONSTRAINT "beacon_account_action_tokens_purpose_check" CHECK ( + "purpose" IN ('verify_email', 'reset_password', 'change_email') + ), + CONSTRAINT "beacon_account_action_tokens_locale_check" CHECK ("locale" IN ('es', 'en')), + CONSTRAINT "beacon_account_action_tokens_account_id_fkey" FOREIGN KEY ("account_id") + REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "beacon_account_action_tokens_token_digest_key" ON "beacon_account_action_tokens"("token_digest"); +CREATE INDEX "beacon_account_action_tokens_account_id_purpose_expires_at_idx" ON "beacon_account_action_tokens"("account_id", "purpose", "expires_at"); +CREATE INDEX "beacon_account_action_tokens_expires_at_idx" ON "beacon_account_action_tokens"("expires_at"); +CREATE INDEX "beacon_account_action_tokens_consumed_at_idx" ON "beacon_account_action_tokens"("consumed_at"); + +CREATE TABLE "beacon_account_auth_throttles" ( + "key" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "window_started_at" TIMESTAMP(3) NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "blocked_until" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "beacon_account_auth_throttles_pkey" PRIMARY KEY ("key") +); +CREATE INDEX "beacon_account_auth_throttles_updated_at_idx" ON "beacon_account_auth_throttles"("updated_at"); + +CREATE TABLE "beacon_account_mail_outbox" ( + "id" UUID NOT NULL, + "account_id" TEXT NOT NULL, + "purpose" TEXT NOT NULL DEFAULT 'verify_email', + "locale" TEXT NOT NULL DEFAULT 'en', + "recipient" TEXT NOT NULL, + "target_email" TEXT, + "sealed_token" TEXT, + "token_expires_at" TIMESTAMP(3), + "idempotency_key" TEXT, + "delivery_attempted_at" TIMESTAMP(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "generation" INTEGER NOT NULL DEFAULT 1, + "next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "locked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "beacon_account_mail_outbox_pkey" PRIMARY KEY ("id"), + CONSTRAINT "beacon_account_mail_outbox_account_id_fkey" FOREIGN KEY ("account_id") + REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "beacon_account_mail_outbox_purpose_check" CHECK ( + "purpose" IN ('verify_email', 'reset_password', 'change_email') + ), + CONSTRAINT "beacon_account_mail_outbox_locale_check" CHECK ("locale" IN ('es', 'en')) +); +ALTER TABLE "beacon_account_mail_outbox" + ADD CONSTRAINT "beacon_account_mail_outbox_generation_check" CHECK ("generation" >= 1), + ADD CONSTRAINT "beacon_account_mail_outbox_idempotency_key_check" CHECK ( + "idempotency_key" IS NULL OR "idempotency_key" ~ '^[0-9a-f]{64}$' + ), + ADD CONSTRAINT "beacon_account_mail_outbox_payload_shape_check" CHECK ( + ("sealed_token" IS NULL AND "token_expires_at" IS NULL AND "idempotency_key" IS NULL) + OR + ("sealed_token" IS NOT NULL AND "token_expires_at" IS NOT NULL AND "idempotency_key" IS NOT NULL) + ); +CREATE UNIQUE INDEX "beacon_account_mail_outbox_account_id_purpose_generation_key" + ON "beacon_account_mail_outbox"("account_id", "purpose", "generation"); +CREATE INDEX "beacon_account_mail_outbox_next_attempt_at_locked_at_idx" + ON "beacon_account_mail_outbox"("next_attempt_at", "locked_at"); + +-- Credential creation and verification delivery intent commit together in +-- the Better Auth adapter transaction. Network delivery is always post-commit. +CREATE FUNCTION "beacon_verification_outbox_after_identity_insert"() RETURNS trigger AS $$ +BEGIN + IF NEW."provider_id" = 'credential' THEN + INSERT INTO "beacon_account_mail_outbox" ( + "id", "account_id", "purpose", "locale", "recipient", "attempts", + "next_attempt_at", "created_at", "updated_at" + ) VALUES ( + gen_random_uuid(), NEW."user_id", 'verify_email', 'en', + (SELECT "email" FROM "early_bird_users" WHERE "id" = NEW."user_id"), 0, + CURRENT_TIMESTAMP + INTERVAL '5 seconds', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER "beacon_verification_outbox_after_identity_insert_trigger" +AFTER INSERT ON "early_bird_identities" +FOR EACH ROW EXECUTE FUNCTION "beacon_verification_outbox_after_identity_insert"(); + +INSERT INTO "beacon_account_mail_outbox" ( + "id", "account_id", "purpose", "locale", "recipient", "attempts", + "next_attempt_at", "created_at", "updated_at" +) +SELECT gen_random_uuid(), identity."user_id", 'verify_email', 'en', account."email", 0, + CURRENT_TIMESTAMP + INTERVAL '5 seconds', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM "early_bird_identities" identity +JOIN "early_bird_users" account ON account."id" = identity."user_id" +WHERE identity."provider_id" = 'credential' AND account."email_verified" = false +ON CONFLICT ("account_id", "purpose", "generation") DO NOTHING; + +CREATE TABLE "listener_account_subjects" ( + "account_id" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "listener_account_subjects_pkey" PRIMARY KEY ("account_id"), + CONSTRAINT "listener_account_subjects_account_id_fkey" FOREIGN KEY ("account_id") + REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "listener_account_subjects_issuer_subject_key" + ON "listener_account_subjects"("issuer", "subject"); + +CREATE TABLE "listener_account_sessions" ( + "id" UUID NOT NULL, + "token_digest" CHAR(64) NOT NULL, + "account_id" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "sid" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "last_checked_at" TIMESTAMP(3) NOT NULL, + "revalidation_lease_until" TIMESTAMP(3), + "synthetic" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "listener_account_sessions_pkey" PRIMARY KEY ("id"), + CONSTRAINT "listener_account_sessions_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "listener_account_sessions_token_digest_key" ON "listener_account_sessions"("token_digest"); +CREATE INDEX "listener_account_sessions_account_id_idx" ON "listener_account_sessions"("account_id"); +CREATE INDEX "listener_account_sessions_issuer_subject_idx" ON "listener_account_sessions"("issuer", "subject"); +CREATE INDEX "listener_account_sessions_issuer_sid_idx" ON "listener_account_sessions"("issuer", "sid"); +CREATE INDEX "listener_account_sessions_expires_at_idx" ON "listener_account_sessions"("expires_at"); +CREATE INDEX "listener_account_sessions_revalidation_lease_until_idx" + ON "listener_account_sessions"("revalidation_lease_until"); + +CREATE TABLE "beacon_oauth_clients" ( + "id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "client_secret" TEXT, + "disabled" BOOLEAN NOT NULL DEFAULT false, + "skip_consent" BOOLEAN, + "enable_end_session" BOOLEAN, + "subject_type" TEXT, + "scopes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "user_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT, + "uri" TEXT, + "icon" TEXT, + "contacts" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "tos" TEXT, + "policy" TEXT, + "software_id" TEXT, + "software_version" TEXT, + "software_statement" TEXT, + "redirect_uris" TEXT[] NOT NULL, + "post_logout_redirect_uris" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "token_endpoint_auth_method" TEXT, + "grant_types" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "response_types" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "public" BOOLEAN NOT NULL DEFAULT false, + "type" TEXT, + "require_pkce" BOOLEAN NOT NULL DEFAULT true, + "reference_id" TEXT, + "metadata" JSONB, + CONSTRAINT "beacon_oauth_clients_pkey" PRIMARY KEY ("id"), + CONSTRAINT "beacon_oauth_clients_user_id_fkey" FOREIGN KEY ("user_id") + REFERENCES "early_bird_users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "beacon_oauth_clients_auth_method_check" CHECK ( + "token_endpoint_auth_method" = 'client_secret_basic' + ), + CONSTRAINT "beacon_oauth_clients_static_confidential_check" CHECK ( + "disabled" = true OR ( + "public" = false + AND "require_pkce" = true + AND "skip_consent" = true + AND "enable_end_session" = true + AND "subject_type" = 'public' + AND "type" = 'web' + AND "grant_types" = ARRAY['authorization_code']::TEXT[] + AND "response_types" = ARRAY['code']::TEXT[] + AND "scopes" = ARRAY['openid', 'profile']::TEXT[] + ) + ) +); +CREATE UNIQUE INDEX "beacon_oauth_clients_client_id_key" ON "beacon_oauth_clients"("client_id"); +CREATE INDEX "beacon_oauth_clients_user_id_idx" ON "beacon_oauth_clients"("user_id"); + +CREATE TABLE "beacon_oauth_refresh_tokens" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "session_id" TEXT, + "user_id" TEXT NOT NULL, + "reference_id" TEXT, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked" TIMESTAMP(3), + "auth_time" TIMESTAMP(3), + "scopes" TEXT[] NOT NULL, + CONSTRAINT "beacon_oauth_refresh_tokens_pkey" PRIMARY KEY ("id"), + CONSTRAINT "beacon_oauth_refresh_tokens_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "beacon_oauth_clients"("client_id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "beacon_oauth_refresh_tokens_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "early_bird_auth_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "beacon_oauth_refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "beacon_oauth_refresh_tokens_token_key" ON "beacon_oauth_refresh_tokens"("token"); +CREATE INDEX "beacon_oauth_refresh_tokens_client_id_idx" ON "beacon_oauth_refresh_tokens"("client_id"); +CREATE INDEX "beacon_oauth_refresh_tokens_session_id_idx" ON "beacon_oauth_refresh_tokens"("session_id"); +CREATE INDEX "beacon_oauth_refresh_tokens_user_id_idx" ON "beacon_oauth_refresh_tokens"("user_id"); +CREATE INDEX "beacon_oauth_refresh_tokens_expires_at_idx" ON "beacon_oauth_refresh_tokens"("expires_at"); +CREATE INDEX "beacon_oauth_refresh_tokens_revoked_idx" ON "beacon_oauth_refresh_tokens"("revoked"); + +CREATE TABLE "beacon_oauth_access_tokens" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "session_id" TEXT, + "user_id" TEXT, + "reference_id" TEXT, + "refresh_id" TEXT, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "scopes" TEXT[] NOT NULL, + CONSTRAINT "beacon_oauth_access_tokens_pkey" PRIMARY KEY ("id"), + CONSTRAINT "beacon_oauth_access_tokens_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "beacon_oauth_clients"("client_id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "beacon_oauth_access_tokens_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "early_bird_auth_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "beacon_oauth_access_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "early_bird_users"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "beacon_oauth_access_tokens_refresh_id_fkey" FOREIGN KEY ("refresh_id") REFERENCES "beacon_oauth_refresh_tokens"("id") ON DELETE SET NULL ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "beacon_oauth_access_tokens_token_key" ON "beacon_oauth_access_tokens"("token"); +CREATE INDEX "beacon_oauth_access_tokens_client_id_idx" ON "beacon_oauth_access_tokens"("client_id"); +CREATE INDEX "beacon_oauth_access_tokens_session_id_idx" ON "beacon_oauth_access_tokens"("session_id"); +CREATE INDEX "beacon_oauth_access_tokens_user_id_idx" ON "beacon_oauth_access_tokens"("user_id"); +CREATE INDEX "beacon_oauth_access_tokens_refresh_id_idx" ON "beacon_oauth_access_tokens"("refresh_id"); +CREATE INDEX "beacon_oauth_access_tokens_expires_at_idx" ON "beacon_oauth_access_tokens"("expires_at"); + +CREATE TABLE "beacon_oauth_consents" ( + "id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "user_id" TEXT, + "reference_id" TEXT, + "scopes" TEXT[] NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "beacon_oauth_consents_pkey" PRIMARY KEY ("id"), + CONSTRAINT "beacon_oauth_consents_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "beacon_oauth_clients"("client_id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "beacon_oauth_consents_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "beacon_oauth_consents_client_id_idx" ON "beacon_oauth_consents"("client_id"); +CREATE INDEX "beacon_oauth_consents_user_id_idx" ON "beacon_oauth_consents"("user_id"); + +CREATE TABLE "beacon_jwks" ( + "id" TEXT NOT NULL, + "public_key" TEXT NOT NULL, + "private_key" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL, + "expires_at" TIMESTAMP(3), + CONSTRAINT "beacon_jwks_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "beacon_jwks_expires_at_idx" ON "beacon_jwks"("expires_at"); diff --git a/prisma/migrations/20260829050000_listener_durable_intervals/migration.sql b/prisma/migrations/20260829050000_listener_durable_intervals/migration.sql new file mode 100644 index 00000000..cadaf51f --- /dev/null +++ b/prisma/migrations/20260829050000_listener_durable_intervals/migration.sql @@ -0,0 +1,33 @@ +CREATE TABLE "early_bird_listening_intervals" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "account_id" TEXT NOT NULL, + "lease_id" UUID NOT NULL, + "lease_generation" INTEGER NOT NULL, + "presence_sequence" INTEGER NOT NULL, + "device_digest" CHAR(64) NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL, + "last_heartbeat_at" TIMESTAMP(3) NOT NULL, + "ended_at" TIMESTAMP(3), + "end_reason" VARCHAR(32), + "access_class" VARCHAR(32) NOT NULL, + "source_category" VARCHAR(16) NOT NULL DEFAULT 'beacon', + "synthetic" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "early_bird_listening_intervals_pkey" PRIMARY KEY ("id"), + CONSTRAINT "early_bird_listening_intervals_generation_check" CHECK ("lease_generation" > 0), + CONSTRAINT "early_bird_listening_intervals_sequence_check" CHECK ("presence_sequence" >= 0), + CONSTRAINT "early_bird_listening_intervals_order_check" CHECK ("ended_at" IS NULL OR "ended_at" >= "started_at"), + CONSTRAINT "early_bird_listening_intervals_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "early_bird_users"("id") ON DELETE CASCADE, + CONSTRAINT "early_bird_listening_intervals_lease_id_fkey" FOREIGN KEY ("lease_id") REFERENCES "early_bird_stream_leases"("id") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX "early_bird_listening_intervals_lease_id_lease_generation_presence_sequence_key" + ON "early_bird_listening_intervals"("lease_id", "lease_generation", "presence_sequence"); +CREATE INDEX "early_bird_listening_intervals_account_id_started_at_ended_at_idx" + ON "early_bird_listening_intervals"("account_id", "started_at", "ended_at"); +CREATE INDEX "early_bird_listening_intervals_ended_at_last_heartbeat_at_idx" + ON "early_bird_listening_intervals"("ended_at", "last_heartbeat_at"); + +COMMENT ON TABLE "early_bird_listening_intervals" IS + 'Server-observed Listener playback spans; open rows are bounded by last heartbeat plus the heartbeat grace window.'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71470e94..b60b2ff4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -83,6 +83,63 @@ enum ContributionState { WITHDRAWN } +enum EarlyBirdMembershipState { + PENDING + ACTIVE + GRACE + CANCELLED_PENDING_END + EXPIRED + REFUNDED + REVOKED +} + +enum EarlyBirdMembershipSource { + FREE + PAYPAL + MERCADO_PAGO +} + +enum EarlyBirdFounderContinuityState { + ACTIVE + CANCELLED_PENDING_END + GRACE + ENDED +} + +// Coarse, non-identifying regions used only for Listener presence. Exact +// locations never enter the application database. +enum ListenerMacroRegion { + NORTH_AMERICA + LATIN_AMERICA + EUROPE + AFRICA + ASIA + OCEANIA + UNKNOWN +} + +enum ListenerPresenceState { + IDLE + LISTENING +} + +enum ListenerWithdrawalProvider { + PAYPAL + MERCADO_PAGO + OTHER +} + +enum ListenerConsumerRequestKind { + WITHDRAWAL + SERVICE_CANCELLATION +} + +enum ListenerWithdrawalStatus { + RECEIVED + ACKNOWLEDGED + RESOLVED +} + model User { id String @id @default(uuid()) @db.Uuid email String @unique @@ -397,3 +454,557 @@ model SessionContribution { @@index([scheduledSessionId, state, createdAt, id]) @@map("session_contributions") } + +// EarlyBirds is a deliberately separate identity and entitlement domain. None +// of these rows can authorize weekend event, staff, LiveKit, or chat surfaces. +model EarlyBirdUser { + id String @id + name String + email String @unique + emailVerified Boolean @default(false) @map("email_verified") + image String? + securityRevision Int @default(1) @map("security_revision") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + identities EarlyBirdIdentity[] + authSessions EarlyBirdAuthSession[] + beaconProfile BeaconProfile? + oauthClients BeaconOAuthClient[] + oauthRefreshTokens BeaconOAuthRefreshToken[] + oauthAccessTokens BeaconOAuthAccessToken[] + oauthConsents BeaconOAuthConsent[] + accountActionTokens BeaconAccountActionToken[] + accountMailOutbox BeaconAccountMailOutbox[] + listenerAccountSubject ListenerAccountSubject? + listenerAccountSessions ListenerAccountSession[] + membership EarlyBirdMembershipProjection? + freeSchedule EarlyBirdFreeSchedule? + welcomeAccess EarlyBirdWelcomeAccess? + streamLeases EarlyBirdStreamLease[] + listeningIntervals EarlyBirdListeningInterval[] + listeningQuota EarlyBirdListeningQuotaCursor? + listeningBonuses EarlyBirdListeningBonusGrant[] + + @@map("early_bird_users") +} + +model EarlyBirdIdentity { + id String @id + providerId String @map("provider_id") + accountId String @map("account_id") + userId String @map("user_id") + user EarlyBirdUser @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? @map("access_token") @db.Text + refreshToken String? @map("refresh_token") @db.Text + idToken String? @map("id_token") @db.Text + accessTokenExpiresAt DateTime? @map("access_token_expires_at") + refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at") + scope String? + password String? @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([providerId, accountId]) + @@unique([userId]) + @@map("early_bird_identities") +} + +model EarlyBirdAuthSession { + id String @id + userId String @map("user_id") + user EarlyBirdUser @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + expiresAt DateTime @map("expires_at") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + securityRevision Int @default(1) @map("security_revision") + authorityEnvironment String @default("legacy") @map("authority_environment") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + oauthRefreshTokens BeaconOAuthRefreshToken[] + oauthAccessTokens BeaconOAuthAccessToken[] + + @@index([userId]) + @@index([expiresAt]) + @@map("early_bird_auth_sessions") +} + +model BeaconAccountAuthorityEnvironment { + id String @id + issuer String @unique + createdAt DateTime @default(now()) @map("created_at") + + @@map("beacon_account_authority_environment") +} + +model EarlyBirdVerification { + id String @id + identifier String + value String @db.Text + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([identifier]) + @@index([expiresAt]) + @@map("early_bird_verifications") +} + +// Provider-independent private Beacon profile. Provider metadata may seed a +// profile once, but never overwrites it after creation. +model BeaconProfile { + accountId String @id @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + displayName String @map("display_name") @db.VarChar(60) + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("beacon_profiles") +} + +model BeaconAccountActionToken { + id String @id @default(uuid()) @db.Uuid + tokenDigest String @unique @map("token_digest") @db.Char(64) + purpose String + accountId String @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + targetEmail String? @map("target_email") + locale String @default("en") + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([accountId, purpose, expiresAt]) + @@index([expiresAt]) + @@index([consumedAt]) + @@map("beacon_account_action_tokens") +} + +// Raw email/network values never enter this table. Keys are HMAC digests made +// with a deployment secret separate from the Account authentication secret. +model BeaconAccountAuthThrottle { + key String @id + kind String + windowStartedAt DateTime @map("window_started_at") + attempts Int @default(0) + blockedUntil DateTime? @map("blocked_until") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([updatedAt]) + @@map("beacon_account_auth_throttles") +} + +model BeaconAccountMailOutbox { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + purpose String @default("verify_email") + locale String @default("en") + recipient String + targetEmail String? @map("target_email") + sealedToken String? @map("sealed_token") @db.Text + tokenExpiresAt DateTime? @map("token_expires_at") + idempotencyKey String? @map("idempotency_key") + deliveryAttemptedAt DateTime? @map("delivery_attempted_at") + attempts Int @default(0) + generation Int @default(1) + nextAttemptAt DateTime @default(now()) @map("next_attempt_at") + lockedAt DateTime? @map("locked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([accountId, purpose, generation]) + @@index([nextAttemptAt, lockedAt]) + @@map("beacon_account_mail_outbox") +} + +model ListenerAccountSession { + id String @id @default(uuid()) @db.Uuid + tokenDigest String @unique @map("token_digest") @db.Char(64) + accountId String @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + issuer String + subject String + sid String + expiresAt DateTime @map("expires_at") + lastCheckedAt DateTime @map("last_checked_at") + revalidationLeaseUntil DateTime? @map("revalidation_lease_until") + synthetic Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") + + @@index([accountId]) + @@index([issuer, subject]) + @@index([issuer, sid]) + @@index([expiresAt]) + @@index([revalidationLeaseUntil]) + @@map("listener_account_sessions") +} + +model ListenerAccountSubject { + accountId String @id @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + issuer String + subject String + createdAt DateTime @default(now()) @map("created_at") + + @@unique([issuer, subject]) + @@map("listener_account_subjects") +} + +// Better Auth OAuth Provider storage. Clients are provisioned from the exact +// static contract; dynamic registration remains disabled. +model BeaconOAuthClient { + id String @id + clientId String @unique @map("client_id") + clientSecret String? @map("client_secret") @db.Text + disabled Boolean @default(false) + skipConsent Boolean? @map("skip_consent") + enableEndSession Boolean? @map("enable_end_session") + subjectType String? @map("subject_type") + scopes String[] + userId String? @map("user_id") + user EarlyBirdUser? @relation(fields: [userId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + name String? + uri String? + icon String? + contacts String[] + tos String? + policy String? + softwareId String? @map("software_id") + softwareVersion String? @map("software_version") + softwareStatement String? @map("software_statement") @db.Text + redirectUris String[] @map("redirect_uris") + postLogoutRedirectUris String[] @map("post_logout_redirect_uris") + tokenEndpointAuthMethod String? @map("token_endpoint_auth_method") + grantTypes String[] @map("grant_types") + responseTypes String[] @map("response_types") + public Boolean @default(false) + type String? + requirePKCE Boolean @default(true) @map("require_pkce") + referenceId String? @map("reference_id") + metadata Json? + + refreshTokens BeaconOAuthRefreshToken[] + accessTokens BeaconOAuthAccessToken[] + consents BeaconOAuthConsent[] + + @@index([userId]) + @@map("beacon_oauth_clients") +} + +model BeaconOAuthRefreshToken { + id String @id + token String @unique @db.Text + clientId String @map("client_id") + client BeaconOAuthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + sessionId String? @map("session_id") + session EarlyBirdAuthSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String @map("user_id") + user EarlyBirdUser @relation(fields: [userId], references: [id], onDelete: Cascade) + referenceId String? @map("reference_id") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revoked DateTime? + authTime DateTime? @map("auth_time") + scopes String[] + + accessTokens BeaconOAuthAccessToken[] + + @@index([clientId]) + @@index([sessionId]) + @@index([userId]) + @@index([expiresAt]) + @@index([revoked]) + @@map("beacon_oauth_refresh_tokens") +} + +model BeaconOAuthAccessToken { + id String @id + token String @unique @db.Text + clientId String @map("client_id") + client BeaconOAuthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + sessionId String? @map("session_id") + session EarlyBirdAuthSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String? @map("user_id") + user EarlyBirdUser? @relation(fields: [userId], references: [id], onDelete: SetNull) + referenceId String? @map("reference_id") + refreshId String? @map("refresh_id") + refresh BeaconOAuthRefreshToken? @relation(fields: [refreshId], references: [id], onDelete: SetNull) + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + scopes String[] + + @@index([clientId]) + @@index([sessionId]) + @@index([userId]) + @@index([refreshId]) + @@index([expiresAt]) + @@map("beacon_oauth_access_tokens") +} + +model BeaconOAuthConsent { + id String @id + clientId String @map("client_id") + client BeaconOAuthClient @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + userId String? @map("user_id") + user EarlyBirdUser? @relation(fields: [userId], references: [id], onDelete: Cascade) + referenceId String? @map("reference_id") + scopes String[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([clientId]) + @@index([userId]) + @@map("beacon_oauth_consents") +} + +model BeaconJwks { + id String @id + publicKey String @map("public_key") @db.Text + privateKey String @map("private_key") @db.Text + createdAt DateTime @map("created_at") + expiresAt DateTime? @map("expires_at") + + @@index([expiresAt]) + @@map("beacon_jwks") +} + +// HMAC-keyed abuse buckets for the Listener email fallback. Neither raw email +// addresses nor raw network addresses are persisted here. +model EarlyBirdMagicLinkThrottle { + key String @id + kind String + windowStartedAt DateTime @map("window_started_at") + attempts Int @default(0) + blockedUntil DateTime? @map("blocked_until") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([updatedAt]) + @@map("early_bird_magic_link_throttles") +} + +// Read-side projection of the canonical membership owned by +// proyecciones-mito. The revision and hash make delivery monotonic and +// idempotent; redirect URLs and local UI state are never authorization proof. +model EarlyBirdMembershipProjection { + id String @id @default(uuid()) @db.Uuid + accountId String @unique @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + revision Int + commandHash String @map("command_hash") @db.Char(64) + state EarlyBirdMembershipState + source EarlyBirdMembershipSource? + offerCode String? @map("offer_code") + offerRevision Int? @map("offer_revision") + effectiveAt DateTime @map("effective_at") + paidThrough DateTime? @map("paid_through") + graceUntil DateTime? @map("grace_until") + provider String? + amountMinor Int? @map("amount_minor") + currency String? @db.VarChar(3) + reasonCode String @map("reason_code") @db.VarChar(64) + synthetic Boolean @default(false) + founderContinuityEpisodeId String? @map("founder_continuity_episode_id") @db.Uuid + founderContinuityRevision Int? @map("founder_continuity_revision") + founderContinuityState EarlyBirdFounderContinuityState? @map("founder_continuity_state") + founderContinuityOfferCode String? @map("founder_continuity_offer_code") @db.VarChar(128) + founderContinuityOfferRevision Int? @map("founder_continuity_offer_revision") + founderContinuityCurrency String? @map("founder_continuity_currency") @db.Char(3) + founderContinuityAmountMinor Int? @map("founder_continuity_amount_minor") + founderContinuityBillingPeriod String? @map("founder_continuity_billing_period") @db.VarChar(32) + founderContinuityActivatedAt DateTime? @map("founder_continuity_activated_at") + founderContinuityServiceThrough DateTime? @map("founder_continuity_service_through") + founderContinuityEndedAt DateTime? @map("founder_continuity_ended_at") + founderContinuityTerminalReason String? @map("founder_continuity_terminal_reason") @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([state, paidThrough]) + @@index([founderContinuityState, founderContinuityServiceThrough]) + @@map("early_bird_membership_projections") +} + +// Account-bound ordinary Free access. This is deliberately independent from +// the canonical commerce membership projection: choosing a listening window +// can never fabricate a purchase or a Founding Listener entitlement. +model EarlyBirdFreeSchedule { + accountId String @id @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + timeZone String @map("time_zone") @db.VarChar(64) + localStartMinute Int @map("local_start_minute") + selectedAt DateTime @map("selected_at") + changeAllowedAt DateTime @map("change_allowed_at") + selectionRequestId String @unique @map("selection_request_id") @db.VarChar(64) + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([changeAllowedAt]) + @@map("early_bird_free_schedules") +} + +// One explicit, account-bound first listen before a person commits to a daily +// Free schedule. The row is the durable consumed marker; it is never created +// by registration, Free for All, membership or a page view. +model EarlyBirdWelcomeAccess { + accountId String @id @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + startedAt DateTime @map("started_at") + endsAt DateTime @map("ends_at") + activationRequestId String @unique @map("activation_request_id") @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([endsAt]) + @@map("early_bird_welcome_accesses") +} + +model EarlyBirdStreamLease { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + deviceDigest String @map("device_digest") @db.Char(64) + presence ListenerPresenceState @default(IDLE) + macroRegion ListenerMacroRegion @default(UNKNOWN) @map("macro_region") + presenceUpdatedAt DateTime? @map("presence_updated_at") + generation Int @default(1) + presenceSequence Int @default(0) @map("presence_sequence") + createdAt DateTime @default(now()) @map("created_at") + lastSeenAt DateTime @map("last_seen_at") + expiresAt DateTime @map("expires_at") + evictedAt DateTime? @map("evicted_at") + listeningIntervals EarlyBirdListeningInterval[] + + @@unique([accountId, deviceDigest]) + @@index([accountId, evictedAt, expiresAt, lastSeenAt]) + @@index([accountId, presence, evictedAt, expiresAt]) + @@index([presence, presenceUpdatedAt, expiresAt]) + @@map("early_bird_stream_leases") +} + +// Durable server-observed playback spans. Browser events can request a +// presence transition but cannot declare elapsed listening time. +model EarlyBirdListeningInterval { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + leaseId String @map("lease_id") @db.Uuid + lease EarlyBirdStreamLease @relation(fields: [leaseId], references: [id], onDelete: Cascade) + leaseGeneration Int @map("lease_generation") + presenceSequence Int @map("presence_sequence") + deviceDigest String @map("device_digest") @db.Char(64) + startedAt DateTime @map("started_at") + lastHeartbeatAt DateTime @map("last_heartbeat_at") + endedAt DateTime? @map("ended_at") + endReason String? @map("end_reason") @db.VarChar(32) + accessClass String @map("access_class") @db.VarChar(32) + sourceCategory String @default("beacon") @map("source_category") @db.VarChar(16) + synthetic Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([leaseId, leaseGeneration, presenceSequence]) + @@index([accountId, startedAt, endedAt]) + @@index([endedAt, lastHeartbeatAt]) + @@map("early_bird_listening_intervals") +} + +// Compact enforcement cursor for the personal seven-day Listener quota. The +// anchor is created only when an ordinary-Free account is first observed in +// LISTENING presence and is never moved after that. +model EarlyBirdListeningQuotaCursor { + accountId String @id @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + policyVersion String @map("policy_version") @db.VarChar(32) + cycleAnchorAt DateTime? @map("cycle_anchor_at") + cycleStartedAt DateTime? @map("cycle_started_at") + cycleEndsAt DateTime? @map("cycle_ends_at") + baseConsumedMs Int @default(0) @map("base_consumed_ms") + settledThrough DateTime? @map("settled_through") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([cycleEndsAt]) + @@map("early_bird_listening_quota_cursors") +} + +// Server-only, audited quota bonus. Identity is the opaque EarlyBird account +// FK; issuer/source/reason are bounded codes rather than free-form PII. +model EarlyBirdListeningBonusGrant { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + amountMs Int @map("amount_ms") + consumedMs Int @default(0) @map("consumed_ms") + fullyConsumed Boolean @default(false) @map("fully_consumed") + issuerCode String @map("issuer_code") @db.VarChar(32) + sourceCode String @map("source_code") @db.VarChar(32) + reasonCode String @map("reason_code") @db.VarChar(32) + idempotencyKey String @map("idempotency_key") @db.VarChar(128) + requestHash String @map("request_hash") @db.Char(64) + grantedAt DateTime @map("granted_at") + availableFrom DateTime @map("available_from") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([accountId, issuerCode, idempotencyKey]) + @@index([accountId, fullyConsumed, expiresAt, availableFrom]) + @@map("early_bird_listening_bonus_grants") +} + +// One-row database policy marker. Authorization code must recognize this value +// before consulting any Free policy state so an incompatible image fails closed. +model EarlyBirdListenerAuthorityPolicy { + id Int @id + policyVersion String @map("policy_version") @db.VarChar(32) + activatedAt DateTime @default(now()) @map("activated_at") + + @@map("early_bird_listener_authority_policy") +} + +// Public consumer-withdrawal requests are deliberately independent from +// Listener identity and payment authority. A person can submit without an +// account; operators must verify the payment in the canonical provider before +// taking any action. Receipt codes are never stored in plaintext. +model ListenerWithdrawalRequest { + id String @id @default(uuid()) @db.Uuid + receiptDigest String @unique @map("receipt_digest") @db.Char(64) + receiptLastFour String @map("receipt_last_four") @db.Char(4) + idempotencyKey String @unique @map("idempotency_key") @db.Uuid + requestHash String @map("request_hash") @db.Char(64) + contactEmail String @map("contact_email") @db.VarChar(254) + requestKind ListenerConsumerRequestKind @map("request_kind") + provider ListenerWithdrawalProvider + purchaseDate DateTime? @map("purchase_date") @db.Date + locale String @db.Char(2) + status ListenerWithdrawalStatus @default(RECEIVED) + acknowledgedAt DateTime? @map("acknowledged_at") + acknowledgedBy String? @map("acknowledged_by") @db.VarChar(64) + resolvedAt DateTime? @map("resolved_at") + resolvedBy String? @map("resolved_by") @db.VarChar(64) + resolutionCode String? @map("resolution_code") @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([status, createdAt]) + @@index([createdAt]) + @@map("listener_withdrawal_requests") +} + +// HMAC-keyed network/email buckets plus one fixed global bucket. Raw IP and +// email values are neither persisted here nor exposed to operators. +model ListenerWithdrawalThrottle { + key String @id @db.VarChar(72) + windowStartedAt DateTime @map("window_started_at") + attempts Int @default(0) + blockedUntil DateTime? @map("blocked_until") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([updatedAt]) + @@map("listener_withdrawal_throttles") +} diff --git a/public/assets/hb-global-nav.js b/public/assets/hb-global-nav.js new file mode 100644 index 00000000..ee4d646e --- /dev/null +++ b/public/assets/hb-global-nav.js @@ -0,0 +1,341 @@ +/* Harmonic Beacon global navigation — canonical cross-product asset. */ +(function () { + 'use strict'; + + var MAIN_ORIGIN = 'https://harmonicbeacon.com'; + var LISTENER_ORIGIN = 'https://listen.harmonicbeacon.com'; + var LISTENER_STAGING_ORIGIN = 'https://earlybirds-staging.harmonicbeacon.com'; + var LIVE_ORIGIN = 'https://live.harmonicbeacon.com'; + var LIVE_STAGING_ORIGIN = 'https://live-staging.harmonicbeacon.com'; + var ACCOUNT_ORIGIN = 'https://account.harmonicbeacon.com'; + var ACCOUNT_STAGING_ORIGIN = 'https://account-staging.harmonicbeacon.com'; + var ELEMENT_NAME = 'hb-global-nav'; + + var links = [ + { key: 'events', href: LIVE_ORIGIN + '/', en: 'Events', es: 'Eventos' }, + { key: 'listen', href: LISTENER_ORIGIN + '/', en: 'Listen', es: 'Escuchar' }, + { key: 'news', href: MAIN_ORIGIN + '/eventos/', en: 'News', es: 'Novedades' }, + { key: 'why', href: MAIN_ORIGIN + '/#porque', en: 'Why it works', es: 'Por qué funciona' }, + { key: 'foundation', href: MAIN_ORIGIN + '/#foundation', en: 'HIT', es: 'HIT' } + ]; + + function validLanguage(value) { + return value === 'es' || value === 'en' ? value : null; + } + + function storedLanguage() { + try { + return validLanguage(localStorage.getItem('hb-locale')) || validLanguage(localStorage.getItem('hb-lang')); + } catch (error) { + return null; + } + } + + function currentLanguage() { + var params = new URLSearchParams(location.search); + var documentLanguage = validLanguage(document.documentElement.getAttribute('data-lang')) || + validLanguage(document.documentElement.getAttribute('data-active-lang')); + var mainSite = location.hostname === 'harmonicbeacon.com' || location.hostname === 'www.harmonicbeacon.com'; + return validLanguage(params.get('lang')) || + (mainSite ? storedLanguage() : documentLanguage) || + (mainSite ? documentLanguage : storedLanguage()) || + ((document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('es') === 0 ? 'es' : 'en'); + } + + function persistLanguage(language) { + document.documentElement.lang = language; + document.documentElement.setAttribute('data-lang', language); + document.documentElement.setAttribute('data-active-lang', language); + try { + localStorage.setItem('hb-lang', language); + localStorage.setItem('hb-locale', language); + } catch (error) { + // Hardened browsers may disable local storage; the cookie still works. + } + document.cookie = 'hb_locale=' + language + '; Path=/; Max-Age=31536000; SameSite=Lax'; + } + + function applyLinkedLanguage() { + var url = new URL(location.href); + var requested = validLanguage(url.searchParams.get('lang')); + if (!requested) return false; + var rendered = validLanguage(document.documentElement.getAttribute('data-lang')) || + validLanguage(document.documentElement.getAttribute('data-active-lang')); + persistLanguage(requested); + url.searchParams.delete('lang'); + if (location.hostname === 'harmonicbeacon.com' || location.hostname === 'www.harmonicbeacon.com') { + history.replaceState(history.state, '', url.pathname + url.search + url.hash); + return false; + } + if (rendered !== requested) { + location.replace(url.toString()); + return true; + } + history.replaceState(history.state, '', url.pathname + url.search + url.hash); + return false; + } + + function localizedHref(href, language) { + var url = new URL(href); + url.searchParams.set('lang', language); + return url.toString(); + } + + function activeKey() { + var host = location.hostname.toLowerCase(); + if (host === 'listen.harmonicbeacon.com' || host === 'earlybirds-staging.harmonicbeacon.com') return 'listen'; + if (host === 'live.harmonicbeacon.com') return 'events'; + if (location.pathname.indexOf('/eventos') === 0) return 'news'; + if (location.hash === '#porque') return 'why'; + if (location.hash === '#foundation') return 'foundation'; + return null; + } + + function accountControlAvailable(element) { + var host = location.hostname.toLowerCase(); + return host === 'earlybirds-staging.harmonicbeacon.com' || + host === 'live-staging.harmonicbeacon.com' || + host === 'account-staging.harmonicbeacon.com' || + element.hasAttribute('data-account-available'); + } + + function accountOrigin() { + var host = location.hostname.toLowerCase(); + return host === 'earlybirds-staging.harmonicbeacon.com' || + host === 'live-staging.harmonicbeacon.com' || + host === 'account-staging.harmonicbeacon.com' + ? ACCOUNT_STAGING_ORIGIN + : ACCOUNT_ORIGIN; + } + + function accountReturnTo() { + var host = location.hostname.toLowerCase(); + if (host === 'listen.harmonicbeacon.com') return LISTENER_ORIGIN + '/'; + if (host === 'earlybirds-staging.harmonicbeacon.com') { + return LISTENER_STAGING_ORIGIN + '/'; + } + if (host === 'live-staging.harmonicbeacon.com') return LIVE_STAGING_ORIGIN + '/'; + if (host === 'live.harmonicbeacon.com') return LIVE_ORIGIN + '/'; + return MAIN_ORIGIN + '/'; + } + + function accountPageHref(language) { + var url = new URL('/account', accountOrigin()); + url.searchParams.set('lang', language); + url.searchParams.set('return_to', accountReturnTo()); + return url.toString(); + } + + function label(item, language) { + return language === 'es' ? item.es : item.en; + } + + function beaconMarkPath() { + var points = []; + for (var index = 0; index <= 280; index += 1) { + var angle = Math.PI * 2 * index / 280; + var x = 100 + 92 * Math.cos(angle * 3); + var y = 100 + 92 * Math.sin(angle * 2); + points.push((index === 0 ? 'M' : 'L') + x.toFixed(2) + ' ' + y.toFixed(2)); + } + return points.join(' '); + } + + var style = ` + :host { display:block; height:72px; color:#E9E0D0; font-family:Inter,system-ui,-apple-system,sans-serif; } + :host([overlay]) { height:0; } + * { box-sizing:border-box; } + a { color:inherit; text-decoration:none; } + .nav { position:fixed; inset:0 0 auto; z-index:2147483000; min-height:72px; border-bottom:1px solid rgba(244,238,226,.08); background:rgba(22,18,13,.82); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); } + .inner { width:100%; max-width:1180px; min-height:72px; margin:0 auto; padding:12px 24px; display:flex; align-items:center; justify-content:space-between; gap:18px; } + .brand { display:flex; align-items:center; gap:10px; flex:0 0 auto; border-radius:8px; } + .mark { width:30px; height:30px; display:block; color:#C9A24E; filter:drop-shadow(0 2px 14px rgba(201,162,78,.18)); } + .mark path { fill:none; stroke:currentColor; stroke-width:3.4; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke; } + .wordmark { color:#F4EEE2; font-size:12px; font-weight:600; letter-spacing:.18em; text-transform:uppercase; white-space:nowrap; } + .links { display:flex; align-items:center; justify-content:flex-end; gap:1px; margin:0; padding:0; list-style:none; } + .links a { position:relative; display:block; padding:9px 8px; border-radius:999px; color:#ADA089; font-size:10.5px; font-weight:500; letter-spacing:.12em; line-height:1.2; text-transform:uppercase; white-space:nowrap; transition:color .25s ease,background .25s ease; } + .links a:hover { color:#F4EEE2; background:rgba(244,238,226,.035); } + .links a[aria-current=page] { color:#C9A24E; } + .links a[aria-current=page]::after { content:""; position:absolute; left:9px; right:9px; bottom:3px; height:1.5px; border-radius:2px; background:#C9A24E; } + .language { min-width:60px; min-height:44px; padding:0 5px; border:0; border-radius:999px; color:#8A7F6B; background:transparent; cursor:pointer; font:500 11px/1 Inter,system-ui,sans-serif; letter-spacing:.1em; } + .language strong { color:#C9A24E; font-weight:600; } + .sep { opacity:.42; padding:0 2px; } + .account-control { position:relative; width:44px; height:44px; flex:0 0 44px; } + .account-trigger { position:absolute; z-index:2; inset:0; display:grid; place-items:center; width:44px; height:44px; padding:0; border:1px solid rgba(201,162,78,.32); border-radius:999px; color:#E9E0D0; background:rgba(244,238,226,.045); cursor:pointer; transition:color .2s ease,border-color .2s ease,background .2s ease; } + .account-trigger:hover,.account-trigger[aria-expanded=true] { color:#F4EEE2; border-color:rgba(201,162,78,.62); background:rgba(201,162,78,.12); } + .account-trigger.signed-in { border-color:rgba(201,162,78,.72); background:rgba(201,162,78,.1); } + .account-trigger.signed-in::after { content:""; position:absolute; right:3px; bottom:3px; width:7px; height:7px; border:2px solid #16120D; border-radius:999px; background:#C9A24E; } + .account-trigger svg { width:22px; height:22px; fill:none; stroke:currentColor; stroke-width:1.65; stroke-linecap:round; stroke-linejoin:round; } + .account-menu { position:absolute; z-index:4; top:calc(100% + 8px); right:0; min-width:164px; padding:6px; border:1px solid rgba(201,162,78,.34); border-radius:12px; background:#16120D; box-shadow:0 18px 48px rgba(0,0,0,.34); } + .account-menu[hidden] { display:none; } + .account-menu slot { display:block; } + .account-menu a { display:flex; min-height:44px; align-items:center; padding:10px 12px; border-radius:8px; color:#E9E0D0; font-size:11px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; white-space:nowrap; } + .account-menu a:hover { color:#F4EEE2; background:rgba(201,162,78,.1); } + .toggle { display:none; width:44px; height:44px; padding:0 10px; border:0; background:transparent; cursor:pointer; } + .toggle span { display:block; height:1.5px; margin:5px 0; background:#ADA089; transition:transform .25s ease,opacity .2s ease; } + .toggle[aria-expanded=true] span:nth-child(1) { transform:translateY(6.5px) rotate(45deg); } + .toggle[aria-expanded=true] span:nth-child(2) { opacity:0; } + .toggle[aria-expanded=true] span:nth-child(3) { transform:translateY(-6.5px) rotate(-45deg); } + .mobile { display:none; border-top:1px solid rgba(244,238,226,.08); background:rgba(22,18,13,.98); } + .mobile.open { display:block; } + .mobile ul { margin:0; padding:8px 24px 18px; list-style:none; } + .mobile a { display:block; min-height:44px; padding:13px 0; border-top:1px solid rgba(244,238,226,.08); color:#E9E0D0; font-size:12px; letter-spacing:.14em; text-transform:uppercase; } + .mobile a[aria-current=page] { color:#C9A24E; } + :focus-visible { outline:2px solid #C9A24E; outline-offset:3px; } + @media (max-width:1120px) { .links { display:none; } .toggle { display:block; } .inner { min-height:68px; padding-top:10px; padding-bottom:10px; } :host { height:68px; } :host([overlay]) { height:0; } } + @media (max-width:430px) { .inner { padding-left:16px; padding-right:12px; } .wordmark { font-size:10.5px; letter-spacing:.14em; } .mark { width:27px; height:27px; } .language { min-width:54px; } } + @media (max-width:365px) { .wordmark { display:none; } } + @media (prefers-reduced-motion:reduce) { * { scroll-behavior:auto !important; transition:none !important; } } + @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))) { .nav { background:#16120D; } } + `; + + function isCockpitEmbed() { + return window.self !== window.top && new URLSearchParams(window.location.search).get('surface') === 'cockpit'; + } + + class HarmonicBeaconGlobalNav extends HTMLElement { + static get observedAttributes() { + return ['data-account-available', 'data-account-signed-in']; + } + + attributeChangedCallback(name, previous, next) { + if (this.constructor.observedAttributes.includes(name) && previous !== next && this.shadowRoot) this.render(); + } + + connectedCallback() { + if (this.shadowRoot) return; + // The conductor cockpit embeds the ordinary room as an operational + // surface. Its outer document already owns the product navigation; + // repeating it inside the iframe wastes scarce room space and creates + // two competing global headers. + if (isCockpitEmbed()) { + this.hidden = true; + return; + } + this.language = currentLanguage(); + this.attachShadow({ mode: 'open' }); + this.render(); + this.observer = new MutationObserver(() => { + var next = currentLanguage(); + if (next !== this.language) { + this.language = next; + this.render(); + } + }); + this.observer.observe(document.documentElement, { attributes:true, attributeFilter:['lang','data-lang','data-active-lang'] }); + } + + disconnectedCallback() { + if (this.observer) this.observer.disconnect(); + if (this.outsideClick) document.removeEventListener('click', this.outsideClick); + } + + render() { + var language = this.language; + var active = this.getAttribute('data-surface') || activeKey(); + var items = links.map(function (item) { + var current = active === item.key ? ' aria-current="page"' : ''; + return '
  • ' + label(item, language) + '
  • '; + }).join(''); + var brandHref = localizedHref(MAIN_ORIGIN + '/', language); + var menuLabel = language === 'es' ? 'Menú' : 'Menu'; + var navLabel = language === 'es' ? 'Navegación principal' : 'Primary navigation'; + var accountSignedIn = this.hasAttribute('data-account-signed-in'); + var userMenuLabel = language === 'es' + ? (accountSignedIn ? 'Menú de usuario, sesión iniciada' : 'Menú de usuario') + : (accountSignedIn ? 'User menu, signed in' : 'User menu'); + var accountLabel = language === 'es' ? 'Cuenta' : 'Account'; + var accountControl = accountControlAvailable(this) + ? '' + : ''; + this.shadowRoot.innerHTML = '' + + ''; + this.shadowRoot.querySelector('.' + language).outerHTML = '' + language.toUpperCase() + ''; + var languageButton = this.shadowRoot.querySelector('.language'); + languageButton.addEventListener('click', () => { + var next = this.language === 'es' ? 'en' : 'es'; + persistLanguage(next); + window.dispatchEvent(new CustomEvent('hb-language-change', { detail:{ language:next } })); + this.language = next; + this.render(); + if (location.hostname !== 'harmonicbeacon.com' && location.hostname !== 'www.harmonicbeacon.com') location.reload(); + }); + var accountTrigger = this.shadowRoot.querySelector('.account-trigger'); + var accountMenu = this.shadowRoot.querySelector('.account-menu'); + if (accountTrigger && accountMenu) { + var accountMenuSlot = accountMenu.querySelector('slot'); + var accountMenuItems = function () { + var assigned = accountMenuSlot.assignedElements({ flatten:true }); + if (!assigned.length) return Array.from(accountMenu.querySelectorAll('[role="menuitem"]')); + return assigned.flatMap(function (element) { + var items = element.matches('[role="menuitem"]') ? [element] : []; + return items.concat(Array.from(element.querySelectorAll('[role="menuitem"]'))); + }); + }; + var setAccountMenuOpen = function (open) { + accountTrigger.setAttribute('aria-expanded', open ? 'true' : 'false'); + accountMenu.hidden = !open; + }; + accountTrigger.addEventListener('click', function () { + setAccountMenuOpen(accountTrigger.getAttribute('aria-expanded') !== 'true'); + }); + accountTrigger.addEventListener('keydown', function (event) { + if (event.key !== 'ArrowDown') return; + event.preventDefault(); + setAccountMenuOpen(true); + var firstItem = accountMenuItems()[0]; + if (firstItem) firstItem.focus(); + }); + accountMenu.addEventListener('keydown', function (event) { + if (event.key !== 'Escape') return; + setAccountMenuOpen(false); + accountTrigger.focus(); + }); + accountTrigger.addEventListener('keydown', function (event) { + if (event.key !== 'Escape') return; + setAccountMenuOpen(false); + accountTrigger.focus(); + }); + if (this.outsideClick) document.removeEventListener('click', this.outsideClick); + this.outsideClick = function (event) { + if (!event.composedPath().includes(this)) setAccountMenuOpen(false); + }.bind(this); + document.addEventListener('click', this.outsideClick); + } + var toggle = this.shadowRoot.querySelector('.toggle'); + var mobile = this.shadowRoot.querySelector('.mobile'); + toggle.addEventListener('click', function () { + var open = toggle.getAttribute('aria-expanded') !== 'true'; + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + mobile.classList.toggle('open', open); + }); + mobile.querySelectorAll('a').forEach(function (link) { + link.addEventListener('click', function () { + toggle.setAttribute('aria-expanded', 'false'); + mobile.classList.remove('open'); + }); + }); + } + } + + if (applyLinkedLanguage()) return; + if (!customElements.get(ELEMENT_NAME)) customElements.define(ELEMENT_NAME, HarmonicBeaconGlobalNav); + if (!document.querySelector(ELEMENT_NAME)) { + var element = document.createElement(ELEMENT_NAME); + element.setAttribute('overlay', ''); + document.body.insertBefore(element, document.body.firstChild); + } +})(); diff --git a/scripts/audit-production-dependencies.ts b/scripts/audit-production-dependencies.ts new file mode 100644 index 00000000..c2389c7d --- /dev/null +++ b/scripts/audit-production-dependencies.ts @@ -0,0 +1,70 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { + evaluateProductionAudit, + type InstalledAuditPackage, + type InstalledAuditPackages, +} from '../src/lib/production-audit-guard'; + +function readInstalledPackage(relativePath: string): InstalledAuditPackage { + const packagePath = path.join(process.cwd(), relativePath, 'package.json'); + const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as { + version?: unknown; + dependencies?: unknown; + }; + + if (typeof parsed.version !== 'string') { + throw new Error(`Missing package version at ${relativePath}`); + } + + const dependencies = parsed.dependencies ?? {}; + if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) { + throw new Error(`Missing dependency map at ${relativePath}`); + } + + return { + version: parsed.version, + dependencies: dependencies as Record, + }; +} + +function readInstalledPackages(): InstalledAuditPackages { + return { + prisma: readInstalledPackage('node_modules/prisma'), + '@prisma/config': readInstalledPackage('node_modules/@prisma/config'), + 'deepmerge-ts': readInstalledPackage('node_modules/deepmerge-ts'), + }; +} + +const audit = spawnSync( + 'npm', + ['audit', '--omit=dev', '--audit-level=high', '--json'], + { cwd: process.cwd(), encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, +); + +if (audit.error || audit.signal || (audit.status !== 0 && audit.status !== 1)) { + console.error(`Production dependency audit could not complete (status ${audit.status ?? 'unknown'}).`); + process.exit(1); +} + +let report: unknown; +try { + report = JSON.parse(audit.stdout); +} catch { + console.error('Production dependency audit returned invalid JSON.'); + process.exit(1); +} + +let installed: InstalledAuditPackages | undefined; +try { + installed = readInstalledPackages(); +} catch { + installed = undefined; +} + +const decision = evaluateProductionAudit(report, installed); +const log = decision.ok ? console.log : console.error; +log(decision.message); +process.exit(decision.ok ? 0 : 1); diff --git a/scripts/beacon-account/activate-social-provider.sh b/scripts/beacon-account/activate-social-provider.sh new file mode 100755 index 00000000..d6491cd7 --- /dev/null +++ b/scripts/beacon-account/activate-social-provider.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +test "$(id -u)" -eq 0 || account_fail 'run as root' +environment=${1:?usage: activate-social-provider.sh staging|production google|apple /secure/deploy.env} +provider=${2:?usage: activate-social-provider.sh staging|production google|apple /secure/deploy.env} +ACCOUNT_DEPLOY_FILE=${3:?usage: activate-social-provider.sh staging|production google|apple /secure/deploy.env} +export ACCOUNT_DEPLOY_FILE +case "$environment" in staging|production) ;; *) account_fail 'environment must be staging or production' ;; esac +case "$provider" in google|apple) ;; *) account_fail 'provider must be google or apple' ;; esac + +account_load_deploy_env "$ACCOUNT_DEPLOY_FILE" +root=$(account_repo_root) +image="harmonic-beacon/account:$BEACON_ACCOUNT_GIT_SHA" +account_env=$BEACON_ACCOUNT_STAGING_ENV_FILE +test "$environment" != production || account_env=$BEACON_ACCOUNT_PRODUCTION_ENV_FILE +bundle="/etc/harmonic-beacon/account-provider-$environment-$provider.env" +state_root=/var/lib/harmonic-beacon/account-social-providers +stamp=$(date -u +%Y%m%dT%H%M%SZ) +activation_state="$state_root/$environment-$provider-$BEACON_ACCOUNT_GIT_SHA-$stamp" +container=$(account_container_name "$environment") +worker=$(account_mail_worker_container_name "$environment") +database_container=beacon-account-account-staging-postgres-1 +test "$environment" != production || database_container=earlybirds-preview-postgres-1 + +exec 9>"/run/lock/beacon-account-$environment.lock" +flock -n 9 || account_fail "another $environment Account operation is active" +account_require_private_file "$bundle" +test "$(git -C "$root" rev-parse HEAD)" = "$BEACON_ACCOUNT_GIT_SHA" || account_fail 'release checkout SHA mismatch' +test -z "$(git -C "$root" status --porcelain)" || account_fail 'release checkout is dirty' +docker image inspect "$image" >/dev/null 2>&1 || account_fail 'exact Account image is missing' +baked_sha=$(docker image inspect "$image" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) +test "$baked_sha" = "$BEACON_ACCOUNT_GIT_SHA" || account_fail 'Account image provenance mismatch' +account_verify_running "$environment" "$BEACON_ACCOUNT_GIT_SHA" "$BEACON_ACCOUNT_IMAGE_TAG" 1 +test "$(docker inspect "$container" --format '{{.Config.Image}}')" = "$image" || account_fail 'running Account image mismatch' + +if test -e "$state_root"; then + test -d "$state_root" && test ! -L "$state_root" || account_fail 'provider state root must be a regular directory' + test "$(stat -c '%U:%G:%a' "$state_root")" = root:root:700 || account_fail 'provider state root must be root:root 0700' +else + install -d -o root -g root -m 0700 "$state_root" +fi +test ! -e "$activation_state" || account_fail 'provider activation state already exists' +install -d -o root -g root -m 0700 "$activation_state" +install -o root -g root -m 0600 "$account_env" "$activation_state/previous.env" +printf '%s\n' "$environment" > "$activation_state/environment.txt" +printf '%s\n' "$provider" > "$activation_state/provider.txt" +printf '%s\n' "$BEACON_ACCOUNT_GIT_SHA" > "$activation_state/sha.txt" +docker inspect "$container" --format '{{.Id}}' > "$activation_state/app-id.before" +docker inspect "$worker" --format '{{.Id}}' > "$activation_state/worker-id.before" +docker inspect "$database_container" --format '{{.Id}}' > "$activation_state/database-id.before" +chmod 0600 "$activation_state"/*.txt "$activation_state"/*.before + +cleanup_before_cutover() { + status=$? + trap - EXIT HUP INT TERM + if test "$status" -ne 0; then rm -rf "$activation_state"; fi + exit "$status" +} +trap cleanup_before_cutover EXIT HUP INT TERM + +if test "$environment" = production; then + backup=$(account_backup_production) +else + backup=$(account_backup_staging) +fi +printf '%s\n' "$backup" > "$activation_state/database-backup.txt" +sha256sum "$backup" | awk '{print $1}' > "$activation_state/database-backup.sha256" +chmod 0600 "$activation_state/database-backup.txt" +chmod 0600 "$activation_state/database-backup.sha256" + +docker run --rm --pull never --network none --read-only --user 0:0 --cap-drop ALL \ + --security-opt no-new-privileges \ + --mount "type=bind,src=$account_env,dst=/run/account.env,readonly" \ + --mount "type=bind,src=$bundle,dst=/run/provider.env,readonly" \ + --mount "type=bind,src=$activation_state,dst=/run/state" \ + --entrypoint node "$image" /app/scripts/beacon-account/social-provider-env.mjs \ + /run/account.env /run/provider.env /run/state/candidate.env "$environment" "$provider" +account_require_private_file "$activation_state/candidate.env" + +production_candidate=$BEACON_ACCOUNT_PRODUCTION_ENV_FILE +staging_candidate=$BEACON_ACCOUNT_STAGING_ENV_FILE +test "$environment" != production || production_candidate=$activation_state/candidate.env +test "$environment" != staging || staging_candidate=$activation_state/candidate.env +docker run --rm --pull never --network none --read-only --cap-drop ALL --user 0:0 \ + --security-opt no-new-privileges \ + --mount "type=bind,src=$production_candidate,dst=/run/account-production.env,readonly" \ + --mount "type=bind,src=$staging_candidate,dst=/run/account-staging.env,readonly" \ + --mount "type=bind,src=$BEACON_ACCOUNT_STAGING_DB_ENV_FILE,dst=/run/account-staging-database.env,readonly" \ + --mount "type=bind,src=$BEACON_ACCOUNT_MAIL_WORKER_PRODUCTION_ENV_FILE,dst=/run/account-mail-worker-production.env,readonly" \ + --mount "type=bind,src=$BEACON_ACCOUNT_MAIL_WORKER_STAGING_ENV_FILE,dst=/run/account-mail-worker-staging.env,readonly" \ + --entrypoint node "$image" /app/ops/beacon-account/validate.mjs \ + /run/account-production.env /run/account-staging.env /run/account-staging-database.env \ + /run/account-mail-worker-production.env /run/account-mail-worker-staging.env + +cutover_started=0 +rollback_on_failure() { + status=$? + trap - EXIT HUP INT TERM + if test "$status" -ne 0 && test "$cutover_started" -eq 1; then + echo 'Beacon Account provider activation failed; restoring the previous environment.' >&2 + temporary="${account_env}.rollback-$$" + install -o root -g root -m 0600 "$activation_state/previous.env" "$temporary" || true + mv -T "$temporary" "$account_env" || true + account_compose up -d --no-deps --force-recreate --no-build "account-$environment" || true + account_wait_healthy "$container" || true + elif test "$status" -ne 0; then + rm -rf "$activation_state" + fi + exit "$status" +} +trap rollback_on_failure EXIT +trap 'exit 130' HUP INT TERM + +temporary="${account_env}.provider-$$" +cutover_started=1 +install -o root -g root -m 0600 "$activation_state/candidate.env" "$temporary" +mv -T "$temporary" "$account_env" +rm -f "$activation_state/candidate.env" +account_compose up -d --no-deps --force-recreate --no-build "account-$environment" +account_wait_healthy "$container" +account_verify_running "$environment" "$BEACON_ACCOUNT_GIT_SHA" "$BEACON_ACCOUNT_IMAGE_TAG" 1 +"$root/scripts/beacon-account/health-smoke.sh" \ + "$environment" "$ACCOUNT_DEPLOY_FILE" "$BEACON_ACCOUNT_GIT_SHA" 1 1 +test "$(docker inspect "$worker" --format '{{.Id}}')" = "$(cat "$activation_state/worker-id.before")" || + account_fail 'mail worker changed during provider activation' +test "$(docker inspect "$database_container" --format '{{.Id}}')" = "$(cat "$activation_state/database-id.before")" || + account_fail 'database changed during provider activation' + +origin=https://account.harmonicbeacon.com +test "$environment" != staging || origin=https://account-staging.harmonicbeacon.com +page="$activation_state/account-page.html" +curl --fail --silent --show-error --proto '=https' --connect-timeout 3 --max-time 8 \ + -H 'Accept-Language: en' "$origin/account?lang=en" > "$page" +label='Continue with Google' +test "$provider" != apple || label='Continue with Apple' +grep -Fq "$label" "$page" || account_fail 'enabled provider is absent from the Account page' +grep -Fq 'Sign in' "$page" || account_fail 'email and password sign-in is absent from the Account page' +rm -f "$page" + +{ + printf 'activated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'environment=%s\n' "$environment" + printf 'provider=%s\n' "$provider" + printf 'sha=%s\n' "$BEACON_ACCOUNT_GIT_SHA" + printf 'database_backup=%s\n' "$backup" + printf 'readiness=pass\nprovider_ui=pass\nmail_worker=unchanged\n' +} > "$activation_state/result.txt" +chmod 0600 "$activation_state/result.txt" +(cd "$activation_state" && sha256sum previous.env environment.txt provider.txt sha.txt app-id.before \ + worker-id.before database-id.before database-backup.txt database-backup.sha256 result.txt > SHA256SUMS) +chmod 0600 "$activation_state/SHA256SUMS" +last_tmp="$state_root/last-activation.tmp-$$" +printf '%s\n' "$activation_state" > "$last_tmp" +chmod 0600 "$last_tmp" +mv -T "$last_tmp" "$state_root/last-activation" + +cutover_started=0 +trap - EXIT HUP INT TERM +echo "Beacon Account $provider is enabled and healthy in $environment at exact SHA $BEACON_ACCOUNT_GIT_SHA." +echo "Rollback state: $activation_state" diff --git a/scripts/beacon-account/check-migrations.mjs b/scripts/beacon-account/check-migrations.mjs new file mode 100644 index 00000000..1893503c --- /dev/null +++ b/scripts/beacon-account/check-migrations.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +import pg from 'pg'; + +const mode = process.argv[2]; +if (!['before', 'after'].includes(mode)) throw new Error('usage: check-migrations.mjs before|after'); +const expected = (process.env.BEACON_ACCOUNT_EXPECTED_PENDING_MIGRATIONS ?? '') + .split(',').map((value) => value.trim()).filter(Boolean).sort(); +if (expected.length === 0) throw new Error('expected pending Account migration list is empty'); +const target = process.env.BEACON_ACCOUNT_SCHEMA_VERSION?.trim(); +if (!target || expected.at(-1) !== target) throw new Error('target schema must equal final expected migration'); + +const migrationRoot = path.resolve(process.cwd(), 'prisma/migrations'); +const available = (await fs.readdir(migrationRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && /^\d{14}_[a-z0-9_]+$/.test(entry.name)) + .map((entry) => entry.name).sort(); +const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); +await client.connect(); +try { + const result = await client.query( + 'SELECT migration_name, finished_at, rolled_back_at FROM "_prisma_migrations" ORDER BY migration_name', + ); + const unresolved = result.rows.filter((row) => row.finished_at === null && row.rolled_back_at === null); + if (unresolved.length) throw new Error('database contains an unresolved migration'); + const applied = new Set(result.rows + .filter((row) => row.finished_at !== null && row.rolled_back_at === null) + .map((row) => row.migration_name)); + const pending = available.filter((name) => !applied.has(name)); + if (mode === 'before') { + const exactPending = JSON.stringify(pending) === JSON.stringify(expected); + const exactAlreadyApplied = pending.length === 0 && applied.has(target); + if (!exactPending && !exactAlreadyApplied) { + throw new Error('pending migrations differ from the reviewed Account-only list'); + } + } + if (mode === 'after' && pending.length !== 0) throw new Error('migrations remain pending after deploy'); +} finally { + await client.end(); +} diff --git a/scripts/beacon-account/health-smoke.sh b/scripts/beacon-account/health-smoke.sh new file mode 100755 index 00000000..947b2f5d --- /dev/null +++ b/scripts/beacon-account/health-smoke.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +environment=${1:?usage: health-smoke.sh staging|production /secure/deploy.env [expected-sha40] [worker-present] [nav-asset-present]} +ACCOUNT_DEPLOY_FILE=${2:?usage: health-smoke.sh staging|production /secure/deploy.env [expected-sha40] [worker-present] [nav-asset-present]} +export ACCOUNT_DEPLOY_FILE +account_load_deploy_env "$ACCOUNT_DEPLOY_FILE" +expected_sha=${3:-$BEACON_ACCOUNT_GIT_SHA} +echo "$expected_sha" | grep -Eq '^[0-9a-f]{40}$' || account_fail 'expected SHA must be exact sha40' +expected_worker_present=${4:-} +if [ -z "$expected_worker_present" ]; then + expected_worker_present=0 + account_image_supports_mail_worker "$expected_sha" && expected_worker_present=1 +fi +case "$expected_worker_present" in 0|1) ;; *) account_fail 'worker presence must be 0 or 1' ;; esac +expected_nav_asset_present=${5:-} +if [ -z "$expected_nav_asset_present" ]; then + expected_nav_asset_present=0 + account_image_supports_navigation_asset "$expected_sha" && expected_nav_asset_present=1 +fi +case "$expected_nav_asset_present" in 0|1) ;; *) account_fail 'navigation asset presence must be 0 or 1' ;; esac +account_validate +account_verify_running "$environment" "$expected_sha" "$expected_sha" "$expected_worker_present" +command -v curl >/dev/null 2>&1 || account_fail 'curl is required for Account health smoke' +command -v jq >/dev/null 2>&1 || account_fail 'jq is required for Account health smoke' +command -v sha256sum >/dev/null 2>&1 || account_fail 'sha256sum is required for Account health smoke' + +port=13002 +origin=https://account.harmonicbeacon.com +[ "$environment" = staging ] && port=13003 && origin=https://account-staging.harmonicbeacon.com +tmp=$(mktemp -d /run/beacon-account-smoke.XXXXXX) +trap 'rm -rf "$tmp"' EXIT HUP INT TERM +host=${origin#https://} +curl --fail --silent --show-error --connect-timeout 3 --max-time 8 -H "Host: $host" \ + "http://127.0.0.1:$port/api/account/health/ready" > "$tmp/ready.json" +curl --fail --silent --show-error --proto '=https' --connect-timeout 3 --max-time 8 \ + "$origin/.well-known/openid-configuration" > "$tmp/discovery.json" +curl --fail --silent --show-error --proto '=https' --connect-timeout 3 --max-time 8 \ + "$origin/.well-known/jwks.json" > "$tmp/jwks.json" +if [ "$expected_nav_asset_present" -eq 1 ]; then + curl --fail --silent --show-error --proto '=https' --connect-timeout 3 --max-time 8 \ + "$origin/assets/hb-global-nav.js?v=$expected_sha" > "$tmp/hb-global-nav.js" + container=$(account_container_name "$environment") + expected_nav_sha=$(docker exec "$container" sha256sum /app/public/assets/hb-global-nav.js | awk '{print $1}') + echo "$expected_nav_sha" | grep -Eq '^[0-9a-f]{64}$' || account_fail 'running navigation asset digest is invalid' + actual_nav_sha=$(sha256sum "$tmp/hb-global-nav.js" | awk '{print $1}') + test "$actual_nav_sha" = "$expected_nav_sha" || account_fail 'public navigation asset differs from running image' +fi +"$(dirname -- "$0")/verify-health-json.sh" \ + "$tmp" "$origin" "$expected_sha" "$BEACON_ACCOUNT_SCHEMA_VERSION" +echo "Beacon Account $environment loopback and HTTPS discovery are healthy." diff --git a/scripts/beacon-account/lib.sh b/scripts/beacon-account/lib.sh new file mode 100755 index 00000000..dcac61fd --- /dev/null +++ b/scripts/beacon-account/lib.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env sh +set -eu + +account_fail() { + echo "beacon-account: $*" >&2 + exit 1 +} + +account_repo_root() { + CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd +} + +account_require_private_file() { + file=$1 + test -f "$file" || account_fail "missing required file: $file" + owner_mode=$(stat -c '%U:%G:%a' "$file") + test "$owner_mode" = root:root:600 || account_fail "$file must be root:root 0600" +} + +account_load_deploy_env() { + deploy_file=$1 + account_require_private_file "$deploy_file" + set -a + # The deploy file contains only non-secret coordinates and is validated below. + # shellcheck disable=SC1090 + . "$deploy_file" + set +a + : "${COMPOSE_PROJECT_NAME:?missing COMPOSE_PROJECT_NAME}" + : "${BEACON_ACCOUNT_IMAGE_TAG:?missing BEACON_ACCOUNT_IMAGE_TAG}" + : "${BEACON_ACCOUNT_GIT_SHA:?missing BEACON_ACCOUNT_GIT_SHA}" + : "${BEACON_ACCOUNT_BUILD_TIME:?missing BEACON_ACCOUNT_BUILD_TIME}" + : "${BEACON_ACCOUNT_SCHEMA_VERSION:?missing BEACON_ACCOUNT_SCHEMA_VERSION}" + : "${BEACON_ACCOUNT_EXPECTED_PENDING_MIGRATIONS:?missing reviewed migration list}" + : "${BEACON_ACCOUNT_PRODUCTION_ENV_FILE:?missing production env path}" + : "${BEACON_ACCOUNT_STAGING_ENV_FILE:?missing staging env path}" + : "${BEACON_ACCOUNT_MAIL_WORKER_PRODUCTION_ENV_FILE:?missing production mail worker env path}" + : "${BEACON_ACCOUNT_MAIL_WORKER_STAGING_ENV_FILE:?missing staging mail worker env path}" + : "${BEACON_ACCOUNT_STAGING_DB_ENV_FILE:?missing staging database env path}" + : "${BEACON_ACCOUNT_BACKUP_DIR:?missing production backup directory}" + : "${BEACON_ACCOUNT_BACKUP_KEY_FILE:?missing production backup encryption key file}" + test "$COMPOSE_PROJECT_NAME" = beacon-account || account_fail 'unexpected Compose project' + test "$BEACON_ACCOUNT_IMAGE_TAG" = "$BEACON_ACCOUNT_GIT_SHA" || account_fail 'image tag and git SHA differ' + echo "$BEACON_ACCOUNT_GIT_SHA" | grep -Eq '^[0-9a-f]{40}$' || account_fail 'git SHA must be exact sha40' + test "${BEACON_ACCOUNT_PRODUCTION_PORT:-13002}" = 13002 || account_fail 'production port must be 13002' + test "${BEACON_ACCOUNT_STAGING_PORT:-13003}" = 13003 || account_fail 'staging port must be 13003' + account_require_private_file "$BEACON_ACCOUNT_PRODUCTION_ENV_FILE" + account_require_private_file "$BEACON_ACCOUNT_STAGING_ENV_FILE" + account_require_private_file "$BEACON_ACCOUNT_MAIL_WORKER_PRODUCTION_ENV_FILE" + account_require_private_file "$BEACON_ACCOUNT_MAIL_WORKER_STAGING_ENV_FILE" + account_require_private_file "$BEACON_ACCOUNT_STAGING_DB_ENV_FILE" + test "$BEACON_ACCOUNT_BACKUP_DIR" = /mnt/beacon-data/backups/account || + account_fail 'unexpected backup directory' + command -v mountpoint >/dev/null 2>&1 || account_fail 'mountpoint command is unavailable' + mountpoint -q /mnt/beacon-data || + account_fail '/mnt/beacon-data must be a mounted filesystem' + test "$(findmnt -n -o TARGET --target /mnt/beacon-data 2>/dev/null)" = /mnt/beacon-data || + account_fail '/mnt/beacon-data mount identity is invalid' + account_require_private_file "$BEACON_ACCOUNT_BACKUP_KEY_FILE" + test "$(wc -c < "$BEACON_ACCOUNT_BACKUP_KEY_FILE")" -ge 48 || account_fail 'backup key is too short' +} + +account_compose() { + root=$(account_repo_root) + docker compose --project-name "$COMPOSE_PROJECT_NAME" \ + --env-file "$ACCOUNT_DEPLOY_FILE" \ + -f "$root/ops/beacon-account/compose.yml" "$@" +} + +account_validate() { + image="harmonic-beacon/account:$BEACON_ACCOUNT_IMAGE_TAG" + docker image inspect "$image" >/dev/null 2>&1 || + account_fail "missing exact Account image for validation: $image" + docker run --rm --network none --read-only --cap-drop ALL --user 0:0 \ + --security-opt no-new-privileges \ + --mount "type=bind,src=$BEACON_ACCOUNT_PRODUCTION_ENV_FILE,dst=/run/account-production.env,readonly" \ + --mount "type=bind,src=$BEACON_ACCOUNT_STAGING_ENV_FILE,dst=/run/account-staging.env,readonly" \ + --mount "type=bind,src=$BEACON_ACCOUNT_STAGING_DB_ENV_FILE,dst=/run/account-staging-database.env,readonly" \ + --mount "type=bind,src=$BEACON_ACCOUNT_MAIL_WORKER_PRODUCTION_ENV_FILE,dst=/run/account-mail-worker-production.env,readonly" \ + --mount "type=bind,src=$BEACON_ACCOUNT_MAIL_WORKER_STAGING_ENV_FILE,dst=/run/account-mail-worker-staging.env,readonly" \ + --entrypoint node "$image" /app/ops/beacon-account/validate.mjs \ + /run/account-production.env /run/account-staging.env \ + /run/account-staging-database.env \ + /run/account-mail-worker-production.env \ + /run/account-mail-worker-staging.env +} + +account_require_internal_mail_network() { + environment=$1 + network="beacon_account_mail_$environment" + metadata=$(docker network inspect "$network" --format '{{.Name}} {{.Driver}} {{.Internal}}' 2>/dev/null) || + account_fail "missing pre-created internal mail network: $network" + test "$metadata" = "$network bridge true" || + account_fail "$network must be an exact internal bridge" +} + +account_container_name() { + case "$1" in + production) echo beacon-account-account-production-1 ;; + staging) echo beacon-account-account-staging-1 ;; + *) account_fail 'environment must be production or staging' ;; + esac +} + +account_mail_worker_container_name() { + case "$1" in + production) echo beacon-account-account-mail-worker-production-1 ;; + staging) echo beacon-account-account-mail-worker-staging-1 ;; + *) account_fail 'environment must be production or staging' ;; + esac +} + +account_wait_healthy() { + account_wait_container=$1 + account_wait_attempts=0 + while [ "$account_wait_attempts" -lt 60 ]; do + state=$(docker inspect "$account_wait_container" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true) + [ "$state" = healthy ] && return 0 + [ "$state" = exited ] && account_fail "$account_wait_container exited before readiness" + account_wait_attempts=$((account_wait_attempts + 1)) + sleep 2 + done + account_fail "$account_wait_container did not become healthy" +} + +account_verify_running() { + environment=$1 + expected_sha=${2:-$BEACON_ACCOUNT_GIT_SHA} + expected_image_tag=${3:-$BEACON_ACCOUNT_IMAGE_TAG} + expected_worker_present=${4:-1} + case "$expected_worker_present" in 0|1) ;; *) account_fail 'expected worker presence must be 0 or 1' ;; esac + container=$(account_container_name "$environment") + worker=$(account_mail_worker_container_name "$environment") + account_wait_healthy "$container" + if [ "$expected_worker_present" -eq 1 ]; then + account_wait_healthy "$worker" + else + worker_state=$(docker inspect "$worker" --format '{{.State.Status}}' 2>/dev/null || true) + case "$worker_state" in + ''|created|exited|dead) ;; + *) account_fail 'Account mail worker must not be running for this image' ;; + esac + fi + image=$(docker inspect "$container" --format '{{.Config.Image}}') + test "$image" = "harmonic-beacon/account:$expected_image_tag" || account_fail 'running image mismatch' + running_sha=$(docker inspect "$container" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) + test "$running_sha" = "$expected_sha" || account_fail 'running SHA mismatch' + published=$(docker inspect "$container" --format '{{json .HostConfig.PortBindings}}') + expected_port=13002 + [ "$environment" = staging ] && expected_port=13003 + echo "$published" | grep -Fq "127.0.0.1" || account_fail 'Account must bind loopback only' + echo "$published" | grep -Fq "$expected_port" || account_fail 'Account port mismatch' + if [ "$expected_worker_present" -eq 1 ]; then + worker_image=$(docker inspect "$worker" --format '{{.Config.Image}}') + test "$worker_image" = "harmonic-beacon/account:$expected_image_tag" || + account_fail 'running mail worker image mismatch' + worker_sha=$(docker inspect "$worker" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) + test "$worker_sha" = "$expected_sha" || account_fail 'running mail worker SHA mismatch' + worker_published=$(docker inspect "$worker" --format '{{json .HostConfig.PortBindings}}') + test "$worker_published" = null || test "$worker_published" = '{}' || + account_fail 'Account mail worker must not publish ports' + fi +} + +account_check_production_migrations() ( + mode=$1 + work=$(mktemp -d /run/beacon-account-migration-check.XXXXXX) + trap 'rm -rf "$work"' EXIT HUP INT TERM + account_write_production_admin_env "$work/admin.env" + docker run --rm --network earlybirds_preview_db_internal --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --env-file "$work/admin.env" \ + -e "BEACON_ACCOUNT_EXPECTED_PENDING_MIGRATIONS=$BEACON_ACCOUNT_EXPECTED_PENDING_MIGRATIONS" \ + -e "BEACON_ACCOUNT_SCHEMA_VERSION=$BEACON_ACCOUNT_SCHEMA_VERSION" \ + --entrypoint node "harmonic-beacon/account:$BEACON_ACCOUNT_IMAGE_TAG" \ + /app/scripts/beacon-account/check-migrations.mjs "$mode" + rm -rf "$work" + trap - EXIT HUP INT TERM +) + +account_write_production_admin_env() { + target=$1 + container=earlybirds-preview-postgres-1 + state=$(docker inspect "$container" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true) + test "$state" = healthy || account_fail 'production PostgreSQL is not healthy' + networks=$(docker inspect "$container" --format '{{range $name,$value := .NetworkSettings.Networks}}{{println $name}}{{end}}') + echo "$networks" | grep -Fxq earlybirds_preview_db_internal || + account_fail 'production PostgreSQL is outside its exact internal network' + work=$(dirname -- "$target") + inspect_file="$work/postgres-inspect.json" + docker inspect "$container" > "$inspect_file" + chmod 0600 "$inspect_file" + docker run --rm --network none --read-only --cap-drop ALL --user 0:0 \ + --security-opt no-new-privileges \ + --mount "type=bind,src=$inspect_file,dst=/run/postgres-inspect.json,readonly" \ + --entrypoint node "harmonic-beacon/account:$BEACON_ACCOUNT_IMAGE_TAG" -e ' + const fs = require("node:fs"); + const inspected = JSON.parse(fs.readFileSync("/run/postgres-inspect.json", "utf8")); + if (!Array.isArray(inspected) || inspected.length !== 1) throw new Error("invalid PostgreSQL inspection"); + const values = new Map((inspected[0].Config?.Env ?? []).map((line) => { + const at = line.indexOf("="); return [line.slice(0, at), line.slice(at + 1)]; + })); + const user = values.get("POSTGRES_USER"); + const password = values.get("POSTGRES_PASSWORD"); + const database = values.get("POSTGRES_DB"); + if (user !== "earlybirds_preview" || database !== "earlybirds_preview" || + !password || password.length < 32) throw new Error("unexpected PostgreSQL authority identity"); + const url = new URL("postgresql://earlybirds-preview-postgres/earlybirds_preview?schema=public"); + url.username = user; url.password = password; + process.stdout.write(`DATABASE_URL=${url.toString()}\n`); + ' > "$target" + rm -f "$inspect_file" + chmod 0600 "$target" + test "$(wc -l < "$target")" -eq 1 || account_fail 'migration database environment is invalid' +} + +account_migrate_production() ( + work=$(mktemp -d /run/beacon-account-migrate.XXXXXX) + trap 'rm -rf "$work"' EXIT HUP INT TERM + account_write_production_admin_env "$work/admin.env" + docker run --rm --network earlybirds_preview_db_internal --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --env-file "$work/admin.env" \ + "harmonic-beacon/account:$BEACON_ACCOUNT_IMAGE_TAG" npx prisma migrate deploy +) + +account_provision_production_role() ( + work=$(mktemp -d /run/beacon-account-role.XXXXXX) + trap 'rm -rf "$work"' EXIT HUP INT TERM + account_write_production_admin_env "$work/admin.env" + sed -n '/^DATABASE_URL=/p' "$BEACON_ACCOUNT_PRODUCTION_ENV_FILE" > "$work/runtime.env" + chmod 0600 "$work/runtime.env" + test "$(wc -l < "$work/runtime.env")" -eq 1 || account_fail 'production runtime DATABASE_URL is missing or duplicated' + docker run --rm --network earlybirds_preview_db_internal --read-only --tmpfs /tmp \ + --cap-drop ALL --user 0:0 --security-opt no-new-privileges \ + --env-file "$work/admin.env" \ + --mount "type=bind,src=$work/runtime.env,dst=/run/account-runtime.env,readonly" \ + --entrypoint node "harmonic-beacon/account:$BEACON_ACCOUNT_IMAGE_TAG" \ + /app/scripts/beacon-account/provision-production-role.mjs /run/account-runtime.env +) + +account_provision_production_authority() { + docker run --rm --network earlybirds_preview_db_internal --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges \ + --env-file "$BEACON_ACCOUNT_PRODUCTION_ENV_FILE" \ + "harmonic-beacon/account:$BEACON_ACCOUNT_IMAGE_TAG" npm run account:provision +} + +account_backup_production() ( + root=$(account_repo_root) + backup_dir=$BEACON_ACCOUNT_BACKUP_DIR + test -d "$backup_dir" || account_fail "missing backup directory: $backup_dir" + test "$(stat -c '%U:%G:%a' "$backup_dir")" = root:root:700 || + account_fail "$backup_dir must be root:root 0700" + backup_name="account-pre-${BEACON_ACCOUNT_GIT_SHA}-$(date -u +%Y%m%dT%H%M%SZ).dump.enc" + test ! -e "$backup_dir/$backup_name" || account_fail 'production backup target already exists' + backup_work=$(mktemp -d /run/beacon-account-backup.XXXXXX) + chmod 0700 "$backup_work" + database_env="$backup_work/database.env" + dump_fifo="$backup_work/dump.fifo" + : > "$database_env" + mkfifo -m 0600 "$dump_fifo" + chmod 0600 "$database_env" + trap 'rm -rf "$backup_work"; rm -f "$backup_dir/$backup_name"' EXIT HUP INT TERM + account_write_production_admin_env "$database_env" + docker run --rm --network earlybirds_preview_db_internal --env-file "$database_env" \ + --mount "type=bind,src=$root/scripts/beacon-account/production-pg-dump.sh,dst=/usr/local/bin/beacon-account-production-pg-dump,readonly" \ + postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 \ + /usr/local/bin/beacon-account-production-pg-dump > "$dump_fifo" & + dump_pid=$! + if ! openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \ + -pass "file:$BEACON_ACCOUNT_BACKUP_KEY_FILE" -in "$dump_fifo" -out "$backup_dir/$backup_name"; then + kill "$dump_pid" >/dev/null 2>&1 || true + wait "$dump_pid" >/dev/null 2>&1 || true + account_fail 'production backup encryption failed' + fi + wait "$dump_pid" || account_fail 'production pg_dump failed' + rm -rf "$backup_work" + openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ + -pass "file:$BEACON_ACCOUNT_BACKUP_KEY_FILE" -in "$backup_dir/$backup_name" | + docker run --rm -i postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 \ + pg_restore --list >/dev/null || account_fail 'encrypted backup verification failed' + trap - EXIT HUP INT TERM + test -s "$backup_dir/$backup_name" || account_fail 'production backup is empty' + chmod 0600 "$backup_dir/$backup_name" + printf '%s\n' "$backup_dir/$backup_name" +) + +account_backup_staging() ( + backup_dir=$BEACON_ACCOUNT_BACKUP_DIR + test -d "$backup_dir" || account_fail "missing backup directory: $backup_dir" + test "$(stat -c '%U:%G:%a' "$backup_dir")" = root:root:700 || + account_fail "$backup_dir must be root:root 0700" + container=beacon-account-account-staging-postgres-1 + state=$(docker inspect "$container" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true) + test "$state" = healthy || account_fail 'staging PostgreSQL is not healthy' + backup_name="account-staging-pre-provider-${BEACON_ACCOUNT_GIT_SHA}-$(date -u +%Y%m%dT%H%M%SZ).dump.enc" + test ! -e "$backup_dir/$backup_name" || account_fail 'staging backup target already exists' + backup_work=$(mktemp -d /run/beacon-account-staging-backup.XXXXXX) + chmod 0700 "$backup_work" + dump_fifo="$backup_work/dump.fifo" + mkfifo -m 0600 "$dump_fifo" + trap 'rm -rf "$backup_work"; rm -f "$backup_dir/$backup_name"' EXIT HUP INT TERM + docker exec "$container" sh -ec \ + 'exec pg_dump --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" --format=custom --no-owner --no-acl' \ + > "$dump_fifo" & + dump_pid=$! + if ! openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \ + -pass "file:$BEACON_ACCOUNT_BACKUP_KEY_FILE" -in "$dump_fifo" -out "$backup_dir/$backup_name"; then + kill "$dump_pid" >/dev/null 2>&1 || true + wait "$dump_pid" >/dev/null 2>&1 || true + account_fail 'staging backup encryption failed' + fi + wait "$dump_pid" || account_fail 'staging pg_dump failed' + rm -rf "$backup_work" + openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ + -pass "file:$BEACON_ACCOUNT_BACKUP_KEY_FILE" -in "$backup_dir/$backup_name" | + docker run --rm -i postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 \ + pg_restore --list >/dev/null || account_fail 'encrypted staging backup verification failed' + trap - EXIT HUP INT TERM + test -s "$backup_dir/$backup_name" || account_fail 'staging backup is empty' + chmod 0600 "$backup_dir/$backup_name" + printf '%s\n' "$backup_dir/$backup_name" +) + +account_capture_previous_runtime() { + environment=$1 + container=$(account_container_name "$environment") + if ! docker inspect "$container" >/dev/null 2>&1; then + printf '\n' + return 0 + fi + previous_tag=$(docker inspect "$container" --format '{{.Config.Image}}' | sed 's#^harmonic-beacon/account:##') + previous_sha=$(docker inspect "$container" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) + echo "$previous_sha" | grep -Eq '^[0-9a-f]{40}$' || account_fail 'previous Account SHA is invalid' + test "$previous_tag" = "$previous_sha" || account_fail 'previous Account image/SHA mismatch' + printf '%s\n' "$previous_sha" +} + +account_capture_previous_worker() { + environment=$1 + previous_sha=$2 + worker=$(account_mail_worker_container_name "$environment") + if ! docker inspect "$worker" >/dev/null 2>&1; then + printf '0\n' + return 0 + fi + test -n "$previous_sha" || account_fail 'mail worker exists without prior Account runtime' + worker_tag=$(docker inspect "$worker" --format '{{.Config.Image}}' | sed 's#^harmonic-beacon/account:##') + worker_sha=$(docker inspect "$worker" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) + test "$worker_tag" = "$previous_sha" || account_fail 'previous mail worker image differs from app' + test "$worker_sha" = "$previous_sha" || account_fail 'previous mail worker SHA differs from app' + printf '1\n' +} + +account_image_supports_mail_worker() { + sha=$1 + docker run --rm --entrypoint sh "harmonic-beacon/account:$sha" -ec \ + 'test -f scripts/process-account-mail-outbox.ts && node -e "const p=require(\"./package.json\");if(!p.scripts?.[\"account:mail-worker\"])process.exit(1)"' \ + >/dev/null 2>&1 +} + +account_image_supports_navigation_asset() { + sha=$1 + docker image inspect "harmonic-beacon/account:$sha" \ + --format '{{range .Config.Env}}{{println .}}{{end}}' | + grep -Fxq 'BEACON_ACCOUNT_NAV_ASSET=1' +} + +account_restore_previous_runtime() { + environment=$1 + previous_sha=$2 + previous_worker_present=${3:-1} + container=$(account_container_name "$environment") + worker=$(account_mail_worker_container_name "$environment") + if [ -z "$previous_sha" ]; then + docker rm -f "$container" >/dev/null 2>&1 || true + docker rm -f "$worker" >/dev/null 2>&1 || true + return 0 + fi + if [ "$previous_worker_present" -eq 1 ]; then + BEACON_ACCOUNT_IMAGE_TAG=$previous_sha BEACON_ACCOUNT_GIT_SHA=$previous_sha \ + account_compose up -d --no-deps --no-build \ + "account-mail-worker-$environment" "account-$environment" + account_wait_healthy "$worker" + else + docker rm -f "$worker" >/dev/null 2>&1 || true + BEACON_ACCOUNT_IMAGE_TAG=$previous_sha BEACON_ACCOUNT_GIT_SHA=$previous_sha \ + account_compose up -d --no-deps --no-build "account-$environment" + fi + account_wait_healthy "$container" +} diff --git a/scripts/beacon-account/production-pg-dump.sh b/scripts/beacon-account/production-pg-dump.sh new file mode 100755 index 00000000..6d95ad84 --- /dev/null +++ b/scripts/beacon-account/production-pg-dump.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +: "${DATABASE_URL:?production database URL is required}" + +# Prisma's `schema` query parameter is not a libpq URI parameter. The +# production admin URL is generated by this deployment package with exactly +# this suffix; reject any other shape instead of silently rewriting arbitrary +# connection options. +case "$DATABASE_URL" in + *'?schema=public') pg_dump_url=${DATABASE_URL%"?schema=public"} ;; + *) + echo 'beacon-account: production database URL must end with the reviewed public schema parameter' >&2 + exit 2 + ;; +esac + +unset DATABASE_URL +exec pg_dump --format=custom --no-owner --no-acl "$pg_dump_url" diff --git a/scripts/beacon-account/provision-production-role.mjs b/scripts/beacon-account/provision-production-role.mjs new file mode 100644 index 00000000..3134dae3 --- /dev/null +++ b/scripts/beacon-account/provision-production-role.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import process from 'node:process'; + +import pg from 'pg'; + +const EXPECTED_HOST = 'earlybirds-preview-postgres'; +const EXPECTED_DATABASE = 'earlybirds_preview'; +const RUNTIME_ROLE = 'account_prod'; +const ROLE_LOCK = 7_393_011_842; + +const runtimeTables = [ + 'early_bird_users', + 'early_bird_auth_sessions', + 'early_bird_identities', + 'early_bird_verifications', + 'beacon_account_authority_environment', + 'beacon_profiles', + 'beacon_account_action_tokens', + 'beacon_account_auth_throttles', + 'beacon_account_mail_outbox', + 'listener_account_sessions', + 'beacon_oauth_clients', + 'beacon_oauth_refresh_tokens', + 'beacon_oauth_access_tokens', + 'beacon_oauth_consents', + 'beacon_jwks', +]; + +const triggerFunctions = [ + 'beacon_profile_after_account_insert', + 'beacon_verification_outbox_after_identity_insert', +]; + +function databaseUrlFromEnvFile(file) { + const matches = fs.readFileSync(file, 'utf8').split(/\r?\n/) + .filter((line) => line.startsWith('DATABASE_URL=')); + if (matches.length !== 1) throw new Error('runtime database file must contain exactly one DATABASE_URL'); + return new URL(matches[0].slice('DATABASE_URL='.length)); +} + +function validateUrl(url, role, label, expectedHost) { + if (!['postgres:', 'postgresql:'].includes(url.protocol) || + url.hostname !== expectedHost || + url.pathname.slice(1) !== EXPECTED_DATABASE || + (url.searchParams.get('schema') ?? 'public') !== 'public') { + throw new Error(`${label} database boundary mismatch`); + } + if (decodeURIComponent(url.username) !== role) throw new Error(`${label} database role mismatch`); + const password = decodeURIComponent(url.password); + if (!/^[A-Za-z0-9_-]{32,128}$/.test(password)) { + throw new Error(`${label} database password must be 32-128 base64url characters`); + } + return password; +} + +async function formattedRolePassword(client, password) { + const result = await client.query( + "SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', $1::text, $2::text) AS statement", + [RUNTIME_ROLE, password], + ); + return result.rows[0].statement; +} + +export async function provisionProductionRole({ + adminUrl, + runtimeEnvFile, + expectedHost = EXPECTED_HOST, +}) { + const runtimeUrl = databaseUrlFromEnvFile(runtimeEnvFile); + const runtimePassword = validateUrl(runtimeUrl, RUNTIME_ROLE, 'runtime', expectedHost); + const parsedAdminUrl = new URL(adminUrl); + validateUrl(parsedAdminUrl, 'earlybirds_preview', 'migration', expectedHost); + + const client = new pg.Client({ connectionString: parsedAdminUrl.toString() }); + await client.connect(); + try { + await client.query('BEGIN'); + await client.query('SELECT pg_advisory_xact_lock($1)', [ROLE_LOCK]); + const authority = await client.query(` + SELECT current_database() AS database, + current_user AS role, + (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS superuser + `); + if (authority.rows[0]?.database !== EXPECTED_DATABASE || + authority.rows[0]?.role !== 'earlybirds_preview' || + authority.rows[0]?.superuser !== true) { + throw new Error('migration connection is not the exact production database authority'); + } + + const existing = await client.query( + 'SELECT rolsuper, rolcreatedb, rolcreaterole, rolreplication, rolbypassrls FROM pg_roles WHERE rolname = $1', + [RUNTIME_ROLE], + ); + if (existing.rowCount === 0) await client.query('CREATE ROLE account_prod LOGIN'); + else if (Object.values(existing.rows[0]).some(Boolean)) { + throw new Error('runtime database role has forbidden elevated attributes'); + } + const memberships = await client.query(` + SELECT count(*)::integer AS count + FROM pg_auth_members membership + JOIN pg_roles member ON member.oid = membership.member + WHERE member.rolname = $1 + `, [RUNTIME_ROLE]); + if (memberships.rows[0].count !== 0) throw new Error('runtime database role must not inherit another role'); + + await client.query(await formattedRolePassword(client, runtimePassword)); + await client.query(` + ALTER ROLE account_prod NOSUPERUSER NOCREATEDB NOCREATEROLE + NOREPLICATION NOBYPASSRLS INHERIT; + ALTER ROLE account_prod SET search_path = public; + GRANT CONNECT ON DATABASE earlybirds_preview TO account_prod; + GRANT USAGE ON SCHEMA public TO account_prod; + REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM account_prod; + REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM account_prod; + `); + + const objects = await client.query( + 'SELECT name FROM unnest($1::text[]) AS expected(name) WHERE to_regclass(format(\'public.%I\', name)) IS NULL', + [runtimeTables], + ); + if (objects.rowCount !== 0) throw new Error('required Account runtime table is missing after migration'); + await client.query(`GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${runtimeTables + .map((name) => `"${name}"`).join(', ')} TO account_prod`); + for (const name of triggerFunctions) { + const present = await client.query('SELECT to_regprocedure($1) IS NOT NULL AS present', [`${name}()`]); + if (!present.rows[0].present) throw new Error('required Account trigger function is missing after migration'); + await client.query(`GRANT EXECUTE ON FUNCTION "${name}"() TO account_prod`); + } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + throw error; + } finally { + await client.end(); + } + + const runtime = new pg.Client({ connectionString: runtimeUrl.toString() }); + await runtime.connect(); + try { + const check = await runtime.query(` + SELECT current_database() AS database, current_user AS role, + to_regclass('public.beacon_account_authority_environment') IS NOT NULL AS authority + `); + if (check.rows[0]?.database !== EXPECTED_DATABASE || + check.rows[0]?.role !== RUNTIME_ROLE || check.rows[0]?.authority !== true) { + throw new Error('runtime database role verification failed'); + } + } finally { + await runtime.end(); + } +} + +if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) { + const runtimeEnvFile = process.argv[2]; + if (!runtimeEnvFile || !process.env.DATABASE_URL) { + throw new Error('usage: DATABASE_URL= provision-production-role.mjs runtime-database.env'); + } + provisionProductionRole({ adminUrl: process.env.DATABASE_URL, runtimeEnvFile }) + .then(() => process.stdout.write('Account production runtime database role is ready.\n')) + .catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : 'role provisioning failed'}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/beacon-account/rollback-app.sh b/scripts/beacon-account/rollback-app.sh new file mode 100755 index 00000000..e2045df9 --- /dev/null +++ b/scripts/beacon-account/rollback-app.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +environment=${1:?usage: rollback-app.sh staging|production /secure/deploy.env previous-sha40} +ACCOUNT_DEPLOY_FILE=${2:?usage: rollback-app.sh staging|production /secure/deploy.env previous-sha40} +previous_sha=${3:?usage: rollback-app.sh staging|production /secure/deploy.env previous-sha40} +export ACCOUNT_DEPLOY_FILE +case "$environment" in production|staging) ;; *) account_fail 'environment must be production or staging' ;; esac +echo "$previous_sha" | grep -Eq '^[0-9a-f]{40}$' || account_fail 'previous SHA must be exact sha40' + +account_load_deploy_env "$ACCOUNT_DEPLOY_FILE" +exec 9>"/run/lock/beacon-account-$environment.lock" +flock -n 9 || account_fail "another $environment deployment is active" +docker image inspect "harmonic-beacon/account:$previous_sha" >/dev/null || account_fail 'previous image is unavailable' +previous_worker_present=0 +if account_image_supports_mail_worker "$previous_sha"; then + previous_worker_present=1 +fi +previous_nav_asset_present=0 +if account_image_supports_navigation_asset "$previous_sha"; then + previous_nav_asset_present=1 +fi +account_restore_previous_runtime "$environment" "$previous_sha" "$previous_worker_present" +"$(dirname -- "$0")/health-smoke.sh" \ + "$environment" "$ACCOUNT_DEPLOY_FILE" "$previous_sha" "$previous_worker_present" \ + "$previous_nav_asset_present" +echo "Beacon Account $environment runtime rolled back to $previous_sha; database was not downgraded." diff --git a/scripts/beacon-account/rollback-social-provider.sh b/scripts/beacon-account/rollback-social-provider.sh new file mode 100755 index 00000000..194e187a --- /dev/null +++ b/scripts/beacon-account/rollback-social-provider.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +test "$(id -u)" -eq 0 || account_fail 'run as root' +rollback_state=${1:?usage: rollback-social-provider.sh /var/lib/harmonic-beacon/account-social-providers/environment-provider-sha-timestamp /secure/deploy.env} +ACCOUNT_DEPLOY_FILE=${2:?usage: rollback-social-provider.sh state /secure/deploy.env} +export ACCOUNT_DEPLOY_FILE +case "$rollback_state" in /var/lib/harmonic-beacon/account-social-providers/*) ;; *) account_fail 'unexpected provider rollback state path' ;; esac +test -d "$rollback_state" && test ! -L "$rollback_state" || account_fail 'provider rollback state must be a regular directory' +test "$(stat -c '%U:%G:%a' "$rollback_state")" = root:root:700 || account_fail 'provider rollback state must be root:root 0700' +for file in previous.env environment.txt provider.txt sha.txt app-id.before worker-id.before \ + database-id.before database-backup.txt database-backup.sha256 result.txt SHA256SUMS; do + test -f "$rollback_state/$file" && test ! -L "$rollback_state/$file" || account_fail 'provider rollback state is incomplete' + test "$(stat -c '%U:%G:%a' "$rollback_state/$file")" = root:root:600 || account_fail 'provider rollback state file must be root:root 0600' +done +(cd "$rollback_state" && sha256sum -c SHA256SUMS >/dev/null) + +environment=$(cat "$rollback_state/environment.txt") +provider=$(cat "$rollback_state/provider.txt") +expected_sha=$(cat "$rollback_state/sha.txt") +case "$environment" in staging|production) ;; *) account_fail 'rollback environment is invalid' ;; esac +case "$provider" in google|apple) ;; *) account_fail 'rollback provider is invalid' ;; esac +echo "$expected_sha" | grep -Eq '^[0-9a-f]{40}$' || account_fail 'rollback SHA is invalid' +account_load_deploy_env "$ACCOUNT_DEPLOY_FILE" +test "$BEACON_ACCOUNT_GIT_SHA" = "$expected_sha" || account_fail 'deploy coordinates differ from rollback SHA' +account_env=$BEACON_ACCOUNT_STAGING_ENV_FILE +test "$environment" != production || account_env=$BEACON_ACCOUNT_PRODUCTION_ENV_FILE +container=$(account_container_name "$environment") +worker=$(account_mail_worker_container_name "$environment") +test "$(docker inspect "$container" --format '{{.Config.Image}}')" = "harmonic-beacon/account:$expected_sha" || + account_fail 'running Account image differs from rollback state' +upper=$(printf '%s' "$provider" | tr '[:lower:]' '[:upper:]') +grep -Fxq "BEACON_ACCOUNT_${upper}_ENABLED=1" "$account_env" || account_fail 'target provider is not currently enabled' + +exec 9>"/run/lock/beacon-account-$environment.lock" +flock -n 9 || account_fail "another $environment Account operation is active" +temporary="${account_env}.rollback-$$" +trap 'rm -f "$temporary"' EXIT +trap '' HUP INT TERM +install -o root -g root -m 0600 "$rollback_state/previous.env" "$temporary" +mv -T "$temporary" "$account_env" +account_compose up -d --no-deps --force-recreate --no-build "account-$environment" +account_wait_healthy "$container" +account_verify_running "$environment" "$expected_sha" "$expected_sha" 1 +"$(account_repo_root)/scripts/beacon-account/health-smoke.sh" \ + "$environment" "$ACCOUNT_DEPLOY_FILE" "$expected_sha" 1 1 +test "$(docker inspect "$worker" --format '{{.Id}}')" = "$(cat "$rollback_state/worker-id.before")" || + account_fail 'mail worker changed during provider rollback' +trap - EXIT HUP INT TERM +echo "Beacon Account $provider in $environment was restored to its pre-activation state; identities and sessions were retained." diff --git a/scripts/beacon-account/social-provider-env.mjs b/scripts/beacon-account/social-provider-env.mjs new file mode 100755 index 00000000..6a718efd --- /dev/null +++ b/scripts/beacon-account/social-provider-env.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +const PROVIDERS = new Set(['google', 'apple']); +const ENVIRONMENTS = new Set(['staging', 'production']); +const MAX_APPLE_LIFETIME_SECONDS = 183 * 24 * 60 * 60; + +function assignments(contents, label) { + const values = new Map(); + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const at = line.indexOf('='); + if (at <= 0) throw new Error(`${label} contains an invalid assignment`); + const key = line.slice(0, at); + if (!/^[A-Z][A-Z0-9_]*$/.test(key) || values.has(key)) { + throw new Error(`${label} contains an invalid or duplicate key`); + } + values.set(key, line.slice(at + 1)); + } + return values; +} + +function decodeCanonicalBase64UrlJSON(value, label) { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error(`${label} is invalid`); + const decoded = Buffer.from(value, 'base64url'); + if (decoded.toString('base64url') !== value) throw new Error(`${label} is not canonical`); + return JSON.parse(decoded.toString('utf8')); +} + +function validateGoogle(clientId, clientSecret) { + if (!/^[A-Za-z0-9._-]{8,480}\.apps\.googleusercontent\.com$/.test(clientId)) { + throw new Error('Google client ID is invalid'); + } + if (clientSecret.length < 16 || clientSecret.length > 512 || /\s/.test(clientSecret)) { + throw new Error('Google client secret is invalid'); + } +} + +function validateApple(clientId, clientSecret, nowSeconds) { + if (!/^[A-Za-z0-9.-]{3,255}$/.test(clientId)) throw new Error('Apple Services ID is invalid'); + const parts = clientSecret.split('.'); + if (parts.length !== 3 || parts.some((part) => !part)) throw new Error('Apple client secret is invalid'); + let header; + let payload; + try { + header = decodeCanonicalBase64UrlJSON(parts[0], 'Apple JWT header'); + payload = decodeCanonicalBase64UrlJSON(parts[1], 'Apple JWT payload'); + } catch { + throw new Error('Apple client secret is invalid'); + } + if (!/^[A-Za-z0-9_-]+$/.test(parts[2]) || Buffer.from(parts[2], 'base64url').length !== 64 || + Buffer.from(parts[2], 'base64url').toString('base64url') !== parts[2]) { + throw new Error('Apple client secret is invalid'); + } + if (header.alg !== 'ES256' || typeof header.kid !== 'string' || + !/^[A-Z0-9]{10}$/.test(header.kid)) { + throw new Error('Apple client secret header is invalid'); + } + if (typeof payload.iss !== 'string' || !/^[A-Z0-9]{10}$/.test(payload.iss) || + payload.sub !== clientId || + payload.aud !== 'https://appleid.apple.com' || !Number.isInteger(payload.iat) || + !Number.isInteger(payload.exp) || payload.iat > nowSeconds + 300 || + payload.exp <= nowSeconds || payload.exp <= payload.iat || + payload.exp - payload.iat > MAX_APPLE_LIFETIME_SECONDS) { + throw new Error('Apple client secret claims are invalid'); + } +} + +function replaceExact(contents, key, value) { + const lines = contents.replace(/\r\n/g, '\n').split('\n'); + let count = 0; + const updated = lines.map((line) => { + if (!line.startsWith(`${key}=`)) return line; + count += 1; + return `${key}=${value}`; + }); + if (count !== 1) throw new Error(`Account environment must contain exactly one ${key}`); + return updated.join('\n'); +} + +export function buildSocialProviderActivation({ + accountContents, + bundleContents, + environment, + provider, + nowSeconds = Math.floor(Date.now() / 1000), +}) { + if (!ENVIRONMENTS.has(environment)) throw new Error('environment must be staging or production'); + if (!PROVIDERS.has(provider)) throw new Error('provider must be google or apple'); + const upper = provider.toUpperCase(); + const account = assignments(accountContents, 'Account environment'); + const bundle = assignments(bundleContents, 'provider bundle'); + const expectedOrigin = environment === 'staging' + ? 'https://account-staging.harmonicbeacon.com' + : 'https://account.harmonicbeacon.com'; + if (account.get('BEACON_ACCOUNT_BASE_URL') !== expectedOrigin) { + throw new Error('Account environment issuer mismatch'); + } + const flagKey = `BEACON_ACCOUNT_${upper}_ENABLED`; + const idKey = `BEACON_ACCOUNT_${upper}_CLIENT_ID`; + const secretKey = `BEACON_ACCOUNT_${upper}_CLIENT_SECRET`; + if (account.get(flagKey) !== '0' || (account.get(idKey) ?? '') !== '' || + (account.get(secretKey) ?? '') !== '') { + throw new Error('target provider must be fully disabled before first activation'); + } + const expectedBundleKeys = new Set([idKey, secretKey]); + if (bundle.size !== expectedBundleKeys.size || + [...bundle.keys()].some((key) => !expectedBundleKeys.has(key))) { + throw new Error('provider bundle must contain only the exact provider client ID and secret'); + } + const clientId = bundle.get(idKey) ?? ''; + const clientSecret = bundle.get(secretKey) ?? ''; + if (provider === 'google') validateGoogle(clientId, clientSecret); + else validateApple(clientId, clientSecret, nowSeconds); + + let result = accountContents; + result = replaceExact(result, idKey, clientId); + result = replaceExact(result, secretKey, clientSecret); + result = replaceExact(result, flagKey, '1'); + const effective = assignments(result, 'activated Account environment'); + if (effective.get(flagKey) !== '1' || effective.get(idKey) !== clientId || + effective.get(secretKey) !== clientSecret) { + throw new Error('provider activation output is inconsistent'); + } + return result.endsWith('\n') ? result : `${result}\n`; +} + +function requireRootPrivateFile(file, label) { + const metadata = fs.lstatSync(file); + if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`${label} must be a regular file`); + if (metadata.uid !== 0 || metadata.gid !== 0 || (metadata.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be root:root mode 0600`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) { + const [accountFile, bundleFile, outputFile, environment, provider] = process.argv.slice(2); + if (!accountFile || !bundleFile || !outputFile || !environment || !provider) { + throw new Error('usage: social-provider-env.mjs account.env bundle.env output.env staging|production google|apple'); + } + requireRootPrivateFile(accountFile, 'Account environment'); + requireRootPrivateFile(bundleFile, 'provider bundle'); + if (fs.existsSync(outputFile)) throw new Error('activation output already exists'); + const directory = fs.lstatSync(path.dirname(outputFile)); + if (!directory.isDirectory() || directory.isSymbolicLink() || directory.uid !== 0 || + directory.gid !== 0 || (directory.mode & 0o777) !== 0o700) { + throw new Error('activation output directory must be root:root mode 0700'); + } + const result = buildSocialProviderActivation({ + accountContents: fs.readFileSync(accountFile, 'utf8'), + bundleContents: fs.readFileSync(bundleFile, 'utf8'), + environment, + provider, + }); + fs.writeFileSync(outputFile, result, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + process.stdout.write(`Beacon Account ${provider} activation environment prepared for ${environment}.\n`); +} diff --git a/scripts/beacon-account/start.sh b/scripts/beacon-account/start.sh new file mode 100755 index 00000000..58e9adc4 --- /dev/null +++ b/scripts/beacon-account/start.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +environment=${1:?usage: start.sh staging|production /secure/deploy.env} +ACCOUNT_DEPLOY_FILE=${2:?usage: start.sh staging|production /secure/deploy.env} +export ACCOUNT_DEPLOY_FILE +case "$environment" in production|staging) ;; *) account_fail 'environment must be production or staging' ;; esac + +account_load_deploy_env "$ACCOUNT_DEPLOY_FILE" +exec 9>"/run/lock/beacon-account-$environment.lock" +flock -n 9 || account_fail "another $environment deployment is active" +account_require_internal_mail_network "$environment" +root=$(account_repo_root) +test "$(git -C "$root" rev-parse HEAD)" = "$BEACON_ACCOUNT_GIT_SHA" || account_fail 'release checkout SHA mismatch' +test -z "$(git -C "$root" status --porcelain)" || account_fail 'release checkout is dirty' +previous_sha=$(account_capture_previous_runtime "$environment") +previous_worker_present=$(account_capture_previous_worker "$environment" "$previous_sha") +cutover_started=0 +rollback_on_failure() { + status=$? + trap - EXIT HUP INT TERM + if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then + echo "beacon-account: cutover failed; restoring prior app image without downgrading the database" >&2 + account_restore_previous_runtime "$environment" "$previous_sha" "$previous_worker_present" || true + fi + exit "$status" +} +trap rollback_on_failure EXIT HUP INT TERM + +# Build once from the exact reviewed checkout. Staging and production consume +# the same immutable image, but never the same runtime secret or database. +account_compose build account-production +baked_sha=$(docker image inspect "harmonic-beacon/account:$BEACON_ACCOUNT_IMAGE_TAG" \ + --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) +test "$baked_sha" = "$BEACON_ACCOUNT_GIT_SHA" || account_fail 'built image provenance mismatch' +account_validate + +if [ "$environment" = production ]; then + account_check_production_migrations before + account_backup_production >/dev/null + account_migrate_production + account_check_production_migrations after + account_provision_production_role + account_provision_production_authority + cutover_started=1 + account_compose up -d --no-deps \ + account-mail-worker-production account-production +else + cutover_started=1 + account_compose up -d account-mail-worker-staging account-staging +fi +account_verify_running "$environment" +"$root/scripts/beacon-account/health-smoke.sh" \ + "$environment" "$ACCOUNT_DEPLOY_FILE" "$BEACON_ACCOUNT_GIT_SHA" 1 1 +cutover_started=0 +trap - EXIT HUP INT TERM +echo "Beacon Account $environment is healthy at exact SHA $BEACON_ACCOUNT_GIT_SHA." diff --git a/scripts/beacon-account/verify-health-json.sh b/scripts/beacon-account/verify-health-json.sh new file mode 100755 index 00000000..3daefb18 --- /dev/null +++ b/scripts/beacon-account/verify-health-json.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env sh +set -eu + +directory=${1:?usage: verify-health-json.sh DIRECTORY ISSUER SHA SCHEMA} +issuer=${2:?usage: verify-health-json.sh DIRECTORY ISSUER SHA SCHEMA} +sha=${3:?usage: verify-health-json.sh DIRECTORY ISSUER SHA SCHEMA} +schema=${4:?usage: verify-health-json.sh DIRECTORY ISSUER SHA SCHEMA} + +command -v jq >/dev/null 2>&1 || { + echo 'beacon-account: jq is required for health verification' >&2 + exit 1 +} + +jq --exit-status --arg sha "$sha" --arg schema "$schema" ' + .status == "ok" + and .gitSha == $sha + and .schemaVersion == $schema + and .checks.database == "ok" + and .checks.mail == "ok" + and .checks.issuer == "ok" + and .checks.jwks == "ok" + and .checks.clients == "ok" + and .checks.providers == "ok" +' "$directory/ready.json" >/dev/null + +jq --exit-status --arg issuer "$issuer" ' + .issuer == $issuer + and .jwks_uri == ($issuer + "/.well-known/jwks.json") + and .authorization_endpoint == ($issuer + "/api/account/auth/oauth2/authorize") + and .token_endpoint == ($issuer + "/api/account/auth/oauth2/token") + and .userinfo_endpoint == ($issuer + "/api/account/auth/oauth2/userinfo") + and .introspection_endpoint == ($issuer + "/api/account/auth/oauth2/introspect") + and .revocation_endpoint == ($issuer + "/api/account/auth/oauth2/revoke") + and .end_session_endpoint == ($issuer + "/api/account/auth/oauth2/end-session") + and .response_types_supported == ["code"] + and .grant_types_supported == ["authorization_code"] + and .code_challenge_methods_supported == ["S256"] + and .token_endpoint_auth_methods_supported == ["client_secret_basic"] +' "$directory/discovery.json" >/dev/null + +jq --exit-status ' + .keys as $keys + | ($keys | type == "array" and length > 0) + and all($keys[]; + (.kid | type == "string" and length > 0 and length <= 128) + and .kty == "OKP" + and .alg == "EdDSA" + and .crv == "Ed25519" + and (.x | type == "string" and test("^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$")) + and (has("d") | not) + and (if has("use") then .use == "sig" else true end) + and (if has("key_ops") then .key_ops == ["verify"] else true end) + ) + and (($keys | map(.kid) | unique | length) == ($keys | length)) +' "$directory/jwks.json" >/dev/null diff --git a/scripts/early-birds-preview/canonical-free-smoke.sh b/scripts/early-birds-preview/canonical-free-smoke.sh new file mode 100755 index 00000000..5f91f705 --- /dev/null +++ b/scripts/early-birds-preview/canonical-free-smoke.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env sh +set -eu +umask 077 +. "$(dirname -- "$0")/lib.sh" + +env_file=${1:?usage: canonical-free-smoke.sh PREVIEW_ENV INVITATION_FILE} +invitation_file=${2:?usage: canonical-free-smoke.sh PREVIEW_ENV INVITATION_FILE} +require_synthetic_env "$env_file" +test -s "$invitation_file" || preview_fail "invitation file is missing or empty" + +temporary=$(mktemp -d) +trap 'rm -rf "$temporary"' EXIT HUP INT TERM +base_url=$(preview_env_value EARLY_BIRDS_AUTH_BASE_URL "$env_file") +login_secret=$(preview_env_value EARLY_BIRDS_TEST_LOGIN_SECRET "$env_file") +invitation_token=$(tr -d '\r\n' <"$invitation_file") +test "${#invitation_token}" -ge 32 || preview_fail "invitation token is too short" + +synthetic_email="free-smoke-$(date +%s)-$$@e2e.invalid" +printf '{"name":"Canonical Free smoke","email":"%s","authOnly":true}' "$synthetic_email" >"$temporary/login.json" +printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \ + "$login_secret" >"$temporary/login.curl" +printf 'url = "%s/early-birds?invite=%s"\n' "$base_url" "$invitation_token" >"$temporary/invitation.curl" + +invitation_status=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --location --config "$temporary/invitation.curl" --cookie-jar "$temporary/cookies") +test "$invitation_status" = 200 || preview_fail "invitation cookie handoff returned HTTP $invitation_status" + +login_status=$(curl --silent --show-error --output "$temporary/login.response" \ + --write-out '%{http_code}' --request POST --config "$temporary/login.curl" \ + --cookie-jar "$temporary/cookies" --data-binary @"$temporary/login.json" \ + "$base_url/api/early-birds/test-login") +test "$login_status" = 200 || preview_fail "synthetic login returned HTTP $login_status" +grep -q '"ok":true' "$temporary/login.response" || preview_fail "synthetic login response is invalid" + +redeem_status=$(curl --silent --show-error --output "$temporary/redeem.response" \ + --write-out '%{http_code}' --request POST --cookie "$temporary/cookies" \ + --cookie-jar "$temporary/cookies" "$base_url/api/early-birds/free/redeem") +test "$redeem_status" = 200 || preview_fail "canonical Free redeem returned HTTP $redeem_status" +grep -q '"ok":true' "$temporary/redeem.response" || preview_fail "canonical Free redeem response is invalid" + +home_status=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --cookie "$temporary/cookies" "$base_url/") +test "$home_status" = 200 || preview_fail "entitled Listener home returned HTTP $home_status" + +echo "Canonical Free smoke passed: synthetic login, private authority redeem, projection, session cookie, and Listener home." diff --git a/scripts/early-birds-preview/disable-public.sh b/scripts/early-birds-preview/disable-public.sh new file mode 100755 index 00000000..d8f86f25 --- /dev/null +++ b/scripts/early-birds-preview/disable-public.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env sh +set -eu + +. "$(dirname -- "$0")/lib.sh" + +usage() { + echo 'usage: disable-public.sh {--dry-run|--apply} /secure/preview.env' >&2 + exit 2 +} + +mode=${1:-} +env_file=${2:-} +test "$#" -eq 2 || usage +case "$mode" in --dry-run|--apply) ;; *) usage ;; esac +test -n "$env_file" || usage +test -f "$env_file" && test ! -L "$env_file" \ + || preview_fail 'preview env path must be a regular non-symlink file' + +umask 077 +lock_file="${env_file}.listener-public.lock" +exec 9>"$lock_file" +chmod 0600 "$lock_file" +flock -n 9 || preview_fail 'another Listener public-mode operation holds the lock' + +protected_env_file=$env_file +require_synthetic_env "$env_file" +env_file=$protected_env_file +test "$(stat -c '%a' "$env_file")" = 600 \ + || preview_fail 'preview env file mode must be exactly 0600' + +for key in \ + EARLY_BIRDS_ENABLED \ + EARLY_BIRDS_FREE_FOR_ALL \ + EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED +do + count=$(grep -c "^${key}=" "$env_file" || true) + test "$count" -eq 1 || preview_fail "$key must appear exactly once" +done + +if test "$mode" = --dry-run; then + echo 'DRY RUN: no environment value, container or public route was changed.' + echo "Would set EARLY_BIRDS_ENABLED=0 (currently $(preview_env_value EARLY_BIRDS_ENABLED "$env_file"))." + echo "Would set EARLY_BIRDS_FREE_FOR_ALL=0 (currently $(preview_env_value EARLY_BIRDS_FREE_FOR_ALL "$env_file"))." + echo "Would set EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0 (currently $(preview_env_value EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED "$env_file"))." + echo 'Would create one mode-0600 backup, atomically replace the env file, recreate only Listener, then verify health/readiness and lease denial.' + exit 0 +fi + +test "$(id -u)" -eq 0 || preview_fail '--apply must run as root' + +timestamp=$(date -u +%Y%m%dT%H%M%SZ) +backup="${env_file}.pre-disable-public-${timestamp}-$$" +candidate=$(mktemp "${env_file}.disable-public.XXXXXX") +cleanup() { test -z "$candidate" || rm -f "$candidate"; } +trap cleanup EXIT HUP INT TERM + +test ! -e "$backup" || preview_fail 'refusing to overwrite an existing disable backup' +cp -p "$env_file" "$backup" +chmod 0600 "$backup" + +awk ' + /^EARLY_BIRDS_ENABLED=/ { print "EARLY_BIRDS_ENABLED=0"; next } + /^EARLY_BIRDS_FREE_FOR_ALL=/ { print "EARLY_BIRDS_FREE_FOR_ALL=0"; next } + /^EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=/ { + print "EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0"; next + } + { print } +' "$env_file" > "$candidate" +chmod 0600 "$candidate" +require_synthetic_env "$candidate" +env_file=$protected_env_file +for key in \ + EARLY_BIRDS_ENABLED \ + EARLY_BIRDS_FREE_FOR_ALL \ + EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED +do + test "$(preview_env_value "$key" "$candidate")" = 0 \ + || preview_fail "$key candidate value is not disabled" +done + +mv -f "$candidate" "$env_file" +candidate='' +sync -f "$env_file" + +fail_closed() { + echo "Listener disable failed after the env was secured; stopping only Listener. Backup: $backup" >&2 + listener_ids=$(docker ps -q \ + --filter label=com.docker.compose.project=earlybirds-preview \ + --filter label=com.docker.compose.service=listener 2>/dev/null || true) + if test -n "$listener_ids"; then + for listener_id in $listener_ids; do docker stop "$listener_id" >/dev/null 2>&1 || true; done + else + (preview_compose_command "$env_file" stop listener) >/dev/null 2>&1 || true + fi + exit 1 +} + +wait_for_http_success() { + wait_url=${1:?usage: wait_for_http_success URL} + wait_attempt=1 + while test "$wait_attempt" -le 10; do + if curl --fail --silent --show-error --max-time 2 "$wait_url" >/dev/null; then + return 0 + fi + test "$wait_attempt" -lt 10 || return 1 + sleep 1 + wait_attempt=$((wait_attempt + 1)) + done + return 1 +} + +(preview_compose_command "$env_file" \ + up -d --no-deps --force-recreate --no-build listener) || fail_closed + +app_port=$(preview_env_value EARLYBIRDS_PREVIEW_APP_PORT "$env_file") +wait_for_http_success "http://127.0.0.1:${app_port}/api/health" || fail_closed +wait_for_http_success "http://127.0.0.1:${app_port}/api/health/ready" || fail_closed +denial_status=$(curl --silent --show-error --max-time 10 \ + --output /dev/null --write-out '%{http_code}' \ + --request POST --header 'content-type: application/json' \ + --data '{"deviceId":"00000000-0000-4000-8000-000000000000","intent":"play"}' \ + "http://127.0.0.1:${app_port}/api/early-birds/stream/lease") || fail_closed +test "$denial_status" = 503 || fail_closed + +echo 'Listener public entry, Free for All and staging team entry are disabled.' +echo 'Only Listener was recreated; PostgreSQL and stream origin were retained.' +echo "Health/readiness passed and the lease endpoint denied with 503. Backup: $backup" diff --git a/scripts/early-birds-preview/health-smoke.sh b/scripts/early-birds-preview/health-smoke.sh new file mode 100755 index 00000000..09a3295e --- /dev/null +++ b/scripts/early-birds-preview/health-smoke.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" +env_file=${1:?usage: scripts/early-birds-preview/health-smoke.sh /secure/preview.env} +require_synthetic_env "$env_file" +app_port=$(preview_env_value EARLYBIRDS_PREVIEW_APP_PORT "$env_file") +stream_port=$(preview_env_value BEACON_STREAM_HOST_PORT "$env_file") + +running_services=$(preview_compose_command "$env_file" ps --status running --services) +for service in postgres listener beacon-stream; do + printf '%s\n' "$running_services" | grep -qx "$service" || { + echo "preview service is not running: $service" >&2 + exit 1 + } +done + +migration_id=$(preview_compose_command "$env_file" ps --all --quiet migration) +test -n "$migration_id" || { echo 'forward-only migration container is missing' >&2; exit 1; } +test "$(docker inspect --format '{{.State.ExitCode}}' "$migration_id")" = 0 || { + echo 'forward-only migration did not complete successfully' >&2 + exit 1 +} + +preview_compose_command "$env_file" exec -T postgres sh -ec 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +health_body=$(curl --fail --silent --show-error --max-time 5 "http://127.0.0.1:${app_port}/api/health") +expected_schema=$(preview_env_value EARLYBIRDS_PREVIEW_SCHEMA_VERSION "$env_file") +printf '%s\n' "$health_body" \ + | grep -Fq "\"databaseSchemaVersion\":\"$expected_schema\"" \ + || { echo "Listener schema provenance does not match the protected preview environment." >&2; exit 1; } +curl --fail --silent --show-error --max-time 5 "http://127.0.0.1:${app_port}/api/health/ready" >/dev/null +curl --fail --silent --show-error --max-time 5 "http://127.0.0.1:${stream_port}/healthz" >/dev/null +preview_compose_command "$env_file" exec -T beacon-stream node -e \ + "fetch('http://127.0.0.1:9090/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +echo 'EarlyBirds preview smoke passed: migration, PostgreSQL, Listener liveness/readiness, and stream liveness/readiness.' diff --git a/scripts/early-birds-preview/install-geoip-country.sh b/scripts/early-birds-preview/install-geoip-country.sh new file mode 100755 index 00000000..97c5c3ff --- /dev/null +++ b/scripts/early-birds-preview/install-geoip-country.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +set -eu + +target=${1:?usage: install-geoip-country.sh /absolute/path/dbip-country-lite-2026-07.mmdb} +case "$target" in + /*/dbip-country-lite-2026-07.mmdb) ;; + *) echo 'refusing: target must be the absolute reviewed July 2026 MMDB path' >&2; exit 2 ;; +esac + +url=https://download.db-ip.com/free/dbip-country-lite-2026-07.mmdb.gz +archive_sha256=989c57a9ad1c1c93032e28acc643afdf03597ea28480520f6f1c76ea6420507f +database_sha256=881e0b274fc0cc801fa7c33687a69810be605f80593769287cde10bdb9ee8bde +temporary=$(mktemp -d) +trap 'rm -rf -- "$temporary"' EXIT HUP INT TERM + +curl --fail --silent --show-error --location --max-time 120 "$url" \ + --output "$temporary/country.mmdb.gz" +printf '%s %s\n' "$archive_sha256" "$temporary/country.mmdb.gz" | sha256sum --check --status +gzip -dc "$temporary/country.mmdb.gz" > "$temporary/country.mmdb" +printf '%s %s\n' "$database_sha256" "$temporary/country.mmdb" | sha256sum --check --status + +mkdir -p -- "$(dirname -- "$target")" +install -m 0444 "$temporary/country.mmdb" "$target" +echo "Installed reviewed DB-IP Country Lite MMDB at $target" diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh new file mode 100755 index 00000000..367ca349 --- /dev/null +++ b/scripts/early-birds-preview/lib.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env sh +set -eu + +preview_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +preview_compose="$preview_root/ops/early-birds-preview/compose.yml" +stream_compose="$preview_root/services/beacon-stream/docker-compose.yml" +preview_overlay="$preview_root/ops/early-birds-preview/stream-build.override.yml" +authority_overlay="$preview_root/ops/early-birds-preview/authority-network.override.yml" +preview_project=earlybirds-preview + +preview_env_value() { + preview_key=${1:?usage: preview_env_value KEY FILE} + preview_value_file=${2:?usage: preview_env_value KEY FILE} + sed -n "s/^${preview_key}=//p" "$preview_value_file" | tail -n 1 | tr -d '\r' +} + +preview_fail() { + echo "refusing to run: $1" >&2 + exit 2 +} + +require_exact_preview_value() { + required_key=${1:?usage: require_exact_preview_value KEY VALUE FILE} + required_value=${2:?usage: require_exact_preview_value KEY VALUE FILE} + required_file=${3:?usage: require_exact_preview_value KEY VALUE FILE} + actual_value=$(preview_env_value "$required_key" "$required_file") + test "$actual_value" = "$required_value" || preview_fail "$required_key must be $required_value" +} + +require_synthetic_secret() { + secret_key=${1:?usage: require_synthetic_secret KEY MIN_LENGTH FILE} + secret_min_length=${2:?usage: require_synthetic_secret KEY MIN_LENGTH FILE} + secret_file=${3:?usage: require_synthetic_secret KEY MIN_LENGTH FILE} + secret_value=$(preview_env_value "$secret_key" "$secret_file") + case "$secret_value" in + synthetic-*) ;; + *) preview_fail "$secret_key must remain visibly synthetic" ;; + esac + test "${#secret_value}" -ge "$secret_min_length" || preview_fail "$secret_key is too short" +} + +require_withdrawal_operator_image() { + operator_env_file=${1:?usage: require_withdrawal_operator_image FILE} + operator_tag=$(preview_env_value EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG "$operator_env_file") + operator_expected_sha=$(preview_env_value EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA "$operator_env_file") + operator_environment=$(preview_env_value EARLYBIRDS_PREVIEW_ENV "$operator_env_file") + if test "$operator_environment" = synthetic && \ + test "$operator_tag" = synthetic && \ + test "$operator_expected_sha" = synthetic-preview; then + : + else + test -n "$operator_tag" || preview_fail 'EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG is required' + test -n "$operator_expected_sha" || preview_fail 'EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA is required' + printf '%s\n' "$operator_tag" | grep -Eq '^[0-9a-f]{40}$' || \ + preview_fail 'EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG must be an exact lowercase sha40' + test "$operator_expected_sha" = "$operator_tag" || \ + preview_fail 'withdrawal operator image tag must match EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA' + fi + operator_image="harmonic-beacon/earlybirds-preview-listener:$operator_tag" + operator_actual_sha=$(docker image inspect "$operator_image" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | \ + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) + test "$operator_actual_sha" = "$operator_expected_sha" || \ + preview_fail 'withdrawal operator image provenance does not match its pinned tag' +} + +verify_running_withdrawal_operator() { + operator_env_file=${1:?usage: verify_running_withdrawal_operator FILE} + operator_expected_sha=$(preview_env_value EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA "$operator_env_file") + operator_container="${LISTENER_WITHDRAWAL_CONTAINER:-earlybirds-preview-withdrawal-operator-1}" + operator_state='' + operator_attempt=0 + while test "$operator_attempt" -lt 60; do + operator_state=$(docker inspect "$operator_container" \ + --format '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true) + test "$operator_state" != 'true healthy' || break + operator_attempt=$((operator_attempt + 1)) + sleep 1 + done + test "$operator_state" = 'true healthy' || preview_fail 'withdrawal operator container is not healthy' + operator_running_sha=$(docker inspect "$operator_container" --format '{{range .Config.Env}}{{println .}}{{end}}' | \ + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) + test "$operator_running_sha" = "$operator_expected_sha" || \ + preview_fail 'running withdrawal operator provenance does not match its pinned SHA' +} + +require_synthetic_env() { + env_file=${1:?usage: provide a synthetic preview env file} + test -f "$env_file" || preview_fail "preview env file not found: $env_file" + + require_exact_preview_value EARLYBIRDS_PREVIEW_ENV synthetic "$env_file" + require_exact_preview_value EARLYBIRDS_PREVIEW_DB_USER earlybirds_preview "$env_file" + require_exact_preview_value EARLYBIRDS_PREVIEW_DB_NAME earlybirds_preview "$env_file" + require_exact_preview_value EARLYBIRDS_PREVIEW_APP_PORT 13000 "$env_file" + require_exact_preview_value BEACON_STREAM_HOST_PORT 18080 "$env_file" + schema_version=$(preview_env_value EARLYBIRDS_PREVIEW_SCHEMA_VERSION "$env_file") + printf '%s\n' "$schema_version" | grep -Eq '^[0-9]{14}_[a-z0-9_]+$' || \ + preview_fail 'EARLYBIRDS_PREVIEW_SCHEMA_VERSION must name a checked-in Prisma migration' + google_client_id=$(preview_env_value EARLY_BIRDS_GOOGLE_CLIENT_ID "$env_file") + google_client_secret=$(preview_env_value EARLY_BIRDS_GOOGLE_CLIENT_SECRET "$env_file") + apple_enabled=$(preview_env_value BEACON_LISTENER_APPLE_ENABLED "$env_file") + apple_client_id=$(preview_env_value BEACON_LISTENER_APPLE_CLIENT_ID "$env_file") + apple_client_secret=$(preview_env_value BEACON_LISTENER_APPLE_CLIENT_SECRET "$env_file") + account_enabled=$(preview_env_value BEACON_LISTENER_ACCOUNT_ENABLED "$env_file") + account_client_prod=$(preview_env_value BEACON_LISTENER_ACCOUNT_CLIENT_SECRET "$env_file") + account_client_staging=$(preview_env_value BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING "$env_file") + account_state_prod=$(preview_env_value BEACON_LISTENER_ACCOUNT_STATE_SECRET "$env_file") + account_state_staging=$(preview_env_value BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING "$env_file") + if { test -n "$google_client_id" && test -z "$google_client_secret"; } || \ + { test -z "$google_client_id" && test -n "$google_client_secret"; }; then + preview_fail 'Google OAuth client ID and secret must be configured together' + fi + if { test -n "$apple_client_id" && test -z "$apple_client_secret"; } || \ + { test -z "$apple_client_id" && test -n "$apple_client_secret"; }; then + preview_fail 'Apple OAuth client ID and secret must be configured together' + fi + case "$apple_enabled" in 0|1) ;; *) preview_fail 'BEACON_LISTENER_APPLE_ENABLED must be 0 or 1' ;; esac + if test "$apple_enabled" = 1 && { test -z "$apple_client_id" || test -z "$apple_client_secret"; }; then + preview_fail 'enabled Apple OAuth requires its client ID and client-secret JWT' + fi + case "$account_enabled" in 0|1) ;; *) preview_fail 'BEACON_LISTENER_ACCOUNT_ENABLED must be 0 or 1' ;; esac + if test "$account_enabled" = 1; then + for account_secret in "$account_client_prod" "$account_state_prod"; do + test "${#account_secret}" -ge 32 || preview_fail 'Listener Account RP secrets must contain at least 32 characters' + done + test "$account_client_prod" != "$account_state_prod" || preview_fail 'Listener Account client and state secrets must differ' + test -z "$account_client_staging" && test -z "$account_state_staging" || + preview_fail 'production Listener must not contain staging Account secrets' + require_exact_preview_value EARLY_BIRDS_AUTH_BASE_URL https://listen.harmonicbeacon.com "$env_file" + require_exact_preview_value EARLY_BIRDS_TRUSTED_ORIGINS \ + https://listen.harmonicbeacon.com,https://earlybirds-staging.harmonicbeacon.com "$env_file" + else + for account_secret in "$account_client_prod" "$account_client_staging" "$account_state_prod" "$account_state_staging"; do + test -z "$account_secret" || preview_fail 'disabled Listener must not carry Account RP secrets' + done + fi + if test "$account_enabled" = 1; then + # The exact production Listener origin was already required together with + # its production-only RP secret pair above. + : + elif test -n "$google_client_id" || test -n "$apple_client_id"; then + oauth_auth_base=$(preview_env_value EARLY_BIRDS_AUTH_BASE_URL "$env_file") + case "$oauth_auth_base" in + https://earlybirds-staging.harmonicbeacon.com) + require_exact_preview_value EARLY_BIRDS_TRUSTED_ORIGINS \ + https://earlybirds-staging.harmonicbeacon.com "$env_file" + ;; + https://listen.harmonicbeacon.com) + require_exact_preview_value EARLY_BIRDS_TRUSTED_ORIGINS \ + https://listen.harmonicbeacon.com,https://earlybirds-staging.harmonicbeacon.com "$env_file" + ;; + *) preview_fail 'OAuth auth base must be an exact reviewed Listener host' ;; + esac + else + require_exact_preview_value EARLY_BIRDS_AUTH_BASE_URL https://earlybirds-staging.harmonicbeacon.com "$env_file" + require_exact_preview_value EARLY_BIRDS_TRUSTED_ORIGINS https://earlybirds-staging.harmonicbeacon.com "$env_file" + fi + require_exact_preview_value EARLY_BIRDS_STREAM_ORIGIN https://stream.harmonicbeacon.com "$env_file" + require_exact_preview_value EARLY_BIRDS_STREAM_CONTROL_ORIGIN http://beacon-stream:8080 "$env_file" + require_exact_preview_value BEACON_STREAM_PUBLIC_ORIGIN https://stream.harmonicbeacon.com "$env_file" + stream_allowed_origins=$(preview_env_value BEACON_STREAM_ALLOWED_ORIGINS "$env_file") + case "$stream_allowed_origins" in + https://earlybirds-staging.harmonicbeacon.com|\ + https://earlybirds-staging.harmonicbeacon.com,https://listen.harmonicbeacon.com) ;; + *) preview_fail 'BEACON_STREAM_ALLOWED_ORIGINS must contain only the reviewed Listener hosts' ;; + esac + listener_artifact=$(preview_env_value EARLY_BIRDS_STREAM_ARTIFACT_ID "$env_file") + origin_artifact=$(preview_env_value BEACON_STREAM_ARTIFACT_ID "$env_file") + test "$listener_artifact" = "$origin_artifact" || preview_fail 'Listener and origin artifact IDs must match' + case "$listener_artifact" in + synthetic-preview-artifact|beacon-luz-20260624-aac320-v1|beacon-luz-20260624-2hs-aac320-v2) ;; + *) preview_fail 'stream artifact is not approved for synthetic staging' ;; + esac + require_exact_preview_value EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS earlybirds-staging.harmonicbeacon.com "$env_file" + geoip_host_path=$(preview_env_value BEACON_LISTENER_GEOIP_HOST_PATH "$env_file") + case "$geoip_host_path" in + /*/dbip-country-lite-2026-07.mmdb) ;; + *) preview_fail 'BEACON_LISTENER_GEOIP_HOST_PATH must be the reviewed absolute July 2026 Country MMDB path' ;; + esac + + kill_switch=$(preview_env_value EARLY_BIRDS_ENABLED "$env_file") + case "$kill_switch" in 0|1) ;; *) preview_fail 'EARLY_BIRDS_ENABLED must be 0 or 1' ;; esac + free_for_all_switch=$(preview_env_value EARLY_BIRDS_FREE_FOR_ALL "$env_file") + case "$free_for_all_switch" in 0|1) ;; *) preview_fail 'EARLY_BIRDS_FREE_FOR_ALL must be 0 or 1' ;; esac + team_entry_switch=$(preview_env_value EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED "$env_file") + case "$team_entry_switch" in 0|1) ;; *) preview_fail 'EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED must be 0 or 1' ;; esac + reactive_lab_switch=$(preview_env_value BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED "$env_file") + case "$reactive_lab_switch" in ''|0|1) ;; *) preview_fail 'BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED must be 0 or 1' ;; esac + withdrawal_switch=$(preview_env_value LISTENER_WITHDRAWAL_ENABLED "$env_file") + case "$withdrawal_switch" in ''|0|1) ;; *) preview_fail 'LISTENER_WITHDRAWAL_ENABLED must be 0 or 1' ;; esac + withdrawal_secret=$(preview_env_value LISTENER_WITHDRAWAL_SECRET "$env_file") + if test "$withdrawal_switch" = 1; then + test "${#withdrawal_secret}" -ge 32 || preview_fail 'LISTENER_WITHDRAWAL_SECRET is required when withdrawal is enabled' + fi + paypal_checkout_switch=$(preview_env_value BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED "$env_file") + case "$paypal_checkout_switch" in ''|0|1) ;; *) preview_fail 'BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED must be 0 or 1' ;; esac + mercado_pago_checkout_switch=$(preview_env_value BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED "$env_file") + case "$mercado_pago_checkout_switch" in ''|0|1) ;; *) preview_fail 'BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED must be 0 or 1' ;; esac + require_exact_preview_value BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED 0 "$env_file" + require_exact_preview_value BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED 0 "$env_file" + require_exact_preview_value BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED 0 "$env_file" + test -z "$(preview_env_value BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID "$env_file")" || \ + preview_fail 'synthetic preview cannot contain a private Live account allowlist' + test -z "$(preview_env_value BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER "$env_file")" || \ + preview_fail 'synthetic preview cannot select a private Live provider' + test -z "$(preview_env_value BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET "$env_file")" || \ + preview_fail 'synthetic preview cannot contain a private Live CSRF secret' + require_exact_preview_value EARLY_BIRDS_TEST_ACCESS_ENABLED 1 "$env_file" + + authority_network=$(preview_env_value EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK "$env_file") + if test -n "$authority_network"; then + test "$authority_network" = earlybirds_authority_private || preview_fail 'authority network must be earlybirds_authority_private' + require_exact_preview_value EARLY_BIRDS_AUTHORITY_BASE_URL http://pmp-myth-api:8765 "$env_file" + else + require_exact_preview_value EARLY_BIRDS_AUTHORITY_BASE_URL https://authority.example.invalid "$env_file" + fi + + require_synthetic_secret EARLYBIRDS_PREVIEW_DB_PASSWORD 24 "$env_file" + require_synthetic_secret EARLY_BIRDS_AUTH_SECRET 32 "$env_file" + require_synthetic_secret EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN 43 "$env_file" + require_synthetic_secret EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT 43 "$env_file" + require_synthetic_secret EARLY_BIRDS_STREAM_SIGNING_SECRET 32 "$env_file" + require_synthetic_secret EARLY_BIRDS_DEVICE_PEPPER 32 "$env_file" + require_synthetic_secret EARLY_BIRDS_TEST_LOGIN_SECRET 32 "$env_file" + require_synthetic_secret BEACON_STREAM_SIGNING_SECRET 32 "$env_file" + + listener_signing_secret=$(preview_env_value EARLY_BIRDS_STREAM_SIGNING_SECRET "$env_file") + origin_signing_secret=$(preview_env_value BEACON_STREAM_SIGNING_SECRET "$env_file") + test "$listener_signing_secret" = "$origin_signing_secret" || preview_fail 'Listener and origin signing secrets must match' + + effective_assignments=$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$env_file" || true) + while IFS= read -r assignment; do + test -n "$assignment" || continue + case "$assignment" in + EARLY_BIRDS_AUTH_BASE_URL=https://earlybirds-staging.harmonicbeacon.com|\ + EARLY_BIRDS_TRUSTED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com|\ + EARLY_BIRDS_AUTH_BASE_URL=https://listen.harmonicbeacon.com|\ + EARLY_BIRDS_TRUSTED_ORIGINS=https://listen.harmonicbeacon.com,https://earlybirds-staging.harmonicbeacon.com|\ + EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com|\ + EARLY_BIRDS_STREAM_ORIGIN=https://stream.harmonicbeacon.com|\ + EARLY_BIRDS_STREAM_CONTROL_ORIGIN=http://beacon-stream:8080|\ + BEACON_STREAM_PUBLIC_ORIGIN=https://stream.harmonicbeacon.com|\ + BEACON_STREAM_ALLOWED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com|\ + BEACON_STREAM_ALLOWED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com,https://listen.harmonicbeacon.com) ;; + *harmonicbeacon.com*) preview_fail 'synthetic preview env contains a non-staging Harmonic Beacon hostname' ;; + esac + assignment_value=${assignment#*=} + case "$assignment_value" in + *[Pp][Aa][Yy][Pp][Aa][Ll]*|*[Mm][Ee][Rr][Cc][Aa][Dd][Oo][Pp][Aa][Gg][Oo]*|*[Pp][Rr][Oo][Dd][Uu][Cc][Tt][Ii][Oo][Nn]*) + preview_fail 'synthetic preview env contains a production/provider value' + ;; + esac + done </dev/null || true) + test "$authority_internal" = true || preview_fail 'authority network must already exist with Internal=true' + docker compose --project-name "$preview_project" --env-file "$env_file" \ + -f "$preview_compose" -f "$stream_compose" -f "$preview_overlay" \ + -f "$authority_overlay" "$@" + else + docker compose --project-name "$preview_project" --env-file "$env_file" \ + -f "$preview_compose" -f "$stream_compose" -f "$preview_overlay" "$@" + fi +} diff --git a/scripts/early-birds-preview/listener-live-dormant-check.mjs b/scripts/early-birds-preview/listener-live-dormant-check.mjs new file mode 100644 index 00000000..717bf42c --- /dev/null +++ b/scripts/early-birds-preview/listener-live-dormant-check.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +import { pathToFileURL } from 'node:url'; + +const REQUEST_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 64 * 1024; +const ATTEMPT_ID = '00000000-0000-4000-8000-000000000000'; + +const hosts = Object.freeze({ + listener: 'https://listen.harmonicbeacon.com', + staging: 'https://earlybirds-staging.harmonicbeacon.com', + event: 'https://live.harmonicbeacon.com', +}); + +const checks = Object.freeze([ + { name: 'listener-health', kind: 'health', url: `${hosts.listener}/api/health` }, + { name: 'listener-readiness', kind: 'health', url: `${hosts.listener}/api/health/ready` }, + { name: 'staging-readiness', kind: 'health', url: `${hosts.staging}/api/health/ready` }, + { name: 'event-readiness', kind: 'health', url: `${hosts.event}/api/health/ready` }, + { name: 'listener-terms', kind: 'html', url: `${hosts.listener}/listener/terms` }, + { name: 'listener-privacy', kind: 'html', url: `${hosts.listener}/listener/privacy` }, + { name: 'listener-withdrawal', kind: 'html', url: `${hosts.listener}/listener/withdrawal` }, + { + name: 'listener-service-cancellation', + kind: 'html', + url: `${hosts.listener}/listener/cancel-service`, + }, + { + name: 'listener-paypal-live-checkout-off', + kind: 'closed', + url: `${hosts.listener}/api/listener/checkout`, + origin: hosts.listener, + body: { provider: 'paypal', attemptId: ATTEMPT_ID }, + }, + { + name: 'listener-mercado-pago-live-checkout-off', + kind: 'closed', + url: `${hosts.listener}/api/listener/checkout`, + origin: hosts.listener, + body: { provider: 'mercado_pago', attemptId: ATTEMPT_ID }, + }, + { + name: 'listener-live-workbench-absent', + kind: 'closed', + url: `${hosts.listener}/api/listener/checkout/live-workbench`, + origin: hosts.listener, + body: { attemptId: ATTEMPT_ID }, + }, + { + name: 'staging-live-workbench-off', + kind: 'closed', + url: `${hosts.staging}/api/listener/checkout/live-workbench`, + origin: hosts.staging, + body: { attemptId: ATTEMPT_ID }, + }, + { + name: 'event-checkout-absent', + kind: 'closed', + url: `${hosts.event}/api/listener/checkout`, + origin: hosts.event, + body: { provider: 'paypal', attemptId: ATTEMPT_ID }, + }, + { + name: 'event-live-workbench-absent', + kind: 'closed', + url: `${hosts.event}/api/listener/checkout/live-workbench`, + origin: hosts.event, + body: { attemptId: ATTEMPT_ID }, + }, +]); + +async function boundedBody(response) { + const declared = response.headers.get('content-length'); + if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_RESPONSE_BYTES)) { + await response.body?.cancel().catch(() => undefined); + throw new Error('invalid_response'); + } + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new Error('invalid_response'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const joined = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder('utf-8', { fatal: true }).decode(joined); +} + +async function runCheck(check, fetchImpl) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const isPost = check.kind === 'closed'; + const response = await fetchImpl(check.url, { + method: isPost ? 'POST' : 'GET', + redirect: 'manual', + cache: 'no-store', + credentials: 'omit', + signal: controller.signal, + headers: isPost ? { + accept: 'application/json', + 'content-type': 'application/json', + origin: check.origin, + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + } : { accept: check.kind === 'html' ? 'text/html' : 'application/json' }, + body: isPost ? JSON.stringify(check.body) : undefined, + }); + if (check.kind === 'closed') { + await response.body?.cancel().catch(() => undefined); + return { name: check.name, passed: response.status === 404, status: response.status }; + } + if (response.status !== 200) { + await response.body?.cancel().catch(() => undefined); + return { name: check.name, passed: false, status: response.status }; + } + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim(); + const body = await boundedBody(response); + if (check.kind === 'html') { + return { name: check.name, passed: contentType === 'text/html' && body.length > 0, status: 200 }; + } + let value; + try { + value = JSON.parse(body); + } catch { + value = null; + } + return { + name: check.name, + passed: contentType === 'application/json' && value?.status === 'ok', + status: 200, + }; + } catch { + return { name: check.name, passed: false, status: null }; + } finally { + clearTimeout(timeout); + } +} + +export async function verifyDormantListenerLiveState({ fetchImpl = fetch } = {}) { + const results = await Promise.all(checks.map((check) => runCheck(check, fetchImpl))); + return { + schemaVersion: 'listener-live-dormant-check.v1', + status: results.every((result) => result.passed) ? 'PASS' : 'FAIL', + checks: results, + }; +} + +async function main() { + if (process.argv.length !== 2) { + process.stderr.write('Usage: node scripts/early-birds-preview/listener-live-dormant-check.mjs\n'); + process.exitCode = 2; + return; + } + const result = await verifyDormantListenerLiveState(); + process.stdout.write(`${JSON.stringify(result)}\n`); + if (result.status !== 'PASS') process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/scripts/early-birds-preview/registered-free-smoke.sh b/scripts/early-birds-preview/registered-free-smoke.sh new file mode 100755 index 00000000..1a1ed41a --- /dev/null +++ b/scripts/early-birds-preview/registered-free-smoke.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env sh +set -eu +umask 077 +. "$(dirname -- "$0")/lib.sh" + +env_file=${1:?usage: registered-free-smoke.sh PREVIEW_ENV [BASE_URL]} +base_url=${2:-https://earlybirds-staging.harmonicbeacon.com} +require_synthetic_env "$env_file" +command -v jq >/dev/null 2>&1 || preview_fail "jq is required" + +case "$base_url" in + https://earlybirds-staging.harmonicbeacon.com|https://listen.harmonicbeacon.com) ;; + *) preview_fail "BASE_URL must be an exact Listener staging or public host" ;; +esac + +temporary=$(mktemp -d) +trap 'rm -rf "$temporary"' EXIT HUP INT TERM +login_secret=$(preview_env_value EARLY_BIRDS_TEST_LOGIN_SECRET "$env_file") +run_id="$(date +%s)-$$" +cookie_jar="$temporary/listener.cookies" + +printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \ + "$login_secret" >"$temporary/login.curl" +printf '{"name":"Weekly quota smoke","email":"weekly-quota-%s@e2e.invalid","authOnly":true}' \ + "$run_id" >"$temporary/login.json" +login_status=$(curl --silent --show-error --output "$temporary/login.response" \ + --write-out '%{http_code}' --request POST --config "$temporary/login.curl" \ + --cookie-jar "$cookie_jar" --data-binary @"$temporary/login.json" \ + "$base_url/api/early-birds/test-login") +test "$login_status" = 200 || preview_fail "synthetic auth-only login returned HTTP $login_status" +jq -e '.ok == true' "$temporary/login.response" >/dev/null || \ + preview_fail "synthetic auth-only login response is invalid" + +initial_status=$(curl --silent --show-error --output "$temporary/initial.json" \ + --write-out '%{http_code}' --cookie "$cookie_jar" \ + "$base_url/api/listener/access-state") +test "$initial_status" = 200 || preview_fail "initial quota state returned HTTP $initial_status" +jq -e ' + .access.kind == "free-quota" and + .access.quota.policy == "personal-7-day-v1" and + .access.quota.status == "not-started" and + .access.quota.cycleStartedAt == null and + .access.quota.baseAllowanceMs == 10800000 and + .access.quota.remainingMs == 10800000 +' "$temporary/initial.json" >/dev/null || preview_fail "initial weekly quota state is invalid" + +for removed in free-window welcome-access; do + removed_status=$(curl --silent --show-error --output "$temporary/$removed.json" \ + --write-out '%{http_code}' --cookie "$cookie_jar" \ + "$base_url/api/listener/$removed") + test "$removed_status" = 404 || preview_fail "$removed legacy authority returned HTTP $removed_status" +done + +for ordinal in 1 2 3; do + printf '{"deviceId":"weekly_quota_%s_device_%s","intent":"play"}' \ + "$run_id" "$ordinal" >"$temporary/lease-$ordinal.json" + lease_status=$(curl --silent --show-error --output "$temporary/lease-$ordinal.response" \ + --write-out '%{http_code}' --request POST --header 'Content-Type: application/json' \ + --cookie "$cookie_jar" --data-binary @"$temporary/lease-$ordinal.json" \ + "$base_url/api/early-birds/stream/lease") + test "$lease_status" = 200 || preview_fail "device $ordinal lease returned HTTP $lease_status" + jq -e ' + .accessKind == "free-quota" and + .quota.policy == "personal-7-day-v1" and + .quota.status == "listening" and + (.quota.remainingMs > 0 and .quota.remainingMs <= 10800000) and + (.leaseGeneration | type == "number") and + (.presenceSequence | type == "number") + ' "$temporary/lease-$ordinal.response" >/dev/null || preview_fail "device $ordinal quota lease is invalid" +done + +jq -e -s ' + .[0].evictedAnotherDevice == false and + .[1].evictedAnotherDevice == false and + .[2].evictedAnotherDevice == true +' "$temporary/lease-1.response" "$temporary/lease-2.response" "$temporary/lease-3.response" \ + >/dev/null || preview_fail "two-device eviction is invalid" + +first_lease=$(jq -er '.leaseId' "$temporary/lease-1.response") +first_generation=$(jq -er '.leaseGeneration' "$temporary/lease-1.response") +first_sequence=$(jq -er '.presenceSequence' "$temporary/lease-1.response") +printf '{"leaseId":"%s","leaseGeneration":%s,"presenceSequence":%s,"intent":"play","presence":"listening"}' \ + "$first_lease" "$first_generation" "$first_sequence" >"$temporary/heartbeat.json" +heartbeat_status=$(curl --silent --show-error --output "$temporary/heartbeat.response" \ + --write-out '%{http_code}' --request POST --header 'Content-Type: application/json' \ + --cookie "$cookie_jar" --data-binary @"$temporary/heartbeat.json" \ + "$base_url/api/early-birds/stream/heartbeat") +test "$heartbeat_status" = 410 || preview_fail "displaced oldest device returned HTTP $heartbeat_status" +jq -e '.reason == "displaced"' "$temporary/heartbeat.response" >/dev/null || \ + preview_fail "oldest device displacement response is invalid" + +third_lease=$(jq -er '.leaseId' "$temporary/lease-3.response") +third_generation=$(jq -er '.leaseGeneration' "$temporary/lease-3.response") +third_sequence=$(jq -er '.presenceSequence' "$temporary/lease-3.response") +manifest_url=$(jq -er '.stream.manifestUrl' "$temporary/lease-3.response") +printf '%s\n' "$manifest_url" | grep -Eq \ + '^https://stream\.harmonicbeacon\.com/v1/hls/[A-Za-z0-9._-]+/live\.m3u8\?grantId=[a-f0-9]{64}&grant=[A-Za-z0-9_-]{43}$' || \ + preview_fail "active Free lease did not return the bounded direct-origin grant" +printf 'url = "%s"\nheader = "Origin: %s"\n' \ + "$manifest_url" "$base_url" >"$temporary/manifest.curl" +manifest_status=$(curl --silent --show-error --output "$temporary/manifest.m3u8" \ + --write-out '%{http_code}' --config "$temporary/manifest.curl") +test "$manifest_status" = 200 || preview_fail "direct-origin Free manifest returned HTTP $manifest_status" +grep -q '^#EXTM3U' "$temporary/manifest.m3u8" || preview_fail "active Free manifest is invalid" +segment_url=$(grep -m1 '^https://stream\.harmonicbeacon\.com/v1/hls/' "$temporary/manifest.m3u8") +test -n "$segment_url" || preview_fail "direct-origin manifest contains no media segment" +printf 'url = "%s"\nheader = "Origin: %s"\n' \ + "$segment_url" "$base_url" >"$temporary/segment.curl" +segment_status=$(curl --silent --show-error --output "$temporary/segment.bin" \ + --write-out '%{http_code}' --config "$temporary/segment.curl") +test "$segment_status" = 200 || preview_fail "direct-origin media segment returned HTTP $segment_status" +test -s "$temporary/segment.bin" || preview_fail "direct-origin media segment is empty" + +printf '{"leaseId":"%s","leaseGeneration":%s,"presenceSequence":%s,"intent":"play","presence":"listening"}' \ + "$third_lease" "$third_generation" "$third_sequence" >"$temporary/renew.json" +renew_status=$(curl --silent --show-error --output "$temporary/renew.response" \ + --write-out '%{http_code}' --request POST --header 'Content-Type: application/json' \ + --cookie "$cookie_jar" --data-binary @"$temporary/renew.json" \ + "$base_url/api/early-birds/stream/heartbeat") +test "$renew_status" = 200 || preview_fail "direct-origin grant renewal returned HTTP $renew_status" +renewed_manifest_url=$(jq -er '.stream.manifestUrl' "$temporary/renew.response") +test "$renewed_manifest_url" = "$manifest_url" || \ + preview_fail "heartbeat replaced the active media URL" + +legacy_manifest_status=$(curl --silent --show-error --output /dev/null \ + --write-out '%{http_code}' --cookie "$cookie_jar" \ + "$base_url/api/early-birds/stream/manifest?leaseId=$third_lease&leaseGeneration=$third_generation") +test "$legacy_manifest_status" = 404 || \ + preview_fail "removed Listener media proxy returned HTTP $legacy_manifest_status" + +active_status=$(curl --silent --show-error --output "$temporary/active.json" \ + --write-out '%{http_code}' --cookie "$cookie_jar" \ + "$base_url/api/listener/access-state") +test "$active_status" = 200 || preview_fail "active quota state returned HTTP $active_status" +jq -e ' + .access.kind == "free-quota" and + .access.quota.status == "listening" and + (.access.quota.cycleStartedAt | type == "string") and + ((.access.quota.cycleEndsAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) - + (.access.quota.cycleStartedAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) == 604800) and + (.access.quota.remainingMs > 0 and .access.quota.remainingMs <= 10800000) +' "$temporary/active.json" >/dev/null || preview_fail "active weekly quota state is invalid" + +echo "Registered Free smoke passed: weekly quota, two-device eviction, stable direct-origin grant, decoded media bytes, and removed Listener media proxy." diff --git a/scripts/early-birds-preview/rehearse-migration.sh b/scripts/early-birds-preview/rehearse-migration.sh new file mode 100755 index 00000000..a531392d --- /dev/null +++ b/scripts/early-birds-preview/rehearse-migration.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" +env_file=${1:?usage: scripts/early-birds-preview/rehearse-migration.sh /secure/preview.env} +require_synthetic_env "$env_file" +preview_compose_command "$env_file" up -d postgres +# The migration service has no immutable image tag of its own. Build it from +# this exact release before running so Compose cannot reuse a migrator created +# from an older checkout and silently report that the new migration is absent. +preview_compose_command "$env_file" build migration +preview_compose_command "$env_file" run --rm migration +echo 'Preview-only Prisma migrate deploy passed. Rollback is kill-switch/route disable plus an additive forward migration.' diff --git a/scripts/early-birds-preview/rollback.sh b/scripts/early-birds-preview/rollback.sh new file mode 100755 index 00000000..8dd596a5 --- /dev/null +++ b/scripts/early-birds-preview/rollback.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" +env_file=${1:?usage: scripts/early-birds-preview/rollback.sh /secure/preview.env} +require_synthetic_env "$env_file" + +# Stop only the application control plane. PostgreSQL and its named volume +# remain intact for inspection, while the approved long-lived origin keeps +# serving already issued short-lived media URLs. Use stop-stream.sh only for +# a separately diagnosed origin incident. +preview_compose_command "$env_file" stop listener +echo 'EarlyBirds Listener stopped; withdrawal operator, preview PostgreSQL and Beacon origin were retained.' +echo 'Set EARLY_BIRDS_ENABLED=0, EARLY_BIRDS_FREE_FOR_ALL=0 and EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0 before the next start.' +echo 'No live/event service or volume was targeted.' diff --git a/scripts/early-birds-preview/start-origin.sh b/scripts/early-birds-preview/start-origin.sh new file mode 100755 index 00000000..e351d994 --- /dev/null +++ b/scripts/early-birds-preview/start-origin.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" +env_file=${1:?usage: scripts/early-birds-preview/start-origin.sh /secure/preview.env} +require_synthetic_env "$env_file" + +echo 'This command may recreate the isolated Beacon audio origin.' +echo 'Run it only in an explicit origin maintenance window with a decoded-audio canary ready.' +preview_compose_command "$env_file" up -d --build --no-deps beacon-stream +echo 'Beacon stream origin updated. Run health-smoke.sh and decoded-audio acceptance immediately.' diff --git a/scripts/early-birds-preview/start.sh b/scripts/early-birds-preview/start.sh new file mode 100755 index 00000000..68ba24b8 --- /dev/null +++ b/scripts/early-birds-preview/start.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" +env_file=${1:?usage: scripts/early-birds-preview/start.sh /secure/preview.env} +require_synthetic_env "$env_file" + +# Compose's completed-successfully dependency makes this order fail closed: +# PostgreSQL health -> forward-only migration -> Listener readiness. The +# long-lived audio origin is intentionally outside an ordinary app release; +# use start-origin.sh only in its own reviewed maintenance window. +preview_compose_command "$env_file" build listener +require_withdrawal_operator_image "$env_file" +preview_compose_command "$env_file" up -d listener withdrawal-operator +verify_running_withdrawal_operator "$env_file" +kill_switch=$(preview_env_value EARLY_BIRDS_ENABLED "$env_file") +free_for_all_switch=$(preview_env_value EARLY_BIRDS_FREE_FOR_ALL "$env_file") +team_entry_switch=$(preview_env_value EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED "$env_file") +echo "EarlyBirds synthetic preview started with EARLY_BIRDS_ENABLED=$kill_switch, EARLY_BIRDS_FREE_FOR_ALL=$free_for_all_switch and EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=$team_entry_switch." +echo 'The Beacon stream origin was not rebuilt, recreated or restarted.' +echo 'Run health-smoke.sh; keep the public entry disabled until every gate passes.' diff --git a/scripts/early-birds-preview/stop.sh b/scripts/early-birds-preview/stop.sh new file mode 100755 index 00000000..f22229c6 --- /dev/null +++ b/scripts/early-birds-preview/stop.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" +env_file=${1:?usage: scripts/early-birds-preview/stop.sh /secure/preview.env} +require_synthetic_env "$env_file" +preview_compose_command "$env_file" stop listener withdrawal-operator beacon-stream postgres +echo 'EarlyBirds preview stopped. The preview database volume and migration evidence were retained.' diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs new file mode 100644 index 00000000..949696db --- /dev/null +++ b/scripts/early-birds-preview/validate.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '../..'); +const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'earlybirds-preview-')); +const envFile = path.join(temporary, 'preview.env'); +const syntheticSecret = 'synthetic-preview-stream-signing-secret-at-least-32-characters'; + +const syntheticEnv = [ + 'EARLYBIRDS_PREVIEW_ENV=synthetic', + 'EARLYBIRDS_PREVIEW_DB_USER=earlybirds_preview', + 'EARLYBIRDS_PREVIEW_DB_PASSWORD=synthetic-preview-database-password', + 'EARLYBIRDS_PREVIEW_DB_NAME=earlybirds_preview', + 'EARLYBIRDS_PREVIEW_APP_PORT=13000', + 'EARLYBIRDS_PREVIEW_IMAGE_TAG=synthetic', + 'EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG=synthetic', + 'EARLYBIRDS_WITHDRAWAL_OPERATOR_GIT_SHA=synthetic-preview', + 'EARLYBIRDS_PREVIEW_GIT_SHA=synthetic-preview', + 'EARLYBIRDS_PREVIEW_BUILD_TIME=synthetic-preview', + 'EARLYBIRDS_PREVIEW_SCHEMA_VERSION=preview-forward-only', + 'EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK=', + 'EARLY_BIRDS_ENABLED=0', + 'EARLY_BIRDS_FREE_FOR_ALL=0', + 'EARLY_BIRDS_AUTH_BASE_URL=https://earlybirds-staging.harmonicbeacon.com', + 'EARLY_BIRDS_TRUSTED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com', + 'EARLY_BIRDS_AUTH_SECRET=synthetic-preview-auth-secret-at-least-32-characters', + 'EARLY_BIRDS_GOOGLE_CLIENT_ID=', + 'EARLY_BIRDS_GOOGLE_CLIENT_SECRET=', + 'BEACON_LISTENER_APPLE_ENABLED=0', + 'BEACON_LISTENER_APPLE_CLIENT_ID=', + 'BEACON_LISTENER_APPLE_CLIENT_SECRET=', + 'BEACON_LISTENER_ACCOUNT_ENABLED=0', + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=', + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING=', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET=', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING=', + 'EARLY_BIRDS_TEST_ACCESS_ENABLED=1', + 'EARLY_BIRDS_TEST_LOGIN_SECRET=synthetic-preview-login-secret-at-least-32-characters', + 'EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0', + 'BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED=0', + 'LISTENER_WITHDRAWAL_ENABLED=0', + 'LISTENER_WITHDRAWAL_SECRET=', + 'EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com', + 'BEACON_LISTENER_GEOIP_HOST_PATH=.', + 'EARLY_BIRDS_AUTHORITY_BASE_URL=https://authority.example.invalid', + 'EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID=synthetic-v1', + 'EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN=synthetic-preview-authority-token-at-least-43-characters-long', + 'EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID=synthetic-v1', + 'EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT=synthetic-preview-inbound-token-at-least-43-characters-long', + 'EARLY_BIRDS_STREAM_ORIGIN=https://stream.harmonicbeacon.com', + 'EARLY_BIRDS_STREAM_CONTROL_ORIGIN=http://beacon-stream:8080', + 'EARLY_BIRDS_STREAM_ARTIFACT_ID=synthetic-preview-artifact', + `EARLY_BIRDS_STREAM_SIGNING_SECRET=${syntheticSecret}`, + 'EARLY_BIRDS_DEVICE_PEPPER=synthetic-preview-device-pepper-at-least-32-characters', + 'EARLY_BIRDS_DROPIN_ES_PATH=', + 'EARLY_BIRDS_DROPIN_EN_PATH=', + 'BEACON_STREAM_ARTIFACTS_HOST_PATH=.', + 'BEACON_STREAM_MEDIA_ROOT=/media/artifacts', + 'BEACON_STREAM_ARTIFACT_ID=synthetic-preview-artifact', + 'BEACON_STREAM_PUBLIC_ORIGIN=https://stream.harmonicbeacon.com', + 'BEACON_STREAM_ALLOWED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com', + `BEACON_STREAM_SIGNING_SECRET=${syntheticSecret}`, + 'BEACON_STREAM_HOST_PORT=18080', + '', +].join('\n'); +await fs.writeFile(envFile, syntheticEnv, { mode: 0o600 }); + +const composeArgs = [ + 'compose', + '--project-name', 'earlybirds-preview-validation', + '--env-file', envFile, + '-f', path.join(root, 'ops/early-birds-preview/compose.yml'), + '-f', path.join(root, 'services/beacon-stream/docker-compose.yml'), + '-f', path.join(root, 'ops/early-birds-preview/stream-build.override.yml'), +]; + +function publishedPort(service, target) { + return service.ports?.find((port) => Number(port.target) === target); +} + +try { + execFileSync('docker', [...composeArgs, 'config', '--quiet'], { stdio: 'inherit' }); + const rendered = execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { + encoding: 'utf8', + }); + const resolved = JSON.parse(rendered); + const { postgres, migration, listener, 'withdrawal-operator': withdrawalOperator, 'beacon-stream': stream } = resolved.services; + + assert.deepEqual(Object.keys(resolved.services).sort(), [ + 'beacon-stream', 'listener', 'migration', 'postgres', 'withdrawal-operator', + ]); + assert.equal(postgres.ports, undefined, 'PostgreSQL must not publish a host port'); + assert.deepEqual(Object.keys(postgres.networks), ['preview_db']); + assert.deepEqual(postgres.networks.preview_db.aliases, ['earlybirds-preview-postgres']); + assert.equal(resolved.networks.preview_db.internal, true); + assert.equal(resolved.networks.preview_db.name, 'earlybirds_preview_db_internal'); + assert.notEqual(resolved.networks.listener_egress.internal, true); + assert.equal(resolved.networks.listener_egress.name, 'earlybirds_preview_listener_egress'); + + assert.deepEqual(migration.command, ['npx', 'prisma', 'migrate', 'deploy']); + assert.equal(migration.profiles, undefined); + assert.equal(migration.depends_on.postgres.condition, 'service_healthy'); + assert.deepEqual(Object.keys(migration.networks), ['preview_db']); + + assert.equal(listener.build.context, root); + assert.equal(listener.build.target, 'runner'); + assert.equal(listener.environment.NODE_ENV, 'production'); + assert.equal(listener.environment.BEACON_GIT_SHA, 'synthetic-preview'); + assert.equal(listener.environment.BEACON_BUILD_TIME, 'synthetic-preview'); + assert.equal(listener.environment.BEACON_DATABASE_SCHEMA_VERSION, 'preview-forward-only'); + assert.equal(listener.environment.EARLY_BIRDS_ENABLED, '0'); + assert.equal(listener.environment.EARLY_BIRDS_FREE_FOR_ALL, '0'); + assert.equal(listener.environment.EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED, '0'); + assert.equal(listener.environment.BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED, '0'); + assert.equal(listener.environment.LISTENER_WITHDRAWAL_ENABLED, '0'); + assert.equal(listener.environment.LISTENER_WITHDRAWAL_SECRET, ''); + assert.equal( + listener.environment.EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS, + 'earlybirds-staging.harmonicbeacon.com', + ); + assert.equal(listener.environment.EARLY_BIRDS_STREAM_ORIGIN, 'https://stream.harmonicbeacon.com'); + assert.equal(listener.environment.EARLY_BIRDS_GOOGLE_CLIENT_ID, ''); + assert.equal(listener.environment.BEACON_LISTENER_APPLE_ENABLED, '0'); + assert.equal(listener.environment.BEACON_LISTENER_APPLE_CLIENT_ID, ''); + assert.equal(listener.environment.BEACON_LISTENER_ACCOUNT_ENABLED, '0'); + assert.equal(listener.environment.BEACON_LISTENER_ACCOUNT_ENVIRONMENT, 'production'); + assert.equal(listener.environment.BEACON_LISTENER_ACCOUNT_CLIENT_SECRET, ''); + assert.equal(listener.environment.BEACON_LISTENER_ACCOUNT_STATE_SECRET, ''); + assert.equal(listener.environment.BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING, undefined); + assert.equal(listener.environment.BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING, undefined); + assert.equal(listener.environment.EARLY_BIRDS_DROPIN_ES_PATH, ''); + assert.equal(listener.environment.BEACON_LISTENER_GEOIP_DB_PATH, '/data/geoip/dbip-country-lite.mmdb'); + assert.equal(listener.volumes.length, 2); + assert.equal(listener.volumes[0].type, 'bind'); + assert.equal(listener.volumes[0].target, '/media/artifacts'); + assert.equal(listener.volumes[0].read_only, true); + assert.equal(listener.volumes[1].type, 'bind'); + assert.equal(listener.volumes[1].target, '/data/geoip/dbip-country-lite.mmdb'); + assert.equal(listener.volumes[1].read_only, true); + assert.equal(listener.depends_on.postgres.condition, 'service_healthy'); + assert.equal(listener.depends_on.migration.condition, 'service_completed_successfully'); + assert.deepEqual(Object.keys(listener.networks).sort(), ['listener_egress', 'preview_db', 'stream_control']); + assert.equal(listener.environment.EARLY_BIRDS_STREAM_CONTROL_ORIGIN, 'http://beacon-stream:8080'); + const appPort = publishedPort(listener, 3000); + assert.equal(appPort.host_ip, '127.0.0.1'); + assert.equal(Number(appPort.published), 13000); + + assert.equal(withdrawalOperator.image, 'harmonic-beacon/earlybirds-preview-listener:synthetic'); + assert.equal(withdrawalOperator.restart, 'unless-stopped'); + assert.deepEqual(withdrawalOperator.command, ['tail', '-f', '/dev/null']); + assert.equal(withdrawalOperator.ports, undefined); + assert.equal(withdrawalOperator.volumes, undefined); + assert.deepEqual(Object.keys(withdrawalOperator.networks), ['preview_db']); + assert.equal(withdrawalOperator.depends_on.postgres.condition, 'service_healthy'); + assert.equal(withdrawalOperator.depends_on.migration.condition, 'service_completed_successfully'); + assert.match(withdrawalOperator.environment.DATABASE_URL, /@earlybirds-preview-postgres:5432/); + + assert.equal(stream.build.context, path.join(root, 'services/beacon-stream')); + assert.equal(stream.build.dockerfile, 'Dockerfile'); + assert.deepEqual(Object.keys(stream.networks).sort(), ['stream_control', 'stream_edge', 'stream_observability']); + assert.equal(resolved.networks.stream_control.internal, true); + assert.equal(resolved.networks.stream_control.name, 'earlybirds_stream_control_internal'); + assert.equal(resolved.networks.stream_observability.internal, true); + assert.equal(resolved.networks.stream_observability.name, 'earlybirds_stream_observability'); + assert.notEqual(resolved.networks.stream_edge.internal, true); + assert.equal(resolved.networks.stream_edge.name, 'earlybirds_stream_edge'); + const streamPort = publishedPort(stream, 8080); + assert.equal(streamPort.host_ip, '127.0.0.1'); + assert.equal(Number(streamPort.published), 18080); + + const authorityEnvFile = path.join(temporary, 'authority-preview.env'); + await fs.writeFile( + authorityEnvFile, + syntheticEnv + .replace('EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK=', 'EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK=earlybirds_authority_private') + .replace('EARLY_BIRDS_AUTHORITY_BASE_URL=https://authority.example.invalid', 'EARLY_BIRDS_AUTHORITY_BASE_URL=http://pmp-myth-api:8765'), + { mode: 0o600 }, + ); + const authorityComposeArgs = composeArgs.map((argument) => ( + argument === envFile ? authorityEnvFile : argument + )); + authorityComposeArgs.push( + '-f', path.join(root, 'ops/early-birds-preview/authority-network.override.yml'), + ); + execFileSync('docker', [...authorityComposeArgs, 'config', '--quiet'], { stdio: 'inherit' }); + const authorityResolved = JSON.parse(execFileSync( + 'docker', [...authorityComposeArgs, 'config', '--format', 'json'], { encoding: 'utf8' }, + )); + assert.equal(authorityResolved.networks.authority_private.external, true); + assert.equal(authorityResolved.networks.authority_private.name, 'earlybirds_authority_private'); + assert.deepEqual(Object.keys(authorityResolved.services.listener.networks).sort(), [ + 'authority_private', 'listener_egress', 'preview_db', 'stream_control', + ]); + assert.deepEqual( + authorityResolved.services.listener.networks.authority_private.aliases, + ['earlybirds-listener'], + ); + + if (process.argv.includes('--build')) { + execFileSync('docker', [...composeArgs, 'build', 'migration', 'listener', 'beacon-stream'], { + stdio: 'inherit', + }); + console.log('EarlyBirds preview images built successfully.'); + } + console.log('EarlyBirds preview compose configuration is valid.'); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/scripts/listener-account-production/activate-env.mjs b/scripts/listener-account-production/activate-env.mjs new file mode 100755 index 00000000..072d4400 --- /dev/null +++ b/scripts/listener-account-production/activate-env.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +const SHA40 = /^[0-9a-f]{40}$/; +const BUILD_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +const SCHEMA = /^\d{14}_[a-z0-9_]+$/; +const SECRET = /^[A-Za-z0-9_-]{32,256}$/; + +function assignments(contents, label) { + const values = new Map(); + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const at = line.indexOf('='); + if (at <= 0) throw new Error(`${label} contains an invalid assignment`); + const key = line.slice(0, at); + if (!/^[A-Z][A-Z0-9_]*$/.test(key)) throw new Error(`${label} contains an invalid key`); + if (values.has(key)) throw new Error(`${label} contains a duplicate key`); + values.set(key, line.slice(at + 1)); + } + return values; +} + +function exact(values, key, expected, label) { + if (values.get(key) !== expected) throw new Error(`${label} ${key} mismatch`); +} + +function replaceAssignment(contents, key, value) { + const lines = contents.replace(/\r\n/g, '\n').split('\n'); + let replacements = 0; + const updated = lines.map((line) => { + if (!line.startsWith(`${key}=`)) return line; + replacements += 1; + return `${key}=${value}`; + }); + if (replacements > 1) throw new Error(`Listener environment contains duplicate ${key}`); + if (replacements === 0) { + if (updated.at(-1) !== '') updated.push(''); + updated.splice(updated.length - 1, 0, `${key}=${value}`); + } + return updated.join('\n'); +} + +export function buildProductionActivation({ + listenerContents, + bundleContents, + expectedSha, + buildTime, + expectedSchema, +}) { + if (!SHA40.test(expectedSha)) throw new Error('exact lowercase sha40 required'); + if (!BUILD_TIME.test(buildTime)) throw new Error('exact UTC build time required'); + if (!SCHEMA.test(expectedSchema)) throw new Error('exact schema migration required'); + const listener = assignments(listenerContents, 'Listener environment'); + const bundle = assignments(bundleContents, 'Listener Account bundle'); + + const accountMode = listener.get('BEACON_LISTENER_ACCOUNT_ENABLED') ?? '0'; + if (accountMode !== '0' && accountMode !== '1') { + throw new Error('Listener environment BEACON_LISTENER_ACCOUNT_ENABLED must be 0 or 1'); + } + exact(listener, 'EARLY_BIRDS_AUTH_BASE_URL', 'https://listen.harmonicbeacon.com', 'Listener environment'); + exact( + listener, + 'EARLY_BIRDS_TRUSTED_ORIGINS', + 'https://listen.harmonicbeacon.com,https://earlybirds-staging.harmonicbeacon.com', + 'Listener environment', + ); + for (const key of [ + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING', + ]) { + if ((listener.get(key) ?? '') !== '') throw new Error(`Listener environment ${key} must be empty`); + } + + const allowedBundle = new Set([ + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', + ]); + for (const key of bundle.keys()) { + if (!allowedBundle.has(key)) throw new Error('Listener Account bundle contains unexpected keys'); + } + if (bundle.size !== allowedBundle.size) throw new Error('Listener Account bundle is incomplete'); + const clientSecret = bundle.get('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET'); + const stateSecret = bundle.get('BEACON_LISTENER_ACCOUNT_STATE_SECRET'); + if (!SECRET.test(clientSecret ?? '') || !SECRET.test(stateSecret ?? '')) { + throw new Error('Listener Account bundle contains an invalid secret'); + } + if (clientSecret === stateSecret) throw new Error('Listener Account secrets must differ'); + + if (accountMode === '0') { + for (const key of [ + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', + ]) { + if ((listener.get(key) ?? '') !== '') throw new Error(`Listener environment ${key} must be empty`); + } + } else { + exact( + listener, + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', + clientSecret, + 'enabled Listener environment', + ); + exact( + listener, + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', + stateSecret, + 'enabled Listener environment', + ); + for (const [key, expected] of [ + ['EARLY_BIRDS_GOOGLE_CLIENT_ID', ''], + ['EARLY_BIRDS_GOOGLE_CLIENT_SECRET', ''], + ['BEACON_LISTENER_APPLE_ENABLED', '0'], + ['BEACON_LISTENER_APPLE_CLIENT_ID', ''], + ['BEACON_LISTENER_APPLE_CLIENT_SECRET', ''], + ['EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL', ''], + ['EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN', ''], + ['EARLY_BIRDS_MAGIC_LINK_RATE_SECRET', ''], + ]) { + exact(listener, key, expected, 'enabled Listener environment'); + } + } + + let result = listenerContents; + for (const [key, value] of [ + ['EARLYBIRDS_PREVIEW_IMAGE_TAG', expectedSha], + ['EARLYBIRDS_PREVIEW_GIT_SHA', expectedSha], + ['EARLYBIRDS_PREVIEW_BUILD_TIME', buildTime], + ['EARLYBIRDS_PREVIEW_SCHEMA_VERSION', expectedSchema], + // Central Account is the sole identity provider after this cutover. Keep + // the old direct-provider and magic-link credentials out of the process; + // the root-only previous env retains them only for bounded rollback. + ['EARLY_BIRDS_GOOGLE_CLIENT_ID', ''], + ['EARLY_BIRDS_GOOGLE_CLIENT_SECRET', ''], + ['BEACON_LISTENER_APPLE_ENABLED', '0'], + ['BEACON_LISTENER_APPLE_CLIENT_ID', ''], + ['BEACON_LISTENER_APPLE_CLIENT_SECRET', ''], + ['EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL', ''], + ['EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN', ''], + ['EARLY_BIRDS_MAGIC_LINK_RATE_SECRET', ''], + ['BEACON_LISTENER_ACCOUNT_ENABLED', '1'], + ['BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', clientSecret], + ['BEACON_LISTENER_ACCOUNT_STATE_SECRET', stateSecret], + ]) { + result = replaceAssignment(result, key, value); + } + const effective = assignments(result, 'activated Listener environment'); + exact(effective, 'BEACON_LISTENER_ACCOUNT_ENABLED', '1', 'activated Listener environment'); + exact(effective, 'EARLYBIRDS_PREVIEW_IMAGE_TAG', expectedSha, 'activated Listener environment'); + exact(effective, 'EARLYBIRDS_PREVIEW_GIT_SHA', expectedSha, 'activated Listener environment'); + exact(effective, 'EARLYBIRDS_PREVIEW_SCHEMA_VERSION', expectedSchema, 'activated Listener environment'); + exact(effective, 'EARLY_BIRDS_GOOGLE_CLIENT_ID', '', 'activated Listener environment'); + exact(effective, 'EARLY_BIRDS_GOOGLE_CLIENT_SECRET', '', 'activated Listener environment'); + exact(effective, 'BEACON_LISTENER_APPLE_ENABLED', '0', 'activated Listener environment'); + exact(effective, 'BEACON_LISTENER_APPLE_CLIENT_ID', '', 'activated Listener environment'); + exact(effective, 'BEACON_LISTENER_APPLE_CLIENT_SECRET', '', 'activated Listener environment'); + exact(effective, 'EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL', '', 'activated Listener environment'); + exact(effective, 'EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN', '', 'activated Listener environment'); + exact(effective, 'EARLY_BIRDS_MAGIC_LINK_RATE_SECRET', '', 'activated Listener environment'); + return result.endsWith('\n') ? result : `${result}\n`; +} + +function requireRootPrivateFile(file, label) { + const metadata = fs.lstatSync(file); + if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`${label} must be a regular file`); + if (metadata.uid !== 0 || metadata.gid !== 0 || (metadata.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be root:root mode 0600`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) { + const [listenerFile, bundleFile, outputFile, expectedSha, buildTime, expectedSchema] = process.argv.slice(2); + if (!listenerFile || !bundleFile || !outputFile || !expectedSha || !buildTime || !expectedSchema) { + throw new Error('usage: activate-env.mjs listener.env account.env output.env sha40 build-time schema'); + } + requireRootPrivateFile(listenerFile, 'Listener environment'); + requireRootPrivateFile(bundleFile, 'Listener Account bundle'); + if (fs.existsSync(outputFile)) throw new Error('activation output already exists'); + const outputDirectory = fs.lstatSync(path.dirname(outputFile)); + if (!outputDirectory.isDirectory() || outputDirectory.isSymbolicLink()) { + throw new Error('activation output directory must be regular'); + } + if (outputDirectory.uid !== 0 || outputDirectory.gid !== 0 || (outputDirectory.mode & 0o777) !== 0o700) { + throw new Error('activation output directory must be root:root mode 0700'); + } + const candidate = buildProductionActivation({ + listenerContents: fs.readFileSync(listenerFile, 'utf8'), + bundleContents: fs.readFileSync(bundleFile, 'utf8'), + expectedSha, + buildTime, + expectedSchema, + }); + fs.writeFileSync(outputFile, candidate, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + process.stdout.write('Listener production Account activation environment prepared.\n'); +} diff --git a/scripts/listener-account-production/activate.sh b/scripts/listener-account-production/activate.sh new file mode 100755 index 00000000..80f1173f --- /dev/null +++ b/scripts/listener-account-production/activate.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env sh +set -eu + +test "$(id -u)" -eq 0 || { echo 'run as root' >&2; exit 2; } + +expected_sha=${1:?usage: activate.sh exact-sha40} +case "$expected_sha" in *[!0-9a-f]*|'') echo 'exact lowercase sha40 required' >&2; exit 2 ;; esac +test "${#expected_sha}" -eq 40 || { echo 'exact lowercase sha40 required' >&2; exit 2; } + +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +image="harmonic-beacon/earlybirds-preview-listener:$expected_sha" +listener_env=/etc/harmonic-beacon/earlybirds-preview.env +bundle=/etc/harmonic-beacon/listener-account-production.env +account_deploy=/etc/harmonic-beacon/beacon-account-deploy.env +state_root=/var/lib/harmonic-beacon/listener-account-production +build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ) +stamp=$(date -u +%Y%m%dT%H%M%SZ) +state="$state_root/activation-$expected_sha-$stamp" +cleanup_preflight() { test ! -d "$state" || rm -rf "$state"; } +trap cleanup_preflight EXIT +trap 'exit 130' HUP INT TERM +umask 077 + +fail() { echo "Listener production Account activation: $*" >&2; exit 2; } +private_file() { + test -f "$1" && test ! -L "$1" || fail "$2 must be a regular file" + test "$(stat -c '%U:%G:%a' "$1")" = root:root:600 || fail "$2 must be root:root mode 0600" +} +write_protected_environment() { + protected_source=$1 + protected_target=$2 + grep -E '^(EARLY_BIRDS_ENABLED|EARLY_BIRDS_FREE_FOR_ALL|BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED|BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED|BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED|BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED|BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED|LISTENER_WITHDRAWAL_ENABLED|EARLY_BIRDS_STREAM_ORIGIN|EARLY_BIRDS_STREAM_ARTIFACT_ID|EARLY_BIRDS_AUTHORITY_BASE_URL)=' \ + "$protected_source" | sort > "$protected_target" + test "$(wc -l < "$protected_target")" -eq 11 || fail 'protected Listener environment inventory is incomplete' + chmod 0600 "$protected_target" +} +write_protected_containers() { + protected_target=$1 + : > "$protected_target" + for protected_container in \ + earlybirds-preview-postgres-1 \ + earlybirds-preview-beacon-stream-1 \ + earlybirds-preview-withdrawal-operator-1; do + docker inspect "$protected_container" \ + --format '{{.Name}}|{{.Id}}|{{.Config.Image}}|{{.State.Running}}|{{.RestartCount}}' \ + >> "$protected_target" + done + chmod 0600 "$protected_target" +} +wait_healthy() { + wait_attempt=0 + while test "$wait_attempt" -lt 60; do + wait_state=$(docker inspect earlybirds-preview-listener-1 \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true) + test "$wait_state" != healthy || return 0 + test "$wait_state" != exited || return 1 + wait_attempt=$((wait_attempt + 1)) + sleep 2 + done + return 1 +} + +exec 9>/run/lock/listener-account-production.lock +flock -n 9 || fail 'another Listener Account production activation is active' +private_file "$listener_env" 'Listener production environment' +private_file "$bundle" 'Listener Account production bundle' +private_file "$account_deploy" 'Account deployment coordinates' +expected_schema=$(sed -n 's/^BEACON_ACCOUNT_SCHEMA_VERSION=//p' "$account_deploy" | tail -n 1 | tr -d '\r') +printf '%s\n' "$expected_schema" | grep -Eq '^[0-9]{14}_[a-z0-9_]+$' || + fail 'Account schema coordinate is invalid' +test "$(git -C "$root" rev-parse HEAD)" = "$expected_sha" || fail 'release checkout SHA mismatch' +test -z "$(git -C "$root" status --porcelain)" || fail 'release checkout is dirty' +docker image inspect "$image" >/dev/null 2>&1 || fail 'exact candidate image is missing' +baked_sha=$(docker image inspect "$image" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) +test "$baked_sha" = "$expected_sha" || fail 'candidate image provenance mismatch' +baked_schema=$(docker image inspect "$image" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_DATABASE_SCHEMA_VERSION=//p' | tail -n 1) +test "$baked_schema" = "$expected_schema" || fail 'candidate image schema provenance mismatch' + +current_state=$(docker inspect earlybirds-preview-listener-1 \ + --format '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true) +test "$current_state" = 'true|healthy' || fail 'current production Listener is not healthy' +previous_image=$(docker inspect earlybirds-preview-listener-1 --format '{{.Config.Image}}') +case "$previous_image" in + harmonic-beacon/earlybirds-preview-listener:[0-9a-f][0-9a-f]*) ;; + *) fail 'previous Listener image reference is invalid' ;; +esac +docker image inspect "$previous_image" >/dev/null 2>&1 || fail 'previous Listener image is missing' +previous_sha=$(docker inspect earlybirds-preview-listener-1 \ + --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) +printf '%s\n' "$previous_sha" | grep -Eq '^[0-9a-f]{40}$' || fail 'previous Listener provenance is invalid' +test "$previous_image" = "harmonic-beacon/earlybirds-preview-listener:$previous_sha" || + fail 'previous Listener image tag and provenance differ' +previous_schema=$(sed -n 's/^EARLYBIRDS_PREVIEW_SCHEMA_VERSION=//p' "$listener_env" | tail -n 1 | tr -d '\r') +printf '%s\n' "$previous_schema" | grep -Eq '^[0-9]{14}_[a-z0-9_]+$' || + fail 'previous Listener schema provenance is invalid' +previous_account_mode=$(sed -n 's/^BEACON_LISTENER_ACCOUNT_ENABLED=//p' "$listener_env" | tail -n 1 | tr -d '\r') +case "$previous_account_mode" in + ''|0) previous_account_mode=0 ;; + 1) ;; + *) fail 'previous Listener Account mode is invalid' ;; +esac +test "$previous_image" != "$image" || fail 'candidate is already running' + +# This is intentionally before every persistent write and runtime mutation. +# It proves the public Account issuer and the exact dedicated RP credential. +"$root/scripts/listener-account-production/preflight.sh" "$expected_sha" + +if test -e "$state_root"; then + test -d "$state_root" && test ! -L "$state_root" || fail 'state root must be a regular directory' + test "$(stat -c '%U:%G:%a' "$state_root")" = root:root:700 || + fail 'state root must be root:root mode 0700' +else + install -d -o root -g root -m 0700 "$state_root" +fi +test ! -e "$state" || fail 'activation state already exists' +install -d -o root -g root -m 0700 "$state" +install -o root -g root -m 0600 "$listener_env" "$state/previous.env" +printf '%s\n' "$previous_image" > "$state/previous-image.txt" +printf '%s\n' "$previous_sha" > "$state/previous-sha.txt" +printf '%s\n' "$previous_schema" > "$state/previous-schema.txt" +printf '%s\n' "$previous_account_mode" > "$state/previous-account-mode.txt" +printf '%s\n' "$image" > "$state/candidate-image.txt" +chmod 0600 "$state/previous-image.txt" "$state/previous-sha.txt" \ + "$state/previous-schema.txt" "$state/previous-account-mode.txt" "$state/candidate-image.txt" +write_protected_environment "$listener_env" "$state/protected-env.before" +write_protected_containers "$state/protected-containers.before" + +docker run --rm --pull never --network none --read-only --user 0:0 --cap-drop ALL \ + --security-opt no-new-privileges \ + --mount "type=bind,src=$listener_env,dst=/run/listener.env,readonly" \ + --mount "type=bind,src=$bundle,dst=/run/account.env,readonly" \ + --mount "type=bind,src=$state,dst=/run/state" \ + --entrypoint node "$image" /app/scripts/listener-account-production/activate-env.mjs \ + /run/listener.env /run/account.env /run/state/candidate.env \ + "$expected_sha" "$build_time" "$expected_schema" +private_file "$state/candidate.env" 'candidate Listener environment' + +. "$root/scripts/early-birds-preview/lib.sh" +candidate_images=$(preview_compose_command "$state/candidate.env" config --images) +printf '%s\n' "$candidate_images" | grep -Fxq "$image" || fail 'Compose does not select the exact candidate image' + +cutover_started=0 +rollback_on_failure() { + status=$? + trap - EXIT HUP INT TERM + if test "$status" -ne 0 && test "$cutover_started" -eq 1; then + echo 'Listener production Account activation failed; restoring prior env and image.' >&2 + temporary="${listener_env}.rollback-$$" + install -o root -g root -m 0600 "$state/previous.env" "$temporary" || true + mv -T "$temporary" "$listener_env" || true + preview_compose_command "$listener_env" up -d --no-deps --force-recreate --no-build listener || true + wait_healthy || true + "$root/scripts/listener-account-production/health-smoke.sh" \ + "$previous_sha" "$previous_account_mode" "$previous_schema" || true + elif test "$status" -ne 0; then + rm -rf "$state" + fi + exit "$status" +} +trap rollback_on_failure EXIT +trap 'exit 130' HUP INT TERM + +temporary="${listener_env}.activate-$$" +cutover_started=1 +install -o root -g root -m 0600 "$state/candidate.env" "$temporary" +mv -T "$temporary" "$listener_env" +rm -f "$state/candidate.env" +preview_compose_command "$listener_env" up -d --no-deps --force-recreate --no-build listener +wait_healthy || fail 'Listener did not become healthy' + +running_image=$(docker inspect earlybirds-preview-listener-1 --format '{{.Config.Image}}') +test "$running_image" = "$image" || fail 'running Listener image mismatch' +running_sha=$(docker inspect earlybirds-preview-listener-1 \ + --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) +test "$running_sha" = "$expected_sha" || fail 'running Listener SHA mismatch' +"$root/scripts/listener-account-production/health-smoke.sh" \ + "$expected_sha" 1 "$expected_schema" +docker inspect earlybirds-preview-listener-1 \ + --format '{{range .Config.Env}}{{println .}}{{end}}' > "$state/runtime-after.env" +chmod 0600 "$state/runtime-after.env" +write_protected_environment "$state/runtime-after.env" "$state/protected-env.after" +rm -f "$state/runtime-after.env" +cmp -s "$state/protected-env.before" "$state/protected-env.after" || + fail 'payments, stream or authority environment changed during Account activation' +write_protected_containers "$state/protected-containers.after" +cmp -s "$state/protected-containers.before" "$state/protected-containers.after" || + fail 'protected Listener dependencies changed during Account activation' + +{ + printf 'activated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'candidate_sha=%s\n' "$expected_sha" + printf 'candidate_schema=%s\n' "$expected_schema" + printf 'previous_sha=%s\n' "$previous_sha" + printf 'previous_account_mode=%s\n' "$previous_account_mode" + printf 'listener_health=pass\n' + printf 'account_preflight=pass\n' + printf 'public_login=pass\n' + printf 'protected_runtime=unchanged\n' +} > "$state/result.txt" +chmod 0600 "$state/result.txt" +(cd "$state" && sha256sum previous.env previous-image.txt previous-sha.txt previous-schema.txt \ + previous-account-mode.txt \ + candidate-image.txt protected-env.before protected-env.after protected-containers.before \ + protected-containers.after result.txt > SHA256SUMS) +chmod 0600 "$state/SHA256SUMS" +last_activation_temporary="$state_root/last-activation.tmp-$$" +printf '%s\n' "$state" > "$last_activation_temporary" +chmod 0600 "$last_activation_temporary" +mv -T "$last_activation_temporary" "$state_root/last-activation" + +cutover_started=0 +trap - EXIT HUP INT TERM +echo "Listener production Account RP is healthy at exact SHA $expected_sha." +echo "Rollback state: $state" diff --git a/scripts/listener-account-production/health-smoke.sh b/scripts/listener-account-production/health-smoke.sh new file mode 100755 index 00000000..e855314f --- /dev/null +++ b/scripts/listener-account-production/health-smoke.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env sh +set -eu + +test "$(id -u)" -eq 0 || { echo 'run as root' >&2; exit 2; } +expected_sha=${1:?usage: health-smoke.sh expected-sha40 account-mode expected-schema} +account_mode=${2:?usage: health-smoke.sh expected-sha40 account-mode expected-schema} +expected_schema=${3:?usage: health-smoke.sh expected-sha40 account-mode expected-schema} +printf '%s\n' "$expected_sha" | grep -Eq '^[0-9a-f]{40}$' || { + echo 'exact lowercase sha40 required' >&2; exit 2; +} +case "$account_mode" in 0|1) ;; *) echo 'account mode must be 0 or 1' >&2; exit 2 ;; esac +printf '%s\n' "$expected_schema" | grep -Eq '^[0-9]{14}_[a-z0-9_]+$' || { + echo 'exact schema migration required' >&2; exit 2; +} + +container=earlybirds-preview-listener-1 +expected_image="harmonic-beacon/earlybirds-preview-listener:$expected_sha" +test "$(docker inspect "$container" --format '{{.Config.Image}}')" = "$expected_image" || { + echo 'Listener image mismatch' >&2; exit 2; +} +test "$(docker inspect "$container" --format '{{.State.Health.Status}}')" = healthy || { + echo 'Listener is not healthy' >&2; exit 2; +} +test "$(docker inspect "$container" --format '{{.RestartCount}}')" = 0 || { + echo 'Listener restarted during the cutover' >&2; exit 2; +} + +work=$(mktemp -d /run/listener-account-production-health.XXXXXX) +trap 'rm -rf "$work"' EXIT +trap 'exit 130' HUP INT TERM +docker inspect "$container" --format '{{range .Config.Env}}{{println .}}{{end}}' > "$work/runtime.env" +chmod 0600 "$work/runtime.env" +env_value() { sed -n "s/^$1=//p" "$work/runtime.env" | tail -n 1 | tr -d '\r'; } +test "$(env_value BEACON_GIT_SHA)" = "$expected_sha" || { echo 'Listener SHA mismatch' >&2; exit 2; } +test "$(env_value BEACON_DATABASE_SCHEMA_VERSION)" = "$expected_schema" || { + echo 'Listener schema provenance mismatch' >&2; exit 2; +} +runtime_account_mode=$(env_value BEACON_LISTENER_ACCOUNT_ENABLED) +if test "$account_mode" -eq 1; then + test "$runtime_account_mode" = 1 || { echo 'Listener Account mode mismatch' >&2; exit 2; } +else + case "$runtime_account_mode" in ''|0) ;; *) echo 'Listener Account mode mismatch' >&2; exit 2 ;; esac +fi + +if test "$account_mode" -eq 1; then + test "$(env_value BEACON_LISTENER_ACCOUNT_ENVIRONMENT)" = production || { + echo 'Listener Account environment mismatch' >&2; exit 2; + } + client_secret=$(env_value BEACON_LISTENER_ACCOUNT_CLIENT_SECRET) + state_secret=$(env_value BEACON_LISTENER_ACCOUNT_STATE_SECRET) + test "${#client_secret}" -ge 32 && test "${#state_secret}" -ge 32 || { + echo 'Listener Account credentials are missing' >&2; exit 2; + } + test "$client_secret" != "$state_secret" || { echo 'Listener Account credentials are reused' >&2; exit 2; } + for key in \ + BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING \ + BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING \ + EARLY_BIRDS_GOOGLE_CLIENT_ID \ + EARLY_BIRDS_GOOGLE_CLIENT_SECRET \ + BEACON_LISTENER_APPLE_CLIENT_ID \ + BEACON_LISTENER_APPLE_CLIENT_SECRET \ + EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL \ + EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN \ + EARLY_BIRDS_MAGIC_LINK_RATE_SECRET; do + test -z "$(env_value "$key")" || { + echo 'legacy or cross-environment identity material is active' >&2; exit 2; + } + done + test "$(env_value BEACON_LISTENER_APPLE_ENABLED)" = 0 || { + echo 'direct Listener Apple identity is active' >&2; exit 2; + } +else + test -z "$(env_value BEACON_LISTENER_ACCOUNT_CLIENT_SECRET)" || { + echo 'Account-off Listener contains an Account credential' >&2; exit 2; + } + test -z "$(env_value BEACON_LISTENER_ACCOUNT_STATE_SECRET)" || { + echo 'Account-off Listener contains an Account state secret' >&2; exit 2; + } +fi + +curl --fail --silent --show-error --connect-timeout 3 --max-time 8 \ + http://127.0.0.1:13000/api/health > "$work/local-health.json" +curl --fail --silent --show-error --connect-timeout 3 --max-time 8 \ + http://127.0.0.1:13000/api/health/ready > "$work/local-ready.json" +curl --fail --silent --show-error --connect-timeout 3 --max-time 8 \ + http://127.0.0.1:18080/healthz >/dev/null +curl --fail --silent --show-error --proto '=https' --connect-timeout 3 --max-time 8 \ + https://listen.harmonicbeacon.com/api/health > "$work/public-health.json" +jq --exit-status --arg sha "$expected_sha" --arg schema "$expected_schema" \ + '.status == "ok" and .gitSha == $sha and .databaseSchemaVersion == $schema' \ + "$work/local-health.json" >/dev/null +jq --exit-status --arg sha "$expected_sha" --arg schema "$expected_schema" \ + '.status == "ok" and .gitSha == $sha and .databaseSchemaVersion == $schema' \ + "$work/public-health.json" >/dev/null +jq --exit-status '.status == "ok" and .checks.database == "ok" and .checks.listenerRuntime == "ok"' \ + "$work/local-ready.json" >/dev/null + +login_code=$(curl --silent --show-error --proto '=https' --connect-timeout 3 --max-time 8 \ + --dump-header "$work/login.headers" --output /dev/null --write-out '%{http_code}' \ + https://listen.harmonicbeacon.com/api/account/login) +if test "$account_mode" -eq 1; then + case "$login_code" in 302|303) ;; *) echo 'public Account login did not redirect' >&2; exit 2 ;; esac + grep -Eiq '^location: https://account\.harmonicbeacon\.com/api/account/auth/oauth2/authorize\?' \ + "$work/login.headers" || { echo 'public Account login targets the wrong issuer' >&2; exit 2; } + grep -Eiq '^set-cookie: __Host-hb_listener_account_attempt=' "$work/login.headers" || { + echo 'public Account login did not set its host-only attempt cookie' >&2; exit 2; + } + jq --exit-status '.checks.listenerAccount == "ok"' "$work/local-ready.json" >/dev/null +else + test "$login_code" = 404 || { echo 'Account-off Listener exposes Account login' >&2; exit 2; } +fi + +suffix_code=$(curl --silent --show-error --proto '=https' --connect-timeout 3 --max-time 8 \ + --output /dev/null --write-out '%{http_code}' \ + https://listen.harmonicbeacon.com/api/account/login/extra) +test "$suffix_code" = 404 || { echo 'public Listener Account suffix is exposed' >&2; exit 2; } +echo "Listener production health and Account mode $account_mode are exact at SHA $expected_sha." diff --git a/scripts/listener-account-production/preflight.mjs b/scripts/listener-account-production/preflight.mjs new file mode 100755 index 00000000..ebf77faf --- /dev/null +++ b/scripts/listener-account-production/preflight.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +import { lstat, readFile } from 'node:fs/promises'; +import process from 'node:process'; + +import { parseEnvironment } from './sync-secret.mjs'; + +const ISSUER = 'https://account.harmonicbeacon.com'; +const CLIENT_ID = 'hb-listener'; +const BUNDLE = '/run/listener-account-production.env'; + +function fail(message) { + throw new Error(message); +} + +async function jsonResponse(url, init = {}) { + const response = await fetch(url, { + ...init, + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(8_000), + }); + const body = await response.json().catch(() => fail(`${url} did not return JSON`)); + return { response, body }; +} + +export async function productionAccountPreflight({ bundlePath = BUNDLE } = {}) { + const metadata = await lstat(bundlePath); + if (!metadata.isFile() || metadata.uid !== 0 || metadata.gid !== 0 || (metadata.mode & 0o777) !== 0o600) { + fail('Listener production Account bundle must be a root:root mode-0600 regular file'); + } + const bundle = parseEnvironment(await readFile(bundlePath, 'utf8')); + const keys = [...bundle.keys()].sort(); + const expectedKeys = [ + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', + ].sort(); + if (keys.join('\n') !== expectedKeys.join('\n')) fail('Listener production Account bundle key inventory mismatch'); + const clientSecret = bundle.get('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET') ?? ''; + if (!/^[A-Za-z0-9_-]{32,128}$/.test(clientSecret)) fail('Listener production Account client secret is invalid'); + + const { response: readyResponse, body: ready } = await jsonResponse(`${ISSUER}/api/account/health/ready`); + if (!readyResponse.ok || ready?.status !== 'ok' || ready?.checks?.database !== 'ok' || + ready?.checks?.mail !== 'ok' || ready?.checks?.issuer !== 'ok' || ready?.checks?.jwks !== 'ok' || + ready?.checks?.clients !== 'ok' || ready?.checks?.providers !== 'ok') { + fail('Account production readiness is unavailable or incomplete'); + } + + const { response: discoveryResponse, body: discovery } = await jsonResponse( + `${ISSUER}/.well-known/openid-configuration`, + { headers: { Accept: 'application/json' } }, + ); + if (!discoveryResponse.ok || discovery.issuer !== ISSUER) fail('Account production discovery mismatch'); + const endpoints = { + authorization_endpoint: '/api/account/auth/oauth2/authorize', + token_endpoint: '/api/account/auth/oauth2/token', + jwks_uri: '/.well-known/jwks.json', + introspection_endpoint: '/api/account/auth/oauth2/introspect', + end_session_endpoint: '/api/account/auth/oauth2/end-session', + }; + for (const [name, path] of Object.entries(endpoints)) { + if (discovery[name] !== `${ISSUER}${path}`) fail(`Account production discovery ${name} mismatch`); + } + if (JSON.stringify(discovery.code_challenge_methods_supported) !== JSON.stringify(['S256']) || + JSON.stringify(discovery.token_endpoint_auth_methods_supported) !== JSON.stringify(['client_secret_basic'])) { + fail('Account production discovery does not expose the frozen RP contract'); + } + + const { response: jwksResponse, body: jwks } = await jsonResponse(`${ISSUER}/.well-known/jwks.json`); + const keyIds = Array.isArray(jwks.keys) ? jwks.keys.map((key) => key?.kid) : []; + if (!jwksResponse.ok || !Array.isArray(jwks.keys) || jwks.keys.length < 1 || + jwks.keys.some((key) => typeof key?.kid !== 'string' || !key.kid || key.kty !== 'OKP' || + key.alg !== 'EdDSA' || key.crv !== 'Ed25519' || !/^[A-Za-z0-9_-]{43}$/.test(key.x ?? '') || key.d || + (key.use !== undefined && key.use !== 'sig') || + (key.key_ops !== undefined && (!Array.isArray(key.key_ops) || !key.key_ops.includes('verify')))) || + new Set(keyIds).size !== keyIds.length) { + fail('Account production JWKS is unavailable or invalid'); + } + + const basic = Buffer.from(`${CLIENT_ID}:${clientSecret}`, 'utf8').toString('base64'); + const { response: statusResponse, body: status } = await jsonResponse(`${ISSUER}/api/account/session-status`, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ sid: 'listener-production-preflight', sub: 'listener-production-preflight' }), + }); + if (!statusResponse.ok || status.active !== false || + !statusResponse.headers.get('cache-control')?.toLowerCase().includes('no-store')) { + fail('Account production Listener client authentication failed'); + } +} + +if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) { + if (process.getuid?.() !== 0 || process.argv.length !== 2) { + throw new Error('run as root without arguments'); + } + productionAccountPreflight() + .then(() => process.stdout.write('Listener production Account preflight passed without exposing secrets.\n')) + .catch((error) => { + process.stderr.write(`Listener production Account preflight failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/listener-account-production/preflight.sh b/scripts/listener-account-production/preflight.sh new file mode 100755 index 00000000..27406968 --- /dev/null +++ b/scripts/listener-account-production/preflight.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env sh +set -eu + +test "$(id -u)" -eq 0 || { echo 'run as root' >&2; exit 2; } + +expected_sha=${1:?usage: preflight.sh exact-sha40} +case "$expected_sha" in *[!0-9a-f]*|'') echo 'exact lowercase sha40 required' >&2; exit 2 ;; esac +test "${#expected_sha}" -eq 40 || { echo 'exact lowercase sha40 required' >&2; exit 2; } +image="harmonic-beacon/earlybirds-preview-listener:$expected_sha" +bundle=/etc/harmonic-beacon/listener-account-production.env + +test -f "$bundle" && test ! -L "$bundle" || { echo 'Listener production Account bundle is absent' >&2; exit 2; } +test "$(stat -c '%U:%G:%a' "$bundle")" = root:root:600 || { + echo 'Listener production Account bundle must be root:root mode 0600' >&2; exit 2; +} +baked_sha=$(docker image inspect "$image" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) +test "$baked_sha" = "$expected_sha" || { echo 'candidate image provenance mismatch' >&2; exit 2; } +network_internal=$(docker network inspect earlybirds_preview_listener_egress --format '{{.Internal}}' 2>/dev/null || true) +test "$network_internal" = false || { echo 'Listener egress network is unavailable' >&2; exit 2; } + +docker run --rm --pull never --network earlybirds_preview_listener_egress --read-only --user 0:0 \ + --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,src=$bundle,dst=/run/listener-account-production.env,readonly" \ + --entrypoint node "$image" /app/scripts/listener-account-production/preflight.mjs diff --git a/scripts/listener-account-production/prepare.sh b/scripts/listener-account-production/prepare.sh new file mode 100755 index 00000000..17a68491 --- /dev/null +++ b/scripts/listener-account-production/prepare.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env sh +set -eu + +test "$(id -u)" -eq 0 || { echo 'run as root' >&2; exit 2; } + +expected_sha=${1:?usage: prepare.sh exact-sha40} +case "$expected_sha" in *[!0-9a-f]*|'') echo 'exact lowercase sha40 required' >&2; exit 2 ;; esac +test "${#expected_sha}" -eq 40 || { echo 'exact lowercase sha40 required' >&2; exit 2; } + +image="harmonic-beacon/earlybirds-preview-listener:$expected_sha" +account_env=/etc/harmonic-beacon/account.production.env +listener_env=/etc/harmonic-beacon/earlybirds-preview.env +target=/etc/harmonic-beacon/listener-account-production.env +target_directory=/etc/harmonic-beacon +work=$(mktemp -d /run/listener-account-production.XXXXXX) +cleanup() { rm -rf "$work"; } +trap cleanup EXIT HUP INT TERM +umask 077 + +test -d "$target_directory" && test ! -L "$target_directory" || { + echo 'Harmonic Beacon secret directory is not a regular directory' >&2; exit 2; +} +test "$(stat -c '%U:%G:%a' "$target_directory")" = root:root:700 || { + echo 'Harmonic Beacon secret directory must be root:root mode 0700' >&2; exit 2; +} + +for source in "$account_env" "$listener_env"; do + test -f "$source" && test ! -L "$source" || { echo 'required production env is not a regular file' >&2; exit 2; } + test "$(stat -c '%U:%G:%a' "$source")" = root:root:600 || { + echo 'required production env must be root:root mode 0600' >&2; exit 2; + } +done +if test -e "$target"; then + test -f "$target" && test ! -L "$target" || { echo 'current Account bundle is not a regular file' >&2; exit 2; } + test "$(stat -c '%U:%G:%a' "$target")" = root:root:600 || { + echo 'current Account bundle must be root:root mode 0600' >&2; exit 2; + } + install -o root -g root -m 0600 "$target" "$work/current.env" +fi + +docker image inspect "$image" >/dev/null +baked_sha=$(docker image inspect "$image" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) +test "$baked_sha" = "$expected_sha" || { echo 'candidate image provenance mismatch' >&2; exit 2; } + +docker run --rm --pull never --network none --read-only --user 0:0 --cap-drop ALL \ + --security-opt no-new-privileges \ + --mount "type=bind,src=$account_env,dst=/run/account-production.env,readonly" \ + --mount "type=bind,src=$listener_env,dst=/run/listener-production.env,readonly" \ + --mount "type=bind,src=$work,dst=/run/work" \ + --entrypoint node "$image" /app/scripts/listener-account-production/sync-secret.mjs + +test -f "$work/candidate.env" && test ! -L "$work/candidate.env" || { + echo 'candidate Account bundle was not produced' >&2; exit 2; +} +test "$(stat -c '%a' "$work/candidate.env")" = 600 || { + echo 'candidate Account bundle mode mismatch' >&2; exit 2; +} +temporary="${target}.tmp-$$" +trap 'rm -f "$temporary"; cleanup' EXIT HUP INT TERM +install -o root -g root -m 0600 "$work/candidate.env" "$temporary" +mv -T "$temporary" "$target" +trap cleanup EXIT HUP INT TERM +test "$(stat -c '%U:%G:%a' "$target")" = root:root:600 || { + echo 'installed Account bundle ownership mismatch' >&2; exit 2; +} +echo 'Listener production Account bundle installed dormant; runtime and feature flag were not changed.' diff --git a/scripts/listener-account-production/rollback.sh b/scripts/listener-account-production/rollback.sh new file mode 100755 index 00000000..fe088c0f --- /dev/null +++ b/scripts/listener-account-production/rollback.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env sh +set -eu + +test "$(id -u)" -eq 0 || { echo 'run as root' >&2; exit 2; } +state=${1:?usage: rollback.sh /var/lib/harmonic-beacon/listener-account-production/activation-*} +case "$state" in + /var/lib/harmonic-beacon/listener-account-production/activation-*) ;; + *) echo 'unexpected rollback state path' >&2; exit 2 ;; +esac +test -d "$state" && test ! -L "$state" || { echo 'rollback state must be a regular directory' >&2; exit 2; } +test "$(stat -c '%U:%G:%a' "$state")" = root:root:700 || { + echo 'rollback state must be root:root mode 0700' >&2; exit 2; +} +for file in previous.env previous-image.txt previous-sha.txt previous-schema.txt previous-account-mode.txt \ + candidate-image.txt protected-env.before protected-env.after \ + protected-containers.before protected-containers.after result.txt SHA256SUMS; do + test -f "$state/$file" && test ! -L "$state/$file" || { echo 'rollback state is incomplete' >&2; exit 2; } + test "$(stat -c '%U:%G:%a' "$state/$file")" = root:root:600 || { + echo 'rollback state file must be root:root mode 0600' >&2; exit 2; + } +done +(cd "$state" && sha256sum -c SHA256SUMS >/dev/null) + +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +listener_env=/etc/harmonic-beacon/earlybirds-preview.env +previous_image=$(sed -n '1p' "$state/previous-image.txt") +previous_sha=$(sed -n '1p' "$state/previous-sha.txt") +previous_schema=$(sed -n '1p' "$state/previous-schema.txt") +previous_account_mode=$(sed -n '1p' "$state/previous-account-mode.txt") +candidate_image=$(sed -n '1p' "$state/candidate-image.txt") +test "$previous_image" = "harmonic-beacon/earlybirds-preview-listener:$previous_sha" || { + echo 'previous image provenance is inconsistent' >&2; exit 2; +} +case "$candidate_image" in + harmonic-beacon/earlybirds-preview-listener:[0-9a-f][0-9a-f]*) ;; + *) echo 'candidate image reference is invalid' >&2; exit 2 ;; +esac +candidate_sha=${candidate_image#harmonic-beacon/earlybirds-preview-listener:} +printf '%s\n' "$candidate_sha" | grep -Eq '^[0-9a-f]{40}$' || { + echo 'candidate image SHA is invalid' >&2; exit 2; +} +printf '%s\n' "$previous_schema" | grep -Eq '^[0-9]{14}_[a-z0-9_]+$' || { + echo 'previous schema provenance is invalid' >&2; exit 2; +} +case "$previous_account_mode" in + 0|1) ;; + *) echo 'previous Listener Account mode is invalid' >&2; exit 2 ;; +esac +test "$(docker inspect earlybirds-preview-listener-1 --format '{{.Config.Image}}')" = "$candidate_image" || { + echo 'running Listener does not match this rollback candidate' >&2; exit 2; +} +docker image inspect "$previous_image" >/dev/null 2>&1 || { echo 'previous image is missing' >&2; exit 2; } + +exec 9>/run/lock/listener-account-production.lock +flock -n 9 || { echo 'another Listener Account production operation is active' >&2; exit 2; } +temporary="${listener_env}.rollback-$$" +trap 'rm -f "$temporary"' EXIT +# Once the rollback env replacement begins, finish restoring the exact prior +# app instead of accepting a half-applied operator interrupt. +trap '' HUP INT TERM +install -o root -g root -m 0600 "$state/previous.env" "$temporary" +mv -T "$temporary" "$listener_env" + +. "$root/scripts/early-birds-preview/lib.sh" +test "$(preview_env_value EARLYBIRDS_PREVIEW_IMAGE_TAG "$listener_env")" = "$previous_sha" || { + echo 'restored env image tag mismatch' >&2; exit 2; +} +test "$(preview_env_value EARLYBIRDS_PREVIEW_GIT_SHA "$listener_env")" = "$previous_sha" || { + echo 'restored env SHA mismatch' >&2; exit 2; +} +test "$(preview_env_value EARLYBIRDS_PREVIEW_SCHEMA_VERSION "$listener_env")" = "$previous_schema" || { + echo 'restored env schema mismatch' >&2; exit 2; +} +preview_compose_command "$listener_env" up -d --no-deps --force-recreate --no-build listener +attempt=0 +while test "$attempt" -lt 60; do + health=$(docker inspect earlybirds-preview-listener-1 \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true) + test "$health" != healthy || break + attempt=$((attempt + 1)) + sleep 2 +done +test "$health" = healthy || { echo 'restored Listener did not become healthy' >&2; exit 2; } +test "$(docker inspect earlybirds-preview-listener-1 --format '{{.Config.Image}}')" = "$previous_image" || { + echo 'restored Listener image mismatch' >&2; exit 2; +} +running_sha=$(docker inspect earlybirds-preview-listener-1 \ + --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) +test "$running_sha" = "$previous_sha" || { echo 'restored Listener SHA mismatch' >&2; exit 2; } +"$root/scripts/listener-account-production/health-smoke.sh" \ + "$previous_sha" "$previous_account_mode" "$previous_schema" +trap - EXIT HUP INT TERM +echo "Listener production restored to exact SHA $previous_sha; database was not downgraded." diff --git a/scripts/listener-account-production/sync-secret.mjs b/scripts/listener-account-production/sync-secret.mjs new file mode 100755 index 00000000..f490b7e0 --- /dev/null +++ b/scripts/listener-account-production/sync-secret.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { randomBytes } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import process from 'node:process'; + +const ACCOUNT_ENV = '/run/account-production.env'; +const LISTENER_ENV = '/run/listener-production.env'; +const CURRENT_BUNDLE = '/run/work/current.env'; +const CANDIDATE_BUNDLE = '/run/work/candidate.env'; + +function fail(message) { + throw new Error(message); +} + +export function parseEnvironment(contents) { + const values = new Map(); + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const at = line.indexOf('='); + if (at < 1) fail('invalid environment file'); + const key = line.slice(0, at); + const value = line.slice(at + 1); + if (!/^[A-Z][A-Z0-9_]*$/.test(key) || values.has(key)) { + fail('invalid or duplicate environment key'); + } + values.set(key, value); + } + return values; +} + +function exact(values, key, expected, label) { + if (values.get(key) !== expected) fail(`${label} ${key} mismatch`); +} + +function secret(values, key, label) { + const value = values.get(key) ?? ''; + if (!/^[A-Za-z0-9_-]{32,128}$/.test(value)) fail(`${label} ${key} is invalid`); + return value; +} + +export function buildProductionBundle({ accountContents, listenerContents, currentContents = '' }) { + const account = parseEnvironment(accountContents); + const listener = parseEnvironment(listenerContents); + const current = parseEnvironment(currentContents); + + exact(account, 'BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com', 'Account production'); + exact(listener, 'EARLY_BIRDS_AUTH_BASE_URL', 'https://listen.harmonicbeacon.com', 'Listener production'); + const enabled = listener.get('BEACON_LISTENER_ACCOUNT_ENABLED') ?? '0'; + if (enabled !== '0') fail('Listener Account must remain disabled while preparing the bundle'); + for (const key of [ + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING', + ]) { + if (listener.has(key) && listener.get(key) !== '') fail(`Listener runtime ${key} must be absent while disabled`); + } + + const clientSecret = secret(account, 'BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER', 'Account production'); + const currentKeys = [...current.keys()].sort(); + if (currentKeys.length > 0 && currentKeys.join('\n') !== [ + 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', + 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', + ].sort().join('\n')) { + fail('current Listener Account bundle contains unexpected keys'); + } + const stateSecret = current.has('BEACON_LISTENER_ACCOUNT_STATE_SECRET') + ? secret(current, 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', 'current Listener bundle') + : randomBytes(32).toString('base64url'); + if (stateSecret === clientSecret) fail('Listener client and state secrets must differ'); + + return [ + `BEACON_LISTENER_ACCOUNT_CLIENT_SECRET=${clientSecret}`, + `BEACON_LISTENER_ACCOUNT_STATE_SECRET=${stateSecret}`, + '', + ].join('\n'); +} + +async function main() { + if (process.getuid?.() !== 0) fail('run as root'); + if (process.argv.length !== 2) fail('this command accepts no paths or secret values'); + const currentContents = await readFile(CURRENT_BUNDLE, 'utf8').catch((error) => { + if (error?.code === 'ENOENT') return ''; + throw error; + }); + const bundle = buildProductionBundle({ + accountContents: await readFile(ACCOUNT_ENV, 'utf8'), + listenerContents: await readFile(LISTENER_ENV, 'utf8'), + currentContents, + }); + await writeFile(CANDIDATE_BUNDLE, bundle, { flag: 'wx', mode: 0o600 }); + process.stdout.write('Listener production Account bundle prepared; feature remains OFF.\n'); +} + +if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) { + main().catch((error) => { + process.stderr.write(`Listener production Account bundle failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/listener-identity-staging/configure-intros.sh b/scripts/listener-identity-staging/configure-intros.sh new file mode 100755 index 00000000..5b384484 --- /dev/null +++ b/scripts/listener-identity-staging/configure-intros.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +deploy_file=${1:?usage: configure-intros.sh /etc/harmonic-beacon/listener-identity-staging.deploy.env} +listener_staging_load "$deploy_file" +exec 9>/run/lock/listener-identity-staging.lock +flock -n 9 || listener_staging_fail 'another Listener staging operation is active' +listener_staging_assert_dependencies + +manifest_entry() { + language=$1 + awk -v marker="-$language-" ' + index($2, marker) { count += 1; value = $2 } + END { if (count != 1) exit 1; print value } + ' "$listener_staging_intro_manifest" || + listener_staging_fail "intro manifest has no unique $language artifact" +} + +desired_es="/media/artifacts/$(manifest_entry es)" +desired_en="/media/artifacts/$(manifest_entry en)" +temporary=$(mktemp "${LISTENER_IDENTITY_STAGING_APP_ENV_FILE}.tmp.XXXXXX") +if ! awk -v desired_es="$desired_es" -v desired_en="$desired_en" ' + /^EARLY_BIRDS_DROPIN_ES_PATH=/ { es += 1; print "EARLY_BIRDS_DROPIN_ES_PATH=" desired_es; next } + /^EARLY_BIRDS_DROPIN_EN_PATH=/ { en += 1; print "EARLY_BIRDS_DROPIN_EN_PATH=" desired_en; next } + { print } + END { if (es != 1 || en != 1) exit 1 } +' "$LISTENER_IDENTITY_STAGING_APP_ENV_FILE" > "$temporary"; then + rm -f "$temporary" + listener_staging_fail 'could not install the reviewed intro paths atomically' +fi +chown root:root "$temporary" +chmod 0600 "$temporary" +mv "$temporary" "$LISTENER_IDENTITY_STAGING_APP_ENV_FILE" +echo 'Listener identity staging intro paths now select the two reviewed mounted artifacts; restart only through the reviewed lifecycle.' diff --git a/scripts/listener-identity-staging/edge-smoke.sh b/scripts/listener-identity-staging/edge-smoke.sh new file mode 100755 index 00000000..dd0a5cef --- /dev/null +++ b/scripts/listener-identity-staging/edge-smoke.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +deploy_file=${1:?usage: edge-smoke.sh /etc/harmonic-beacon/listener-identity-staging.deploy.env} +listener_staging_load "$deploy_file" +nginx -t +test "$(sha256sum "$listener_staging_nginx_target" | awk '{print $1}')" = \ + "$(sha256sum "$listener_staging_nginx_source" | awk '{print $1}')" || + listener_staging_fail 'active staging vhost is not the reviewed template' + +origin=https://earlybirds-staging.harmonicbeacon.com +curl_edge() { + curl --silent --show-error --resolve earlybirds-staging.harmonicbeacon.com:443:127.0.0.1 "$@" +} + +health=$(curl_edge --fail --max-time 5 "$origin/api/health") +account_enabled=$(listener_staging_account_enabled) +printf '%s\n' "$health" | jq --exit-status \ + --arg sha "$LISTENER_IDENTITY_STAGING_GIT_SHA" \ + '.status == "ok" and .gitSha == $sha' >/dev/null || + listener_staging_fail 'public edge health provenance mismatch' + +sentinel="listener-account-edge-${LISTENER_IDENTITY_STAGING_GIT_SHA}" +headers=$(mktemp) +body=$(mktemp) +trap 'rm -f "$headers" "$body"' EXIT HUP INT TERM + +nav_code=$(curl_edge --max-time 5 --dump-header "$headers" --output "$body" \ + --write-out '%{http_code}' "$origin/assets/hb-global-nav.js") +test "$nav_code" = 200 || listener_staging_fail 'canonical navigation asset is not available at the exact edge path' +expected_nav_sha=$(docker exec listener-identity-staging-app \ + sha256sum /app/public/assets/hb-global-nav.js | awk '{print $1}') +test "$(sha256sum "$body" | awk '{print $1}')" = "$expected_nav_sha" || + listener_staging_fail 'public navigation asset differs from the verified app image' +grep -Eiq '^cache-control: public, max-age=300' "$headers" || + listener_staging_fail 'navigation asset lost bounded public caching' +grep -Eiq '^x-content-type-options: nosniff' "$headers" || + listener_staging_fail 'navigation asset lost nosniff' +grep -Eiq '^referrer-policy: no-referrer' "$headers" || + listener_staging_fail 'navigation asset lost no-referrer' +if grep -Fq '/dev/null || + listener_staging_fail 'health provenance or schema mismatch' +if test "$account_enabled" = 1; then + printf '%s\n' "$ready" | jq --exit-status \ + '.status == "ok" and .checks.database == "ok" and + .checks.listenerRuntime == "ok" and .checks.listenerAccount == "ok"' >/dev/null || + listener_staging_fail 'readiness or Account-on boundary mismatch' + + account_origin=https://account-staging.harmonicbeacon.com + account_ready=$(curl --fail --silent --show-error --max-time 5 --proto '=https' \ + "$account_origin/api/account/health/ready") + discovery=$(curl --fail --silent --show-error --max-time 5 --proto '=https' \ + "$account_origin/.well-known/openid-configuration") + jwks=$(curl --fail --silent --show-error --max-time 5 --proto '=https' \ + "$account_origin/.well-known/jwks.json") + printf '%s\n' "$account_ready" | jq --exit-status ' + .status == "ok" and + (.gitSha | type == "string" and test("^[0-9a-f]{40}$")) and + (.schemaVersion | type == "string" and test("^[0-9]{14}_[a-z0-9_]+$")) and + .checks.database == "ok" and .checks.mail == "ok" and + .checks.issuer == "ok" and .checks.jwks == "ok" and + .checks.clients == "ok" and .checks.providers == "ok" + ' >/dev/null || listener_staging_fail 'Account staging authority is not ready' + printf '%s\n' "$discovery" | jq --exit-status --arg issuer "$account_origin" ' + .issuer == $issuer and + .jwks_uri == ($issuer + "/.well-known/jwks.json") and + .authorization_endpoint == ($issuer + "/api/account/auth/oauth2/authorize") and + .token_endpoint == ($issuer + "/api/account/auth/oauth2/token") and + .response_types_supported == ["code"] and + .code_challenge_methods_supported == ["S256"] and + .token_endpoint_auth_methods_supported == ["client_secret_basic"] + ' >/dev/null || listener_staging_fail 'Account staging OIDC discovery drifted from its exact issuer' + printf '%s\n' "$jwks" | jq --exit-status ' + (.keys | type == "array" and length > 0) and + all(.keys[]; (.kid | type == "string" and length > 0) and + (.kty | type == "string" and length > 0) and + (.alg | type == "string" and length > 0)) + ' >/dev/null || listener_staging_fail 'Account staging JWKS has no usable verification key' +else + printf '%s\n' "$ready" | jq --exit-status \ + '.status == "ok" and .checks.database == "ok" and + .checks.listenerRuntime == "ok" and (.checks | has("listenerAccount") | not)' >/dev/null || + listener_staging_fail 'readiness or Account-off boundary mismatch' +fi + +test "$(docker inspect listener-identity-staging-postgres --format '{{range $name, $network := .NetworkSettings.Networks}}{{$name}} {{end}}')" = \ + 'listener_identity_staging_database ' || listener_staging_fail 'PostgreSQL escaped its dedicated internal network' +echo "Listener identity staging smoke passed: provenance, migration, database, readiness and Account mode $account_enabled." diff --git a/scripts/listener-identity-staging/lib.sh b/scripts/listener-identity-staging/lib.sh new file mode 100755 index 00000000..8ba32d41 --- /dev/null +++ b/scripts/listener-identity-staging/lib.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env sh +set -eu + +listener_staging_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +listener_staging_compose="$listener_staging_root/ops/listener-identity-staging/compose.yml" +listener_staging_nginx_source="$listener_staging_root/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template" +listener_staging_nginx_target=/etc/nginx/sites-available/earlybirds-staging.harmonicbeacon.com +listener_staging_nginx_enabled=/etc/nginx/sites-enabled/earlybirds-staging.harmonicbeacon.com +listener_staging_intro_manifest="$listener_staging_root/ops/listener-identity-staging/intro-artifacts.sha256" + +listener_staging_fail() { + echo "listener-identity-staging: $*" >&2 + exit 2 +} + +listener_staging_require_root() { + test "$(id -u)" = 0 || listener_staging_fail 'run through the reviewed root wrapper (sudo), not with exported secrets' +} + +listener_staging_require_private_file() { + private_file=$1 + test -f "$private_file" || listener_staging_fail "missing protected file: $private_file" + test "$(stat -c '%U:%G:%a' "$private_file")" = root:root:600 || + listener_staging_fail "$private_file must be root:root 0600" +} + +listener_staging_load() { + LISTENER_IDENTITY_STAGING_DEPLOY_FILE=$1 + export LISTENER_IDENTITY_STAGING_DEPLOY_FILE + listener_staging_require_root + listener_staging_require_private_file "$LISTENER_IDENTITY_STAGING_DEPLOY_FILE" + set -a + # shellcheck disable=SC1090 + . "$LISTENER_IDENTITY_STAGING_DEPLOY_FILE" + set +a + : "${LISTENER_IDENTITY_STAGING_APP_ENV_FILE:?missing app env path}" + : "${LISTENER_IDENTITY_STAGING_DATABASE_ENV_FILE:?missing database env path}" + listener_staging_require_private_file "$LISTENER_IDENTITY_STAGING_APP_ENV_FILE" + listener_staging_require_private_file "$LISTENER_IDENTITY_STAGING_DATABASE_ENV_FILE" + : "${LISTENER_IDENTITY_STAGING_IMAGE_TAG:?missing immutable image tag}" + : "${LISTENER_IDENTITY_STAGING_GIT_SHA:?missing reviewed git SHA}" +} + +listener_staging_compose() { + docker compose --project-name listener-identity-staging \ + --env-file "$LISTENER_IDENTITY_STAGING_DEPLOY_FILE" \ + -f "$listener_staging_compose" "$@" +} + +listener_staging_account_enabled() { + awk -F= ' + $1 == "BEACON_LISTENER_ACCOUNT_ENABLED" { count += 1; value = $2 } + END { + if (count != 1 || (value != "0" && value != "1")) exit 1 + print value + } + ' "$LISTENER_IDENTITY_STAGING_APP_ENV_FILE" || + listener_staging_fail 'Account enablement must be one exact 0/1 assignment in the protected app env' +} + +listener_staging_restore_account_enabled() { + previous_file="$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-account-enabled" + test -f "$previous_file" || return 0 + previous=$(sed -n '1p' "$previous_file") + test "$previous" = 0 || test "$previous" = 1 || + listener_staging_fail 'recorded rollback Account mode is invalid' + test -z "$(sed -n '2p' "$previous_file")" || + listener_staging_fail 'recorded rollback Account mode has unexpected content' + + current=$(listener_staging_account_enabled) + test "$current" != "$previous" || return 0 + temporary=$(mktemp "${LISTENER_IDENTITY_STAGING_APP_ENV_FILE}.tmp.XXXXXX") + if ! awk -v desired="$previous" ' + /^BEACON_LISTENER_ACCOUNT_ENABLED=/ { + count += 1 + print "BEACON_LISTENER_ACCOUNT_ENABLED=" desired + next + } + { print } + END { if (count != 1) exit 1 } + ' "$LISTENER_IDENTITY_STAGING_APP_ENV_FILE" > "$temporary"; then + rm -f "$temporary" + listener_staging_fail 'could not restore the prior Account mode atomically' + fi + chown root:root "$temporary" + chmod 0600 "$temporary" + mv "$temporary" "$LISTENER_IDENTITY_STAGING_APP_ENV_FILE" + test "$(listener_staging_account_enabled)" = "$previous" || + listener_staging_fail 'restored app env does not match the prior Account mode' +} + +listener_staging_restore_drop_ins() { + previous_file="$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-drop-ins" + test -f "$previous_file" || return 0 + previous_es=$(awk -F= '$1 == "EARLY_BIRDS_DROPIN_ES_PATH" { count += 1; value = substr($0, index($0, "=") + 1) } END { if (count != 1) exit 1; print value }' "$previous_file") || + listener_staging_fail 'recorded Spanish intro rollback state is invalid' + previous_en=$(awk -F= '$1 == "EARLY_BIRDS_DROPIN_EN_PATH" { count += 1; value = substr($0, index($0, "=") + 1) } END { if (count != 1) exit 1; print value }' "$previous_file") || + listener_staging_fail 'recorded English intro rollback state is invalid' + temporary=$(mktemp "${LISTENER_IDENTITY_STAGING_APP_ENV_FILE}.tmp.XXXXXX") + if ! awk -v desired_es="$previous_es" -v desired_en="$previous_en" ' + /^EARLY_BIRDS_DROPIN_ES_PATH=/ { es += 1; print "EARLY_BIRDS_DROPIN_ES_PATH=" desired_es; next } + /^EARLY_BIRDS_DROPIN_EN_PATH=/ { en += 1; print "EARLY_BIRDS_DROPIN_EN_PATH=" desired_en; next } + { print } + END { if (es != 1 || en != 1) exit 1 } + ' "$LISTENER_IDENTITY_STAGING_APP_ENV_FILE" > "$temporary"; then + rm -f "$temporary" + listener_staging_fail 'could not restore prior intro configuration atomically' + fi + chown root:root "$temporary" + chmod 0600 "$temporary" + mv "$temporary" "$LISTENER_IDENTITY_STAGING_APP_ENV_FILE" +} + +listener_staging_assert_checkout() { + test "$(git -C "$listener_staging_root" rev-parse HEAD)" = "$LISTENER_IDENTITY_STAGING_GIT_SHA" || + listener_staging_fail 'release checkout does not match the reviewed SHA' + test -z "$(git -C "$listener_staging_root" status --porcelain)" || + listener_staging_fail 'release checkout is dirty' +} + +listener_staging_assert_dependencies() { + for network in earlybirds_stream_control_internal earlybirds_authority_private; do + metadata=$(docker network inspect "$network" --format '{{.Name}} {{.Driver}} {{.Internal}}' 2>/dev/null) || + listener_staging_fail "missing reviewed external network: $network" + test "$metadata" = "$network bridge true" || + listener_staging_fail "$network must remain an internal bridge" + done + for path in "$BEACON_STREAM_ARTIFACTS_HOST_PATH" "$BEACON_LISTENER_GEOIP_HOST_PATH"; do + test -e "$path" || listener_staging_fail "required read-only artifact is absent: $path" + done + test -f "$listener_staging_intro_manifest" || + listener_staging_fail 'reviewed intro checksum manifest is absent' + while read -r checksum relative; do + printf '%s\n' "$checksum" | grep -Eq '^[0-9a-f]{64}$' || + listener_staging_fail 'intro checksum manifest contains an invalid digest' + printf '%s\n' "$relative" | grep -Eq '^drop-ins/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.m4a$' || + listener_staging_fail 'intro checksum manifest contains an invalid relative path' + intro="$BEACON_STREAM_ARTIFACTS_HOST_PATH/$relative" + test -f "$intro" && test ! -L "$intro" || + listener_staging_fail "approved intro artifact is absent or not a regular file: $relative" + test "$(sha256sum "$intro" | awk '{print $1}')" = "$checksum" || + listener_staging_fail "approved intro artifact checksum mismatch: $relative" + done < "$listener_staging_intro_manifest" + + if docker network inspect listener_identity_staging_database >/dev/null 2>&1; then + metadata=$(docker network inspect listener_identity_staging_database \ + --format '{{.Driver}} {{.Internal}} {{index .Labels "com.docker.compose.project"}}') + test "$metadata" = 'bridge true listener-identity-staging' || + listener_staging_fail 'dedicated database network exists outside the reviewed project' + fi + if docker network inspect listener_identity_staging_egress >/dev/null 2>&1; then + metadata=$(docker network inspect listener_identity_staging_egress \ + --format '{{.Driver}} {{.Internal}} {{index .Labels "com.docker.compose.project"}}') + test "$metadata" = 'bridge false listener-identity-staging' || + listener_staging_fail 'dedicated egress network exists outside the reviewed project' + fi + if docker volume inspect listener-identity-staging-postgres >/dev/null 2>&1; then + project=$(docker volume inspect listener-identity-staging-postgres \ + --format '{{index .Labels "com.docker.compose.project"}}') + test "$project" = listener-identity-staging || + listener_staging_fail 'dedicated PostgreSQL volume exists outside the reviewed project' + fi +} + +listener_staging_port_owner() { + docker ps --filter publish=13001 --format '{{.Names}}' | sed -n '1p' +} + +listener_staging_assert_port() { + owner=$(listener_staging_port_owner) + test -z "$owner" || test "$owner" = listener-identity-staging-app || test "$owner" = listener-ui-dev || + listener_staging_fail "loopback 13001 is owned by an unexpected container: $owner" +} + +listener_staging_fingerprint_protected() { + for container in \ + earlybirds-preview-listener-1 earlybirds-preview-postgres-1 \ + beacon-app beacon-postgres beacon-livekit beacon-playlist-bot beacon-tapestry; do + docker inspect "$container" --format '{{.Name}} {{.Id}} {{.Config.Image}}' 2>/dev/null || + listener_staging_fail "protected container is absent: $container" + done +} + +listener_staging_wait_healthy() { + attempts=0 + while test "$attempts" -lt 60; do + state=$(docker inspect listener-identity-staging-app \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true) + test "$state" = healthy && return 0 + test "$state" != exited || listener_staging_fail 'application exited before readiness' + attempts=$((attempts + 1)) + sleep 2 + done + listener_staging_fail 'application did not become healthy' +} + +listener_staging_wait_postgres() { + attempts=0 + while test "$attempts" -lt 30; do + state=$(docker inspect listener-identity-staging-postgres \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true) + test "$state" = healthy && return 0 + test "$state" != exited || listener_staging_fail 'PostgreSQL exited before readiness' + attempts=$((attempts + 1)) + sleep 2 + done + listener_staging_fail 'PostgreSQL did not become healthy' +} + +listener_staging_capture_previous() { + install -d -o root -g root -m 0700 "$LISTENER_IDENTITY_STAGING_STATE_DIR" + # Rollback state describes only the runtime observed at the start of this + # attempt. Never inherit a failed candidate from an earlier attempt. + rm -f "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-image" \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-account-enabled" \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-drop-ins" \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/legacy-runtime" + if docker inspect listener-identity-staging-app >/dev/null 2>&1; then + running=$(docker inspect listener-identity-staging-app --format '{{.State.Running}}') + health=$(docker inspect listener-identity-staging-app \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}') + if test "$running" = true && test "$health" = healthy; then + previous=$(docker inspect listener-identity-staging-app --format '{{.Config.Image}}') + printf '%s\n' "$previous" | grep -Eq '^harmonic-beacon/listener-identity-staging:[0-9a-f]{40}$' || + listener_staging_fail 'healthy staging app is not an immutable Listener image' + printf '%s\n' "$previous" > "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-image" + previous_account=$(docker inspect listener-identity-staging-app \ + --format '{{range .Config.Env}}{{println .}}{{end}}' | awk -F= ' + $1 == "BEACON_LISTENER_ACCOUNT_ENABLED" { count += 1; value = $2 } + END { + if (count != 1 || (value != "0" && value != "1")) exit 1 + print value + } + ') || listener_staging_fail 'accepted staging app has an invalid Account mode' + printf '%s\n' "$previous_account" > \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-account-enabled" + docker inspect listener-identity-staging-app \ + --format '{{range .Config.Env}}{{println .}}{{end}}' | awk -F= ' + $1 == "EARLY_BIRDS_DROPIN_ES_PATH" { es += 1; es_value = substr($0, index($0, "=") + 1) } + $1 == "EARLY_BIRDS_DROPIN_EN_PATH" { en += 1; en_value = substr($0, index($0, "=") + 1) } + END { + if (es != 1 || en != 1) exit 1 + print "EARLY_BIRDS_DROPIN_ES_PATH=" es_value + print "EARLY_BIRDS_DROPIN_EN_PATH=" en_value + } + ' > "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-drop-ins" || + listener_staging_fail 'accepted staging app has invalid intro configuration' + chmod 0600 "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-image" \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-account-enabled" \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-drop-ins" + fi + fi + if docker inspect listener-ui-dev >/dev/null 2>&1; then + docker inspect listener-ui-dev --format '{{.Id}} {{.Config.Image}}' \ + > "$LISTENER_IDENTITY_STAGING_STATE_DIR/legacy-runtime" + chmod 0600 "$LISTENER_IDENTITY_STAGING_STATE_DIR/legacy-runtime" + fi + test -f "$listener_staging_nginx_target" || listener_staging_fail 'public staging vhost is absent' + test -L "$listener_staging_nginx_enabled" || listener_staging_fail 'public staging vhost is not enabled' + test "$(readlink -f "$listener_staging_nginx_enabled")" = "$listener_staging_nginx_target" || + listener_staging_fail 'public staging vhost symlink targets an unexpected file' + cp --preserve=mode,ownership,timestamps "$listener_staging_nginx_target" \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/nginx-previous.conf" + sha256sum "$listener_staging_nginx_target" | awk '{print $1}' \ + > "$LISTENER_IDENTITY_STAGING_STATE_DIR/nginx-previous.sha256" + chmod 0600 "$LISTENER_IDENTITY_STAGING_STATE_DIR/nginx-previous.conf" \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/nginx-previous.sha256" +} + +listener_staging_restore_edge() { + previous="$LISTENER_IDENTITY_STAGING_STATE_DIR/nginx-previous.conf" + test -f "$previous" || return 0 + expected=$(sed -n '1p' "$LISTENER_IDENTITY_STAGING_STATE_DIR/nginx-previous.sha256") + test "$(sha256sum "$previous" | awk '{print $1}')" = "$expected" || + listener_staging_fail 'backed-up staging vhost checksum mismatch' + install -o root -g root -m 0644 "$previous" "$listener_staging_nginx_target" + nginx -t + systemctl reload nginx +} + +listener_staging_install_edge() { + test -f "$listener_staging_nginx_source" || listener_staging_fail 'reviewed staging vhost template is absent' + install -o root -g root -m 0644 "$listener_staging_nginx_source" "$listener_staging_nginx_target" + if ! nginx -t; then + listener_staging_restore_edge || true + listener_staging_fail 'reviewed staging vhost failed nginx validation' + fi + if ! systemctl reload nginx; then + listener_staging_restore_edge || true + listener_staging_fail 'nginx reload failed and the prior staging vhost was restored' + fi + expected=$(sha256sum "$listener_staging_nginx_source" | awk '{print $1}') + test "$(sha256sum "$listener_staging_nginx_target" | awk '{print $1}')" = "$expected" || + listener_staging_fail 'installed staging vhost checksum differs from the reviewed template' + printf '%s\n' "$expected" > "$LISTENER_IDENTITY_STAGING_STATE_DIR/nginx-current.sha256" + chmod 0600 "$LISTENER_IDENTITY_STAGING_STATE_DIR/nginx-current.sha256" +} + +listener_staging_backup() { + install -d -o root -g root -m 0700 "$LISTENER_IDENTITY_STAGING_BACKUP_DIR" + backup="$LISTENER_IDENTITY_STAGING_BACKUP_DIR/pre-${LISTENER_IDENTITY_STAGING_GIT_SHA}-$(date -u +%Y%m%dT%H%M%SZ).dump" + listener_staging_compose exec -T postgres pg_dump \ + --username listener_identity_staging --dbname listener_identity_staging --format custom > "$backup" + chmod 0600 "$backup" + docker exec -i listener-identity-staging-postgres pg_restore --list < "$backup" >/dev/null + printf '%s\n' "$backup" > "$LISTENER_IDENTITY_STAGING_STATE_DIR/last-backup" + chmod 0600 "$LISTENER_IDENTITY_STAGING_STATE_DIR/last-backup" +} + +listener_staging_verify_image() { + image="harmonic-beacon/listener-identity-staging:$LISTENER_IDENTITY_STAGING_IMAGE_TAG" + actual=$(docker image inspect "$image" --format '{{range .Config.Env}}{{println .}}{{end}}' | + sed -n 's/^BEACON_GIT_SHA=//p' | tail -n 1) + test "$actual" = "$LISTENER_IDENTITY_STAGING_GIT_SHA" || + listener_staging_fail 'image provenance does not match its immutable tag' +} + +listener_staging_validate_image() { + image="harmonic-beacon/listener-identity-staging:$LISTENER_IDENTITY_STAGING_IMAGE_TAG" + docker run --rm \ + --user 0:0 \ + --network none \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --tmpfs /tmp:size=16m,mode=1777 \ + --mount "type=bind,src=$LISTENER_IDENTITY_STAGING_DEPLOY_FILE,dst=/run/listener-deploy.env,readonly" \ + --mount "type=bind,src=$LISTENER_IDENTITY_STAGING_APP_ENV_FILE,dst=/run/listener-app.env,readonly" \ + --mount "type=bind,src=$LISTENER_IDENTITY_STAGING_DATABASE_ENV_FILE,dst=/run/listener-database.env,readonly" \ + --entrypoint node \ + "$image" \ + /app/ops/listener-identity-staging/validate.mjs \ + /run/listener-deploy.env /run/listener-app.env /run/listener-database.env +} diff --git a/scripts/listener-identity-staging/rollback.sh b/scripts/listener-identity-staging/rollback.sh new file mode 100755 index 00000000..a6a713f1 --- /dev/null +++ b/scripts/listener-identity-staging/rollback.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +deploy_file=${1:?usage: rollback.sh /etc/harmonic-beacon/listener-identity-staging.deploy.env} +listener_staging_load "$deploy_file" +exec 9>/run/lock/listener-identity-staging.lock +flock -n 9 || listener_staging_fail 'another Listener staging operation is active' +listener_staging_restore_edge +listener_staging_restore_account_enabled +listener_staging_restore_drop_ins + +previous_file="$LISTENER_IDENTITY_STAGING_STATE_DIR/previous-image" +if test -f "$previous_file"; then + previous=$(sed -n '1p' "$previous_file") + printf '%s\n' "$previous" | grep -Eq '^harmonic-beacon/listener-identity-staging:[0-9a-f]{40}$' || + listener_staging_fail 'recorded rollback image is invalid' + docker image inspect "$previous" >/dev/null + LISTENER_IDENTITY_STAGING_IMAGE_TAG=${previous##*:} + LISTENER_IDENTITY_STAGING_GIT_SHA=$LISTENER_IDENTITY_STAGING_IMAGE_TAG + export LISTENER_IDENTITY_STAGING_IMAGE_TAG LISTENER_IDENTITY_STAGING_GIT_SHA + listener_staging_compose up -d --no-deps --force-recreate app + listener_staging_wait_healthy + health=$(curl --fail --silent --show-error --max-time 5 http://127.0.0.1:13001/api/health) + printf '%s\n' "$health" | grep -Fq "\"gitSha\":\"$LISTENER_IDENTITY_STAGING_GIT_SHA\"" || + listener_staging_fail 'rollback health provenance mismatch' + echo "Listener identity staging rolled back to $LISTENER_IDENTITY_STAGING_GIT_SHA; database was not downgraded." + exit 0 +fi + +# First-cutover recovery: the prior disposable staging container is retained, +# stopped rather than deleted, until the new stack is accepted. +listener_staging_compose stop app || true +if docker inspect listener-ui-dev >/dev/null 2>&1; then + docker start listener-ui-dev >/dev/null + echo 'First-cutover rollback restored the retained listener-ui-dev container; dedicated PostgreSQL was preserved.' + exit 0 +fi +listener_staging_fail 'no previous immutable image or retained legacy staging container is available' diff --git a/scripts/listener-identity-staging/start.sh b/scripts/listener-identity-staging/start.sh new file mode 100755 index 00000000..887c7b97 --- /dev/null +++ b/scripts/listener-identity-staging/start.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env sh +set -eu +. "$(dirname -- "$0")/lib.sh" + +deploy_file=${1:?usage: start.sh /etc/harmonic-beacon/listener-identity-staging.deploy.env} +listener_staging_load "$deploy_file" +exec 9>/run/lock/listener-identity-staging.lock +flock -n 9 || listener_staging_fail 'another Listener staging operation is active' +listener_staging_assert_checkout +listener_staging_compose config --quiet +listener_staging_compose build app +listener_staging_verify_image +# Mona intentionally has no host Node runtime. Validate the protected files +# with the exact reviewed image, before any database or runtime mutation. +listener_staging_validate_image +listener_staging_assert_dependencies +listener_staging_assert_port +protected_before=$(listener_staging_fingerprint_protected) +listener_staging_capture_previous +listener_staging_compose up -d postgres +listener_staging_wait_postgres +listener_staging_backup + +cutover_started=0 +rollback_on_error() { + status=$? + trap - EXIT HUP INT TERM + if test "$status" -ne 0 && test "$cutover_started" = 1; then + flock -u 9 || true + "$(dirname -- "$0")/rollback.sh" "$deploy_file" || true + fi + exit "$status" +} +trap rollback_on_error EXIT HUP INT TERM + +# Keep the public disposable runtime serving throughout validation, build and +# backup. Stop it only at the last reversible boundary before binding 13001. +if test "$(listener_staging_port_owner)" = listener-ui-dev; then + docker stop listener-ui-dev >/dev/null + cutover_started=1 +fi + +# Compose runs the exact-image, forward-only migration and starts the app only +# after migration exits successfully. It never targets the production project. +cutover_started=1 +listener_staging_compose up -d app +listener_staging_wait_healthy +"$(dirname -- "$0")/health-smoke.sh" "$deploy_file" +listener_staging_install_edge +"$(dirname -- "$0")/edge-smoke.sh" "$deploy_file" +protected_after=$(listener_staging_fingerprint_protected) +test "$protected_before" = "$protected_after" || + listener_staging_fail 'a protected Listener production/event container changed during staging cutover' + +image="harmonic-beacon/listener-identity-staging:$LISTENER_IDENTITY_STAGING_IMAGE_TAG" +image_id=$(docker image inspect "$image" --format '{{.Id}}') +printf '%s\n' "$image" > "$LISTENER_IDENTITY_STAGING_STATE_DIR/current-image" +printf '%s\n' "$image_id" > "$LISTENER_IDENTITY_STAGING_STATE_DIR/current-image-id" +chmod 0600 "$LISTENER_IDENTITY_STAGING_STATE_DIR/current-image" \ + "$LISTENER_IDENTITY_STAGING_STATE_DIR/current-image-id" + +cutover_started=0 +trap - EXIT HUP INT TERM +echo "Listener identity staging is healthy at exact SHA $LISTENER_IDENTITY_STAGING_GIT_SHA." diff --git a/scripts/listener-quiesce-for-free-for-all.ts b/scripts/listener-quiesce-for-free-for-all.ts new file mode 100644 index 00000000..ba188707 --- /dev/null +++ b/scripts/listener-quiesce-for-free-for-all.ts @@ -0,0 +1,31 @@ +import { prisma } from '@/lib/db'; +import { quiescePersonalListenerLeasesForFreeForAll } from '@/lib/early-birds/stream'; + +const BATCH_SIZE = 1_000; +const MAX_BATCHES = 100; + +async function main() { + if (process.env.EARLY_BIRDS_ENABLED !== '0' || process.env.EARLY_BIRDS_FREE_FOR_ALL !== '0') { + throw new Error('Disable Listener public entry and keep Free For All OFF before quiescing leases'); + } + + let totalSettled = 0; + for (let batch = 0; batch < MAX_BATCHES; batch += 1) { + const result = await quiescePersonalListenerLeasesForFreeForAll(BATCH_SIZE); + totalSettled += result.accountsSettled; + if (result.accountsSettled === 0) { + console.info(`Listener personal leases quiesced; accounts settled: ${totalSettled}`); + return; + } + } + throw new Error('Listener lease quiescence did not converge within the bounded batch limit'); +} + +main() + .catch((error) => { + console.error(error instanceof Error ? error.message : 'Listener lease quiescence failed'); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh new file mode 100755 index 00000000..5a5d547b --- /dev/null +++ b/scripts/listener-ui-preview.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fast, disposable Listener UI loop. Source stays on the workstation, is synced +# to the secondary volume on mona, and is served by Next dev behind the existing +# staging hostname. The persistent Listener release on port 13000 is untouched. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PREVIEW_HOST="${LISTENER_UI_PREVIEW_HOST:-mona}" +REMOTE_ROOT="${LISTENER_UI_PREVIEW_ROOT:-/mnt/beacon-data/listener-ui-dev}" +REMOTE_SOURCE="${REMOTE_ROOT}/source" +REMOTE_NEXT="${REMOTE_ROOT}/next" +DEV_CONTAINER="listener-ui-dev" +RELEASE_CONTAINER="earlybirds-preview-listener-1" +PREVIEW_FREE_FOR_ALL="${LISTENER_UI_PREVIEW_FREE_FOR_ALL:-1}" +PREVIEW_REACTIVE_FIELD_LAB="${LISTENER_UI_PREVIEW_REACTIVE_FIELD_LAB_ENABLED:-1}" +PREVIEW_DROPIN_ES_PATH="${LISTENER_UI_PREVIEW_DROPIN_ES_PATH:-}" +PREVIEW_DROPIN_EN_PATH="${LISTENER_UI_PREVIEW_DROPIN_EN_PATH:-}" +PREVIEW_PAYPAL_CHECKOUT="${LISTENER_UI_PREVIEW_PAYPAL_SANDBOX_CHECKOUT_ENABLED:-0}" +PREVIEW_MERCADO_PAGO_CHECKOUT="${LISTENER_UI_PREVIEW_MERCADO_PAGO_TEST_CHECKOUT_ENABLED:-0}" +PREVIEW_LIVE_WORKBENCH="${LISTENER_UI_PREVIEW_LIVE_WORKBENCH_ENABLED:-0}" +PREVIEW_APPLE="${LISTENER_UI_PREVIEW_APPLE_ENABLED:-0}" +PREVIEW_EXPECTED_SHA="${LISTENER_UI_PREVIEW_EXPECTED_SHA:-}" +LIVE_WORKBENCH_ENV_FILE="/etc/harmonic-beacon/listener-live-workbench.env" +ACCOUNT_STAGING_ENV_FILE="/etc/harmonic-beacon/listener-account-staging.env" +PREVIEW_ORIGIN="https://earlybirds-staging.harmonicbeacon.com" + +for switch in "$PREVIEW_FREE_FOR_ALL" "$PREVIEW_REACTIVE_FIELD_LAB" "$PREVIEW_PAYPAL_CHECKOUT" \ + "$PREVIEW_MERCADO_PAGO_CHECKOUT" "$PREVIEW_LIVE_WORKBENCH" "$PREVIEW_APPLE"; do + case "$switch" in 0|1) ;; *) echo "Preview switches must be 0 or 1." >&2; exit 2 ;; esac +done + +if [ "$PREVIEW_APPLE" = 1 ] && [ "$PREVIEW_FREE_FOR_ALL" != 0 ]; then + echo "Apple sign-in acceptance requires Free For All to be disabled." >&2 + exit 2 +fi + +for intro_override in \ + "Spanish:$PREVIEW_DROPIN_ES_PATH" \ + "English:$PREVIEW_DROPIN_EN_PATH"; do + intro_language="${intro_override%%:*}" + intro_path="${intro_override#*:}" + if [ -n "$intro_path" ] && + [[ ! "$intro_path" =~ ^/media/artifacts/drop-ins/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.m4a$ ]]; then + echo "$intro_language intro preview must name one bounded immutable .m4a artifact." >&2 + exit 2 + fi +done + +payment_modes=$((PREVIEW_PAYPAL_CHECKOUT + PREVIEW_MERCADO_PAGO_CHECKOUT + PREVIEW_LIVE_WORKBENCH)) +if [ "$payment_modes" -gt 0 ] && [ "$PREVIEW_FREE_FOR_ALL" != 0 ]; then + echo "Payment checkout requires Free For All to be disabled." >&2 + exit 2 +fi +if [ "$payment_modes" -gt 1 ]; then + echo "Select exactly one payment provider or private Live workbench." >&2 + exit 2 +fi +if [ "$PREVIEW_LIVE_WORKBENCH" = 1 ]; then + case "$PREVIEW_EXPECTED_SHA" in + *[!0-9a-f]*|'') echo "Private Live workbench requires an exact lowercase 40-character SHA." >&2; exit 2 ;; + esac + [ "${#PREVIEW_EXPECTED_SHA}" -eq 40 ] || { + echo "Private Live workbench requires an exact lowercase 40-character SHA." >&2 + exit 2 + } +elif [ -n "$PREVIEW_EXPECTED_SHA" ]; then + echo "LISTENER_UI_PREVIEW_EXPECTED_SHA is valid only for the private Live workbench." >&2 + exit 2 +fi + +usage() { + echo "Usage: $0 {start|sync|watch|status|stop|logs}" >&2 +} + +sync_source() { + ssh "$PREVIEW_HOST" "sudo install -d -m 0755 -o \"\$(id -un)\" -g \"\$(id -gn)\" '$REMOTE_ROOT' '$REMOTE_SOURCE' '$REMOTE_SOURCE/src' '$REMOTE_SOURCE/public'" + rsync -az --delete --chmod=D755,F644 "$ROOT_DIR/src/" "$PREVIEW_HOST:$REMOTE_SOURCE/src/" + rsync -az --delete --chmod=D755,F644 "$ROOT_DIR/public/" "$PREVIEW_HOST:$REMOTE_SOURCE/public/" + rsync -az --chmod=F644 \ + "$ROOT_DIR/next.config.ts" \ + "$ROOT_DIR/postcss.config.mjs" \ + "$ROOT_DIR/tsconfig.json" \ + "$PREVIEW_HOST:$REMOTE_SOURCE/" +} + +start_remote() { + ssh "$PREVIEW_HOST" "REMOTE_SOURCE='$REMOTE_SOURCE' REMOTE_NEXT='$REMOTE_NEXT' DEV_CONTAINER='$DEV_CONTAINER' RELEASE_CONTAINER='$RELEASE_CONTAINER' PREVIEW_FREE_FOR_ALL='$PREVIEW_FREE_FOR_ALL' PREVIEW_REACTIVE_FIELD_LAB='$PREVIEW_REACTIVE_FIELD_LAB' PREVIEW_DROPIN_ES_PATH='$PREVIEW_DROPIN_ES_PATH' PREVIEW_DROPIN_EN_PATH='$PREVIEW_DROPIN_EN_PATH' PREVIEW_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' PREVIEW_MERCADO_PAGO_CHECKOUT='$PREVIEW_MERCADO_PAGO_CHECKOUT' PREVIEW_LIVE_WORKBENCH='$PREVIEW_LIVE_WORKBENCH' PREVIEW_APPLE='$PREVIEW_APPLE' PREVIEW_EXPECTED_SHA='$PREVIEW_EXPECTED_SHA' LIVE_WORKBENCH_ENV_FILE='$LIVE_WORKBENCH_ENV_FILE' ACCOUNT_STAGING_ENV_FILE='$ACCOUNT_STAGING_ENV_FILE' PREVIEW_ORIGIN='$PREVIEW_ORIGIN' bash -s" <<'REMOTE' +set -euo pipefail + +if [ "$PREVIEW_LIVE_WORKBENCH" = 1 ]; then + image="harmonic-beacon/earlybirds-preview-listener:${PREVIEW_EXPECTED_SHA}" + docker image inspect "$image" >/dev/null + docker image inspect "$image" --format '{{range .Config.Env}}{{println .}}{{end}}' | + grep -Fqx "BEACON_GIT_SHA=$PREVIEW_EXPECTED_SHA" +else + image="$(docker inspect "$RELEASE_CONTAINER" --format '{{.Config.Image}}')" +fi +env_file="$(mktemp /tmp/listener-ui-dev-env.XXXXXX)" +workbench_container_started=0 +workbench_validated=0 +cleanup() { + rm -f "$env_file" + if [ "$PREVIEW_LIVE_WORKBENCH" = 1 ] && + [ "$workbench_container_started" = 1 ] && + [ "$workbench_validated" != 1 ]; then + docker rm -f "$DEV_CONTAINER" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +umask 077 +docker inspect "$RELEASE_CONTAINER" | jq -r '.[0].Config.Env[]' > "$env_file" + +set_env_file_value() { + key="$1" + value="$2" + sed -i "/^${key}=/d" "$env_file" + printf '%s=%s\n' "$key" "$value" >> "$env_file" +} + +unset_env_file_value() { + sed -i "/^${1}=/d" "$env_file" +} + +if [ -n "$PREVIEW_DROPIN_EN_PATH" ]; then + set_env_file_value EARLY_BIRDS_DROPIN_EN_PATH "$PREVIEW_DROPIN_EN_PATH" +fi +if [ -n "$PREVIEW_DROPIN_ES_PATH" ]; then + set_env_file_value EARLY_BIRDS_DROPIN_ES_PATH "$PREVIEW_DROPIN_ES_PATH" +fi + +install -d -m 0755 "$REMOTE_NEXT" +sudo chown 1001:1001 "$REMOTE_NEXT" +sudo install -m 0644 -o 1001 -g 1001 /dev/null "$REMOTE_NEXT/next-env.d.ts" + +if docker container inspect "$DEV_CONTAINER" >/dev/null 2>&1; then + docker rm -f "$DEV_CONTAINER" >/dev/null +fi + +# Every disposable staging mode must initiate and receive authentication on +# its own host. Inheriting the persistent Listener base URL sends OAuth back +# to listen.harmonicbeacon.com, where the staging state cookie is absent. +set_env_file_value BEACON_LISTENER_AUTH_BASE_URL "$PREVIEW_ORIGIN" +set_env_file_value EARLY_BIRDS_AUTH_BASE_URL "$PREVIEW_ORIGIN" +# The disposable 13001 runtime is staging-bound and must never inherit the +# 13000 production RP secrets copied from the release container. +unset_env_file_value BEACON_LISTENER_ACCOUNT_CLIENT_SECRET +unset_env_file_value BEACON_LISTENER_ACCOUNT_STATE_SECRET +unset_env_file_value BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING +unset_env_file_value BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING +set_env_file_value BEACON_LISTENER_ACCOUNT_ENVIRONMENT staging +if sudo test -f "$ACCOUNT_STAGING_ENV_FILE" && ! sudo test -L "$ACCOUNT_STAGING_ENV_FILE" && + test "$(sudo stat -c '%U:%G:%a' "$ACCOUNT_STAGING_ENV_FILE")" = root:root:600; then + staging_keys=$(sudo awk -F= 'NF { print $1 }' "$ACCOUNT_STAGING_ENV_FILE" | LC_ALL=C sort) + expected_staging_keys=$(printf '%s\n' \ + BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING \ + BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING | LC_ALL=C sort) + test "$staging_keys" = "$expected_staging_keys" || { + echo 'Staging Account RP secret file must contain exactly the two approved keys.' >&2 + exit 1 + } + staging_client=$(sudo sed -n 's/^BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING=//p' "$ACCOUNT_STAGING_ENV_FILE") + staging_state=$(sudo sed -n 's/^BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING=//p' "$ACCOUNT_STAGING_ENV_FILE") + test "${#staging_client}" -ge 32 && test "${#staging_state}" -ge 32 || { + echo 'Staging Account RP secret file is incomplete.' >&2 + exit 1 + } + set_env_file_value BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING "$staging_client" + set_env_file_value BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING "$staging_state" + set_env_file_value BEACON_LISTENER_ACCOUNT_ENABLED 1 +else + set_env_file_value BEACON_LISTENER_ACCOUNT_ENABLED 0 +fi +# Never inherit a future public Apple enablement accidentally. Credentials can +# be installed dormant in the root-owned release environment; staging exposes +# Apple only through its own explicit preview switch. +set_env_file_value BEACON_LISTENER_APPLE_ENABLED "$PREVIEW_APPLE" + +runtime_args=() +command_args=() +if [ "$PREVIEW_PAYPAL_CHECKOUT" = 1 ] || [ "$PREVIEW_MERCADO_PAGO_CHECKOUT" = 1 ] || [ "$PREVIEW_LIVE_WORKBENCH" = 1 ]; then + # Synthetic team entry is deliberately unavailable under NODE_ENV=development. + # Payment rehearsal therefore runs the exact built release artifact. + runtime_args=(-e NODE_ENV=production) + command_args=(node server.js) +else + runtime_args=( + -e NODE_ENV=development + -e BEACON_GIT_SHA=ui-dev + -e WATCHPACK_POLLING=true + -v "$REMOTE_SOURCE/src:/app/src:ro" + -v "$REMOTE_SOURCE/public:/app/public:ro" + -v "$REMOTE_SOURCE/next.config.ts:/app/next.config.ts:ro" + -v "$REMOTE_NEXT/next-env.d.ts:/app/next-env.d.ts" + -v "$REMOTE_SOURCE/postcss.config.mjs:/app/postcss.config.mjs:ro" + -v "$REMOTE_SOURCE/tsconfig.json:/app/tsconfig.json:ro" + -v "$REMOTE_NEXT:/app/.next" + ) + # Turbopack can stall indefinitely while compiling source bind-mounted + # from the remote preview volume. Webpack is slower to cold-start but is + # deterministic for this disposable network-mounted UI loop. + command_args=(npm run dev -- --webpack --hostname 0.0.0.0 --port 3000) +fi + +if [ "$PREVIEW_LIVE_WORKBENCH" = 1 ]; then + test "$(sudo stat -c '%u:%g:%a' "$LIVE_WORKBENCH_ENV_FILE")" = "0:0:600" + sudo awk -F= ' + BEGIN { good=1 } + /^[[:space:]]*$/ { next } + $1 == "BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED" || + $1 == "BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID" || + $1 == "BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER" || + $1 == "BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET" { seen[$1]++; next } + { good=0 } + END { + required[1]="BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED" + required[2]="BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID" + required[3]="BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER" + required[4]="BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET" + for (i=1; i<=4; i++) if (seen[required[i]] != 1) good=0 + exit good ? 0 : 1 + } + ' "$LIVE_WORKBENCH_ENV_FILE" + # The persistent Listener env already contains the dormant workbench keys. + # Replace each value instead of appending duplicates: OCI env arrays may + # preserve both entries and runtimes are not required to select the last. + while IFS='=' read -r workbench_key workbench_value; do + set_env_file_value "$workbench_key" "$workbench_value" + done < <(sudo cat "$LIVE_WORKBENCH_ENV_FILE") + set_env_file_value EARLY_BIRDS_FREE_FOR_ALL 0 + set_env_file_value BEACON_LISTENER_FREE_FOR_ALL 0 + set_env_file_value BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED 0 + set_env_file_value BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED 0 + set_env_file_value BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED 0 + set_env_file_value BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED 0 + # The inherited release env would otherwise override the selected image's + # baked provenance with the persistent 13000 release SHA. + set_env_file_value BEACON_GIT_SHA "$PREVIEW_EXPECTED_SHA" +fi + +docker run -d \ + --name "$DEV_CONTAINER" \ + --restart unless-stopped \ + --init \ + --env-file "$env_file" \ + -e NEXT_TELEMETRY_DISABLED=1 \ + -e EARLY_BIRDS_ENABLED=1 \ + -e BEACON_LISTENER_ENABLED=1 \ + -e EARLY_BIRDS_FREE_FOR_ALL="$PREVIEW_FREE_FOR_ALL" \ + -e BEACON_LISTENER_FREE_FOR_ALL="$PREVIEW_FREE_FOR_ALL" \ + -e BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED="$PREVIEW_REACTIVE_FIELD_LAB" \ + -e BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED="$PREVIEW_PAYPAL_CHECKOUT" \ + -e BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED="$PREVIEW_MERCADO_PAGO_CHECKOUT" \ + --network earlybirds_preview_db_internal \ + -p 127.0.0.1:13001:3000 \ + --volumes-from "$RELEASE_CONTAINER:ro" \ + "${runtime_args[@]}" \ + "$image" \ + "${command_args[@]}" >/dev/null +if [ "$PREVIEW_LIVE_WORKBENCH" = 1 ]; then + workbench_container_started=1 +fi + +docker network connect earlybirds_preview_listener_egress "$DEV_CONTAINER" +docker network connect earlybirds_authority_private "$DEV_CONTAINER" +docker network connect earlybirds_stream_control_internal "$DEV_CONTAINER" + +for _ in $(seq 1 90); do + if curl --fail --silent --max-time 3 http://127.0.0.1:13001/api/health >/dev/null && + curl --fail --silent --max-time 3 http://127.0.0.1:13001/api/health/ready >/dev/null; then + if [ "$PREVIEW_LIVE_WORKBENCH" = 1 ]; then + running_sha="$(docker inspect "$DEV_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^BEACON_GIT_SHA=//p')" + test "$running_sha" = "$PREVIEW_EXPECTED_SHA" + test "$(docker port "$DEV_CONTAINER" 3000/tcp)" = "127.0.0.1:13001" + workbench_validated=1 + fi + exit 0 + fi + sleep 1 +done + +docker logs --tail 80 "$DEV_CONTAINER" >&2 +exit 1 +REMOTE +} + +case "${1:-}" in + start) + sync_source + start_remote + ;; + sync) + sync_source + ;; + watch) + sync_source + echo "Watching Listener UI sources; Ctrl-C stops only the sync loop." + while inotifywait -qq -r -e close_write,create,delete,move \ + "$ROOT_DIR/src" "$ROOT_DIR/public" \ + "$ROOT_DIR/next.config.ts" "$ROOT_DIR/postcss.config.mjs" "$ROOT_DIR/tsconfig.json"; do + sync_source + done + ;; + status) + ssh "$PREVIEW_HOST" "docker ps --filter name=^/${DEV_CONTAINER}$ --format '{{.Names}} {{.Image}} {{.Status}}'; curl --fail --silent http://127.0.0.1:13001/api/health; printf '\n'" + ;; + stop) + ssh "$PREVIEW_HOST" "docker rm -f '$DEV_CONTAINER' >/dev/null 2>&1 || true" + ;; + logs) + ssh "$PREVIEW_HOST" "docker logs --tail 120 -f '$DEV_CONTAINER'" + ;; + *) + usage + exit 2 + ;; +esac diff --git a/scripts/listener-withdrawal-export-metrics.sh b/scripts/listener-withdrawal-export-metrics.sh new file mode 100755 index 00000000..cd02d7ca --- /dev/null +++ b/scripts/listener-withdrawal-export-metrics.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID:-$(id -u)} -ne 0 ]]; then + echo 'listener withdrawal metrics export requires root' >&2 + exit 1 +fi + +container=${LISTENER_WITHDRAWAL_CONTAINER:-earlybirds-preview-withdrawal-operator-1} +metrics_dir=/var/lib/harmonic-beacon/metrics +metrics_file=$metrics_dir/listener-withdrawal.prom +docker inspect --format '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" \ + 2>/dev/null | grep -Fxq 'true healthy' || { + echo 'Listener withdrawal operator sidecar is not healthy' >&2 + exit 1 +} + +install -d -o root -g root -m 0755 "$metrics_dir" +temporary=$(mktemp "$metrics_dir/.listener-withdrawal.XXXXXX") +trap 'rm -f "$temporary"' EXIT +docker exec --user root "$container" \ + npx --no-install tsx scripts/listener-withdrawal-operator.ts metrics >"$temporary" +printf '%s %s\n' beacon_listener_withdrawal_metrics_export_unixtime "$(date +%s)" >>"$temporary" +chown root:root "$temporary" +chmod 0644 "$temporary" +mv -f "$temporary" "$metrics_file" +trap - EXIT diff --git a/scripts/listener-withdrawal-operator.ts b/scripts/listener-withdrawal-operator.ts new file mode 100755 index 00000000..051282b2 --- /dev/null +++ b/scripts/listener-withdrawal-operator.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env -S npx tsx + +import { listenerWithdrawalReceiptDigest } from '../src/lib/listener/consumer-withdrawal'; +import { prisma } from '../src/lib/db'; + +const OPERATOR_PATTERN = /^[a-z0-9][a-z0-9._-]{1,63}$/i; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const RECEIPT_PATTERN = /^HBW-[0-9A-F]{30}$/; +const RESOLUTION_CODES = new Set([ + 'CANCELLED', + 'REFUNDED', + 'CANCELLED_AND_REFUNDED', + 'DUPLICATE', + 'NOT_APPLICABLE', +]); + +function usage(): never { + throw new Error([ + 'Usage:', + ' listener-withdrawal-operator.ts list [limit]', + ' listener-withdrawal-operator.ts show ', + ' listener-withdrawal-operator.ts acknowledge ', + ' listener-withdrawal-operator.ts resolve ', + ' listener-withdrawal-operator.ts metrics', + ' listener-withdrawal-operator.ts prune-throttles [retention-hours]', + ].join('\n')); +} + +function assertRoot(): void { + if (typeof process.getuid !== 'function' || process.getuid() !== 0) { + throw new Error('Refusing to expose or mutate the private queue outside a root-owned operator session.'); + } +} + +function requestSelector(value: string) { + if (UUID_PATTERN.test(value)) return { id: value }; + if (RECEIPT_PATTERN.test(value)) return { receiptDigest: listenerWithdrawalReceiptDigest(value) }; + return usage(); +} + +function operatorCode(value: string): string { + if (!OPERATOR_PATTERN.test(value)) usage(); + return value; +} + +async function main() { + assertRoot(); + const [command, ...args] = process.argv.slice(2); + + if (command === 'metrics') { + if (args.length !== 0) usage(); + const [received, acknowledged, oldest] = await Promise.all([ + prisma.listenerWithdrawalRequest.count({ where: { status: 'RECEIVED' } }), + prisma.listenerWithdrawalRequest.count({ where: { status: 'ACKNOWLEDGED' } }), + prisma.listenerWithdrawalRequest.findFirst({ + where: { status: { not: 'RESOLVED' } }, + orderBy: { createdAt: 'asc' }, + select: { createdAt: true }, + }), + ]); + const ageSeconds = oldest + ? Math.max(0, Math.floor((Date.now() - oldest.createdAt.getTime()) / 1_000)) + : 0; + process.stdout.write([ + '# HELP beacon_listener_withdrawal_open_requests Open consumer requests by bounded status.', + '# TYPE beacon_listener_withdrawal_open_requests gauge', + `beacon_listener_withdrawal_open_requests{status="received"} ${received}`, + `beacon_listener_withdrawal_open_requests{status="acknowledged"} ${acknowledged}`, + '# HELP beacon_listener_withdrawal_oldest_open_age_seconds Age of the oldest open request.', + '# TYPE beacon_listener_withdrawal_oldest_open_age_seconds gauge', + `beacon_listener_withdrawal_oldest_open_age_seconds ${ageSeconds}`, + '', + ].join('\n')); + return; + } + + if (command === 'prune-throttles') { + if (args.length > 1) usage(); + const hours = args[0] === undefined ? 48 : Number(args[0]); + if (!Number.isSafeInteger(hours) || hours < 2 || hours > 8_760) usage(); + const cutoff = new Date(Date.now() - hours * 60 * 60 * 1_000); + const deleted = await prisma.listenerWithdrawalThrottle.deleteMany({ + where: { updatedAt: { lt: cutoff } }, + }); + process.stdout.write(`${JSON.stringify({ prunedThrottleRows: deleted.count, cutoff: cutoff.toISOString() })}\n`); + return; + } + + if (command === 'list') { + if (args.length > 1) usage(); + const limit = args[0] === undefined ? 50 : Number(args[0]); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) usage(); + const rows = await prisma.listenerWithdrawalRequest.findMany({ + where: { status: { not: 'RESOLVED' } }, + orderBy: { createdAt: 'asc' }, + take: limit, + select: { + id: true, + receiptLastFour: true, + provider: true, + requestKind: true, + status: true, + createdAt: true, + acknowledgedAt: true, + }, + }); + process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); + return; + } + + if (command === 'show') { + if (args.length !== 1) usage(); + const row = await prisma.listenerWithdrawalRequest.findUnique({ + where: requestSelector(args[0]), + select: { + id: true, + receiptLastFour: true, + contactEmail: true, + provider: true, + requestKind: true, + purchaseDate: true, + locale: true, + status: true, + createdAt: true, + acknowledgedAt: true, + acknowledgedBy: true, + resolvedAt: true, + resolvedBy: true, + resolutionCode: true, + }, + }); + if (!row) throw new Error('Request not found.'); + process.stdout.write(`${JSON.stringify(row, null, 2)}\n`); + return; + } + + if (command === 'acknowledge') { + if (args.length !== 2 || !UUID_PATTERN.test(args[0])) usage(); + const actor = operatorCode(args[1]); + const now = new Date(); + const updated = await prisma.listenerWithdrawalRequest.updateMany({ + where: { id: args[0], status: 'RECEIVED' }, + data: { status: 'ACKNOWLEDGED', acknowledgedAt: now, acknowledgedBy: actor }, + }); + if (updated.count !== 1) { + const current = await prisma.listenerWithdrawalRequest.findUnique({ + where: { id: args[0] }, select: { status: true }, + }); + if (!current) throw new Error('Request not found.'); + if (current.status !== 'ACKNOWLEDGED') throw new Error(`Cannot acknowledge request in ${current.status}.`); + } + process.stdout.write(`${JSON.stringify({ id: args[0], status: 'ACKNOWLEDGED' })}\n`); + return; + } + + if (command === 'resolve') { + if (args.length !== 3 || !UUID_PATTERN.test(args[0])) usage(); + const actor = operatorCode(args[1]); + const resolution = args[2].toUpperCase(); + if (!RESOLUTION_CODES.has(resolution)) usage(); + const updated = await prisma.listenerWithdrawalRequest.updateMany({ + where: { id: args[0], status: 'ACKNOWLEDGED' }, + data: { + status: 'RESOLVED', + resolvedAt: new Date(), + resolvedBy: actor, + resolutionCode: resolution, + }, + }); + if (updated.count !== 1) { + const current = await prisma.listenerWithdrawalRequest.findUnique({ + where: { id: args[0] }, + select: { status: true, resolutionCode: true }, + }); + if (!current || current.status !== 'RESOLVED' || current.resolutionCode !== resolution) { + throw new Error('Request must exist and be ACKNOWLEDGED before resolution.'); + } + } + process.stdout.write(`${JSON.stringify({ id: args[0], status: 'RESOLVED', resolution })}\n`); + return; + } + + usage(); +} + +main() + .catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : 'Operator command failed.'}\n`); + process.exitCode = 1; + }) + .finally(async () => prisma.$disconnect()); diff --git a/scripts/listener-withdrawal-prune-throttles.sh b/scripts/listener-withdrawal-prune-throttles.sh new file mode 100755 index 00000000..ec738d69 --- /dev/null +++ b/scripts/listener-withdrawal-prune-throttles.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID:-$(id -u)} -ne 0 ]]; then + echo 'listener withdrawal throttle pruning requires root' >&2 + exit 1 +fi + +container=${LISTENER_WITHDRAWAL_CONTAINER:-earlybirds-preview-withdrawal-operator-1} +docker inspect --format '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" \ + 2>/dev/null | grep -Fxq 'true healthy' || { + echo 'Listener withdrawal operator sidecar is not healthy' >&2 + exit 1 +} +exec docker exec --user root "$container" \ + npx --no-install tsx scripts/listener-withdrawal-operator.ts prune-throttles 48 diff --git a/scripts/listener_container_observer.py b/scripts/listener_container_observer.py new file mode 100644 index 00000000..3a1f6841 --- /dev/null +++ b/scripts/listener_container_observer.py @@ -0,0 +1,361 @@ +#!/usr/bin/python3 +"""Export fixed Listener container restart/OOM continuity as textfile metrics.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import secrets +import stat +import subprocess +import sys +import time +from typing import Any + +OBSERVER_SCHEMA_VERSION = 1 +DOCKER_BINARY = "/usr/bin/docker" +METRICS_FILE = Path("/var/lib/harmonic-beacon/metrics/listener-container-observer.prom") +STATE_FILE = Path("/var/lib/harmonic-beacon/listener-container-observer/state.json") +MAX_INSPECT_BYTES = 256 * 1024 +MAX_STATE_BYTES = 64 * 1024 +MAX_SAFE_COUNTER = (2**53) - 1 + +TARGETS = ( + {"role": "listener", "name": "earlybirds-preview-listener-1", "service": "listener"}, + {"role": "origin", "name": "earlybirds-preview-beacon-stream-1", "service": "beacon-stream"}, +) + + +def _bounded_counter(value: Any, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > MAX_SAFE_COUNTER: + raise ValueError(f"{label} is invalid") + return value + + +def _timestamp(value: Any, label: str) -> float: + if not isinstance(value, str): + raise ValueError(f"{label} is invalid") + try: + # Docker emits RFC3339 with nanoseconds and Z; fromisoformat accepts a + # bounded microsecond form, so truncate only the fractional precision. + normalized = value.replace("Z", "+00:00") + if "." in normalized: + prefix, suffix = normalized.split(".", 1) + digits, zone = suffix.split("+", 1) if "+" in suffix else suffix.split("-", 1) + sign = "+" if "+" in suffix else "-" + normalized = f"{prefix}.{digits[:6]}{sign}{zone}" + from datetime import datetime + + result = datetime.fromisoformat(normalized).timestamp() + except (TypeError, ValueError): + raise ValueError(f"{label} is invalid") from None + if not result > 0: + raise ValueError(f"{label} is invalid") + return result + + +def _identity_digest(value: Any) -> str: + if not isinstance(value, str) or len(value) != 64 or any(ch not in "0123456789abcdef" for ch in value): + raise ValueError("container identity is invalid") + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def parse_docker_inspect(raw: str) -> dict[str, dict[str, Any]]: + if not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_INSPECT_BYTES: + raise ValueError("Docker inspect output is invalid") + try: + rows = json.loads(raw) + except json.JSONDecodeError: + raise ValueError("Docker inspect output is malformed") from None + if not isinstance(rows, list) or len(rows) != len(TARGETS): + raise ValueError("Docker inspect target set is incomplete or ambiguous") + + by_name: dict[str, dict[str, Any]] = {} + for row in rows: + name = row.get("Name", "") if isinstance(row, dict) else "" + name = name.removeprefix("/") if isinstance(name, str) else "" + if not name or name in by_name: + raise ValueError("Docker inspect target is duplicated") + by_name[name] = row + + result: dict[str, dict[str, Any]] = {} + for target in TARGETS: + row = by_name.get(target["name"]) + labels = row.get("Config", {}).get("Labels", {}) if isinstance(row, dict) else {} + if ( + not row + or labels.get("com.docker.compose.project") != "earlybirds-preview" + or labels.get("com.docker.compose.service") != target["service"] + ): + raise ValueError(f"Docker inspect {target['role']} target does not match its fixed boundary") + state = row.get("State", {}) + if state.get("Status") != "running": + raise ValueError(f"Docker inspect {target['role']} target is not running") + result[target["role"]] = { + "identity": _identity_digest(row.get("Id")), + "startTimeSeconds": _timestamp(state.get("StartedAt"), f"{target['role']} start time"), + "dockerRestartCount": _bounded_counter( + row.get("RestartCount"), f"{target['role']} restart count" + ), + "oomKilled": state.get("OOMKilled") is True, + } + return result + + +def _validate_previous_target(value: Any, role: str) -> dict[str, Any]: + if ( + not isinstance(value, dict) + or not isinstance(value.get("identity"), str) + or len(value["identity"]) != 64 + or any(ch not in "0123456789abcdef" for ch in value["identity"]) + or isinstance(value.get("startTimeSeconds"), bool) + or not isinstance(value.get("startTimeSeconds"), (int, float)) + or not isinstance(value.get("oomKilled"), bool) + ): + raise ValueError(f"observer {role} state is invalid") + _bounded_counter(value.get("dockerRestartCount"), f"{role} stored Docker restart count") + _bounded_counter(value.get("restartEventsTotal"), f"{role} stored restart total") + _bounded_counter(value.get("oomEventsTotal"), f"{role} stored OOM total") + return value + + +def _add_counter(left: int, right: int, label: str) -> int: + return _bounded_counter(left + right, label) + + +def advance_observer_state( + previous: dict[str, Any] | None, + observations: dict[str, dict[str, Any]], + observed_at_seconds: int, +) -> dict[str, Any]: + if isinstance(observed_at_seconds, bool) or not isinstance(observed_at_seconds, int) or observed_at_seconds <= 0: + raise ValueError("observer timestamp is invalid") + first = previous is None + if not first and ( + not isinstance(previous, dict) + or previous.get("schemaVersion") != OBSERVER_SCHEMA_VERSION + or isinstance(previous.get("epochStartedAtSeconds"), bool) + or not isinstance(previous.get("epochStartedAtSeconds"), int) + or previous["epochStartedAtSeconds"] <= 0 + or not isinstance(previous.get("targets"), dict) + ): + raise ValueError("observer state is invalid") + + next_targets: dict[str, dict[str, Any]] = {} + for target in TARGETS: + role = target["role"] + observation = observations.get(role) + if not isinstance(observation, dict): + raise ValueError(f"observer {role} observation is missing") + identity = observation.get("identity") + if not isinstance(identity, str) or len(identity) != 64: + raise ValueError(f"observer {role} observation is invalid") + restart_count = _bounded_counter(observation.get("dockerRestartCount"), f"{role} restart count") + start_time = observation.get("startTimeSeconds") + if isinstance(start_time, bool) or not isinstance(start_time, (int, float)) or not start_time > 0: + raise ValueError(f"observer {role} start time is invalid") + oom_killed = observation.get("oomKilled") is True + old = None if first else _validate_previous_target(previous["targets"].get(role), role) + + if old is None: + restart_total = restart_count + oom_total = 1 if oom_killed else 0 + elif old["identity"] != identity: + restart_total = _add_counter( + old["restartEventsTotal"], 1 + restart_count, f"{role} restart total" + ) + oom_total = _add_counter(old["oomEventsTotal"], 1 if oom_killed else 0, f"{role} OOM total") + else: + if restart_count < old["dockerRestartCount"]: + raise ValueError(f"observer {role} Docker restart counter moved backwards") + restart_total = _add_counter( + old["restartEventsTotal"], restart_count - old["dockerRestartCount"], f"{role} restart total" + ) + oom_total = _add_counter( + old["oomEventsTotal"], 1 if oom_killed and not old["oomKilled"] else 0, f"{role} OOM total" + ) + + next_targets[role] = { + "identity": identity, + "startTimeSeconds": start_time, + "dockerRestartCount": restart_count, + "restartEventsTotal": restart_total, + "oomEventsTotal": oom_total, + "oomKilled": oom_killed, + } + + return { + "schemaVersion": OBSERVER_SCHEMA_VERSION, + "epochStartedAtSeconds": observed_at_seconds if first else previous["epochStartedAtSeconds"], + "lastSuccessAtSeconds": observed_at_seconds, + "targets": next_targets, + } + + +def render_observer_metrics(state: dict[str, Any]) -> str: + if not isinstance(state, dict) or state.get("schemaVersion") != OBSERVER_SCHEMA_VERSION: + raise ValueError("observer metrics state is invalid") + epoch = _bounded_counter(state.get("epochStartedAtSeconds"), "observer epoch") + success = _bounded_counter(state.get("lastSuccessAtSeconds"), "observer success timestamp") + lines = [ + "# HELP beacon_listener_container_observer_up Whether the fixed Listener container observer completed its latest sample.", + "# TYPE beacon_listener_container_observer_up gauge", + "beacon_listener_container_observer_up 1", + "# HELP beacon_listener_container_observer_last_success_timestamp_seconds Unix time of the latest complete fixed-target sample.", + "# TYPE beacon_listener_container_observer_last_success_timestamp_seconds gauge", + f"beacon_listener_container_observer_last_success_timestamp_seconds {success}", + "# HELP beacon_listener_container_observer_epoch_start_time_seconds Unix time at which the durable observer epoch began.", + "# TYPE beacon_listener_container_observer_epoch_start_time_seconds gauge", + f"beacon_listener_container_observer_epoch_start_time_seconds {epoch}", + "# HELP beacon_listener_container_start_time_seconds Start time of the currently observed fixed container role.", + "# TYPE beacon_listener_container_start_time_seconds gauge", + "# HELP beacon_listener_container_restart_events_total Restarts or replacements observed for the fixed container role.", + "# TYPE beacon_listener_container_restart_events_total counter", + "# HELP beacon_listener_container_oom_events_total OOM-killed terminal states observed for the fixed container role.", + "# TYPE beacon_listener_container_oom_events_total counter", + ] + for target in TARGETS: + role = target["role"] + value = _validate_previous_target(state.get("targets", {}).get(role), role) + lines.extend( + [ + f'beacon_listener_container_start_time_seconds{{role="{role}"}} {value["startTimeSeconds"]}', + f'beacon_listener_container_restart_events_total{{role="{role}"}} {value["restartEventsTotal"]}', + f'beacon_listener_container_oom_events_total{{role="{role}"}} {value["oomEventsTotal"]}', + ] + ) + return "\n".join(lines) + "\n" + + +def render_observer_failure_metrics(observed_at_seconds: int) -> str: + failure = _bounded_counter(observed_at_seconds, "observer failure timestamp") + return "\n".join( + [ + "# HELP beacon_listener_container_observer_up Whether the fixed Listener container observer completed its latest sample.", + "# TYPE beacon_listener_container_observer_up gauge", + "beacon_listener_container_observer_up 0", + "# HELP beacon_listener_container_observer_last_failure_timestamp_seconds Unix time of the latest failed sample.", + "# TYPE beacon_listener_container_observer_last_failure_timestamp_seconds gauge", + f"beacon_listener_container_observer_last_failure_timestamp_seconds {failure}", + "", + ] + ) + + +def _ensure_root_directory(path: Path, mode: int) -> None: + path.mkdir(parents=True, exist_ok=True, mode=mode) + details = path.lstat() + if not stat.S_ISDIR(details.st_mode) or details.st_uid != 0 or details.st_mode & 0o022: + raise RuntimeError("observer output directory is unsafe") + path.chmod(mode) + + +def _atomic_write(path: Path, contents: str, mode: int) -> None: + temporary = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(6)}.tmp") + descriptor = None + try: + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + descriptor = None + output.write(contents) + output.flush() + os.fsync(output.fileno()) + os.chmod(temporary, mode, follow_symlinks=False) + os.replace(temporary, path) + directory_descriptor = os.open( + path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + ) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + finally: + if descriptor is not None: + os.close(descriptor) + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _read_state() -> dict[str, Any] | None: + try: + details = STATE_FILE.lstat() + except FileNotFoundError: + return None + if ( + not stat.S_ISREG(details.st_mode) + or details.st_uid != 0 + or stat.S_IMODE(details.st_mode) != 0o600 + or details.st_size > MAX_STATE_BYTES + ): + raise RuntimeError("observer state cannot be read safely") + descriptor = os.open(STATE_FILE, os.O_RDONLY | os.O_NOFOLLOW) + try: + with os.fdopen(descriptor, "r", encoding="utf-8") as source: + descriptor = -1 + value = json.load(source) + except (json.JSONDecodeError, UnicodeDecodeError): + raise RuntimeError("observer state cannot be read safely") from None + finally: + if descriptor >= 0: + os.close(descriptor) + return value + + +def _write_failure_best_effort(observed_at_seconds: int) -> None: + try: + _ensure_root_directory(METRICS_FILE.parent, 0o755) + _atomic_write(METRICS_FILE, render_observer_failure_metrics(observed_at_seconds), 0o644) + except Exception: + pass + + +def run_observer() -> None: + if len(sys.argv) != 1: + raise RuntimeError("observer accepts no arguments") + if os.geteuid() != 0: + raise RuntimeError("observer requires root") + observed_at_seconds = int(time.time()) + _ensure_root_directory(METRICS_FILE.parent, 0o755) + _ensure_root_directory(STATE_FILE.parent, 0o700) + try: + completed = subprocess.run( + [ + DOCKER_BINARY, + "--host=unix:///var/run/docker.sock", + "inspect", + *(target["name"] for target in TARGETS), + ], + check=True, + capture_output=True, + env={ + "DOCKER_CONFIG": "/nonexistent", + "HOME": "/nonexistent", + "LANG": "C", + "LC_ALL": "C", + "PATH": "/usr/bin:/bin", + }, + text=True, + timeout=5, + ) + if len(completed.stdout.encode("utf-8")) > MAX_INSPECT_BYTES: + raise RuntimeError("Docker inspect output is oversized") + state = advance_observer_state( + _read_state(), parse_docker_inspect(completed.stdout), observed_at_seconds + ) + _atomic_write(STATE_FILE, json.dumps(state, separators=(",", ":")) + "\n", 0o600) + _atomic_write(METRICS_FILE, render_observer_metrics(state), 0o644) + except Exception: + _write_failure_best_effort(observed_at_seconds) + raise + + +if __name__ == "__main__": + try: + run_observer() + except Exception as error: + print(f"Listener container observer failed closed: {error}", file=sys.stderr) + raise SystemExit(1) from None diff --git a/scripts/process-account-mail-outbox.ts b/scripts/process-account-mail-outbox.ts new file mode 100644 index 00000000..3c5def12 --- /dev/null +++ b/scripts/process-account-mail-outbox.ts @@ -0,0 +1,86 @@ +import { rename, writeFile } from 'node:fs/promises'; + +import { prisma } from '../src/lib/db'; +import { assertAccountAuthorityDatabase } from '../src/lib/account/authority-db'; +import { + accountMailOutboxMetrics, + processAccountMailOutboxBatch, +} from '../src/lib/account/mail-outbox'; +import { cleanupAccountAuthorityRecords } from '../src/lib/account/maintenance'; +import { + accountMaintenanceDue, + accountWorkerStatus, + initialAccountMaintenanceState, + recordAccountMaintenanceAttempt, +} from '../src/lib/account/worker-health'; + +const WATCH = process.argv.includes('--watch'); +const HEARTBEAT = process.env.BEACON_ACCOUNT_MAIL_WORKER_HEARTBEAT_FILE?.trim() || + '/tmp/beacon-account-mail-worker-heartbeat'; +let stopping = false; +let consecutiveErrors = 0; +let lastSuccessAt: string | null = null; +let maintenanceState = initialAccountMaintenanceState(); +process.once('SIGTERM', () => { stopping = true; }); +process.once('SIGINT', () => { stopping = true; }); + +async function heartbeat(delivered: number) { + const metrics = await accountMailOutboxMetrics(); + const status = accountWorkerStatus(consecutiveErrors, maintenanceState); + const temporary = `${HEARTBEAT}.${process.pid}.tmp`; + await writeFile(temporary, JSON.stringify({ + status, at: new Date().toISOString(), delivered, + gitSha: process.env.BEACON_GIT_SHA ?? 'unknown', + ...metrics, + consecutiveErrors, + maintenanceStatus: maintenanceState.failed ? 'error' : 'ok', + lastSuccessAt, + }), { mode: 0o600 }); + await rename(temporary, HEARTBEAT); +} + +async function once() { + await assertAccountAuthorityDatabase(); + const now = Date.now(); + if (accountMaintenanceDue(maintenanceState, now)) { + try { + await cleanupAccountAuthorityRecords(); + maintenanceState = recordAccountMaintenanceAttempt(maintenanceState, now, true); + } catch { + maintenanceState = recordAccountMaintenanceAttempt(maintenanceState, now, false); + } + } + const batch = await processAccountMailOutboxBatch(50); + if (batch.failed > 0) consecutiveErrors += 1; + else if (batch.attempted > 0 || (await accountMailOutboxMetrics()).pendingCount === 0) { + consecutiveErrors = 0; + lastSuccessAt = new Date().toISOString(); + } + await heartbeat(batch.delivered); + return { ...batch, maintenanceFailed: maintenanceState.failed }; +} + +async function main() { + if (!WATCH) { + const batch = await once(); + process.stdout.write(`Account mail batch attempted=${batch.attempted} delivered=${batch.delivered} failed=${batch.failed}\n`); + if (batch.failed > 0 || batch.maintenanceFailed) process.exitCode = 1; + return; + } + let backoff = 1_000; + while (!stopping) { + try { + await once(); + backoff = 1_000; + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } catch { + await new Promise((resolve) => setTimeout(resolve, backoff)); + backoff = Math.min(backoff * 2, 30_000); + } + } +} + +main().finally(() => prisma.$disconnect()).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : 'Account mail outbox failed'}\n`); + process.exitCode = 1; +}); diff --git a/scripts/provision-account-authority.ts b/scripts/provision-account-authority.ts new file mode 100644 index 00000000..9e1c51f0 --- /dev/null +++ b/scripts/provision-account-authority.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto'; + +import { prisma } from '../src/lib/db'; +import { + accountOrigin, + accountStaticClientSecrets, + activeAccountStaticClients, +} from '../src/lib/account/config'; +import { hashAccountClientSecret } from '../src/lib/account/client-secret'; +import { accountAuth } from '../src/lib/account/auth'; + +async function main() { + const issuer = accountOrigin(); + if (process.env.BEACON_ACCOUNT_PROVISION_CONFIRM_ISSUER !== issuer) { + throw new Error('BEACON_ACCOUNT_PROVISION_CONFIRM_ISSUER must exactly match the configured issuer'); + } + const configured = new Map(accountStaticClientSecrets().map((client) => [client.clientId, client])); + const active = activeAccountStaticClients(); + for (const client of active) { + const secret = configured.get(client.clientId)?.clientSecret; + if (!secret || secret.length < 32) throw new Error(`${client.secretVariable} is missing or too short`); + } + await prisma.$transaction(async (transaction) => { + const marker = await transaction.beaconAccountAuthorityEnvironment.findUnique({ + where: { id: 'authority' }, select: { issuer: true }, + }); + if (marker && marker.issuer !== issuer) { + throw new Error('Refusing to provision an Account database claimed by another issuer'); + } + if (!marker) await transaction.beaconAccountAuthorityEnvironment.create({ + data: { id: 'authority', issuer }, + }); + for (const client of active) { + const secret = configured.get(client.clientId)!.clientSecret!; + await transaction.beaconOAuthClient.upsert({ + where: { clientId: client.clientId }, + create: { + id: randomUUID(), + clientId: client.clientId, + clientSecret: hashAccountClientSecret(secret), + disabled: false, + skipConsent: true, + enableEndSession: true, + subjectType: 'public', + scopes: ['openid', 'profile'], + name: client.clientId, + redirectUris: [client.redirectUri], + postLogoutRedirectUris: [client.postLogoutRedirectUri], + tokenEndpointAuthMethod: 'client_secret_basic', + grantTypes: ['authorization_code'], + responseTypes: ['code'], + public: false, + type: 'web', + requirePKCE: true, + contacts: [], + }, + update: { + clientSecret: hashAccountClientSecret(secret), + disabled: false, + skipConsent: true, + enableEndSession: true, + subjectType: 'public', + scopes: ['openid', 'profile'], + redirectUris: [client.redirectUri], + postLogoutRedirectUris: [client.postLogoutRedirectUri], + tokenEndpointAuthMethod: 'client_secret_basic', + grantTypes: ['authorization_code'], + responseTypes: ['code'], + public: false, + type: 'web', + requirePKCE: true, + }, + }); + } + await transaction.beaconOAuthClient.updateMany({ + where: { clientId: { notIn: active.map((client) => client.clientId) } }, + data: { disabled: true }, + }); + }); + await accountAuth().api.getJwks(); + process.stdout.write(`Account authority provisioned for ${issuer}; ${active.length} static clients active.\n`); +} + +main().finally(() => prisma.$disconnect()).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : 'Account provisioning failed'}\n`); + process.exitCode = 1; +}); diff --git a/scripts/verify-early-bird-contracts.py b/scripts/verify-early-bird-contracts.py new file mode 100644 index 00000000..97ff75a1 --- /dev/null +++ b/scripts/verify-early-bird-contracts.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Verify byte-exact copies of the canonical EarlyBird contracts.""" + +import hashlib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACTS = ( + ROOT / "contracts/early-bird-authority/v1", + ROOT / "contracts/early-bird-authority/v2", + ROOT / "contracts/early-bird-authority/v3", + ROOT / "contracts/early-bird-checkout/v2", + ROOT / "contracts/early-bird-membership/v1", + ROOT / "contracts/early-bird-membership/v2", + ROOT / "contracts/listener-checkout/v1", +) + + +def main() -> None: + verified = 0 + for directory in CONTRACTS: + manifest = directory / "SHA256SUMS" + for line in manifest.read_text(encoding="utf-8").splitlines(): + expected, filename = line.split(" ", 1) + actual = hashlib.sha256((directory / filename).read_bytes()).hexdigest() + if actual != expected: + raise SystemExit(f"EarlyBird contract hash mismatch: {directory.name}/{filename}") + verified += 1 + print(f"EarlyBird contracts byte-exact: {verified} files") + + +if __name__ == "__main__": + main() diff --git a/services/beacon-stream/.gitignore b/services/beacon-stream/.gitignore new file mode 100644 index 00000000..b6a9130b --- /dev/null +++ b/services/beacon-stream/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +coverage/ +media/artifacts/*/segments/ +media/artifacts/*/*.m4s +media/artifacts/*/*.ts +media/inventory.json diff --git a/services/beacon-stream/Dockerfile b/services/beacon-stream/Dockerfile new file mode 100644 index 00000000..c8bd981b --- /dev/null +++ b/services/beacon-stream/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine + +WORKDIR /srv/beacon-stream +COPY package.json ./ +COPY src ./src +COPY scripts ./scripts + +USER node +EXPOSE 8080 +CMD ["node", "src/server.mjs"] diff --git a/services/beacon-stream/README.md b/services/beacon-stream/README.md new file mode 100644 index 00000000..fa81ed64 --- /dev/null +++ b/services/beacon-stream/README.md @@ -0,0 +1,77 @@ +# Beacon stream origin + +This is a separate, resource-bounded HLS origin for EarlyBirds. It has no +LiveKit, `AudioContext`, event code, encoder, or media transform. The service +only serves an explicitly approved, already-packaged immutable artifact. + +## Approval and artifact boundary + +1. Record the read-only master checksum with `npm run inventory -- --master + /path/master.wav --output /safe/inventory.json`. +2. Nico performs the required A/B review outside this service. +3. An approved artifact is packaged externally into a new directory, with + `artifact.json` and immutable six-second segments. No segment can replace an + existing segment; a correction receives a new artifact ID. +4. Run `npm run verify-artifact -- --media-root /mounted/artifacts --artifact + approved-artifact-id` before startup. Startup repeats the checksum check. + +There is deliberately no command here to encode, resample, alter gain, or pick +a codec. It cannot make an audio candidate before an external approval exists. + +## Artifact metadata + +`artifact.json` accepts schema version 1 for legacy transport-stream packages +and schema version 2 for the current fMP4 package. Both require `approval.status` +(`APPROVED`), approval timestamp and review record, source master SHA-256, derivative SHA-256, +fixed `timing.epochUtc`, `segmentCount`, and a complete immutable file inventory. +Version 2 additionally records the initialization fragment, exact per-segment +durations and loop duration. The UTC epoch means that +every origin instance computes the same global position and restart never +changes a listener's wall-clock position. + +## Authorization contract + +The application validates the Listener entitlement, then signs a short-lived +playlist URL using HMAC SHA-256 over: + +```text +GET\n/v1/hls//live.m3u8\n +``` + +with `BEACON_STREAM_SIGNING_SECRET`. The service accepts only future expiry +timestamps no more than ten minutes ahead and compares signatures in constant +time. The manifest signs every individual segment URL because native HLS does +not inherit the playlist query string. Signatures, secrets, and complete signed +URLs are never logged. + +Chrome/Firefox fetch signed segments through `hls.js`, so the origin returns +CORS headers only for the exact comma-separated origins configured in +`BEACON_STREAM_ALLOWED_ORIGINS`. Use the Listener application origin here, not +the media origin itself; no wildcard is accepted or emitted. + +Public listener routes are `/healthz` and authenticated HLS paths. `/readyz` +and `/metrics` listen separately on a private metrics interface; they must not +be reverse-proxied on the listener origin. The origin emits low-cardinality +request/status, p95/p99 duration, served-byte and uptime metrics. + +## Verification and operations + +```bash +cd services/beacon-stream +npm test +npm run check +npm run canary # BEACON_CANARY_MANIFEST_URL is a fresh signed URL +npm run load -- --manifest "$SIGNED_MANIFEST" --clients 50 --rounds 20 +docker compose --env-file preview.env up --build +``` + +The small service-local canary verifies HLS syntax and retrieves a non-empty +signed segment. The deployed operations canary under `ops/early-birds` also +decodes six seconds through FFmpeg now that the delivery format is approved. +The load harness fetches manifests +and signed segments without decoding or altering media and reports error rate, +bytes and p95/p99 latency. It is a ramp harness, not proof of a 3,000-listener +production target. + +For an incident, `docker compose stop beacon-stream` stops this origin alone; +the event compose project and playlist bot are unrelated. diff --git a/services/beacon-stream/docker-compose.yml b/services/beacon-stream/docker-compose.yml new file mode 100644 index 00000000..36184cb3 --- /dev/null +++ b/services/beacon-stream/docker-compose.yml @@ -0,0 +1,52 @@ +# Isolated preview only. It neither joins nor replaces the event compose project. +services: + beacon-stream: + build: . + restart: unless-stopped + environment: + BEACON_STREAM_PORT: 8080 + BEACON_STREAM_MEDIA_ROOT: ${BEACON_STREAM_MEDIA_ROOT:?set_in_preview.env} + BEACON_STREAM_ARTIFACT_ID: ${BEACON_STREAM_ARTIFACT_ID:?set_in_preview.env} + BEACON_STREAM_PUBLIC_ORIGIN: ${BEACON_STREAM_PUBLIC_ORIGIN:?set_in_preview.env} + BEACON_STREAM_ALLOWED_ORIGINS: ${BEACON_STREAM_ALLOWED_ORIGINS:?set_in_preview.env} + BEACON_STREAM_SIGNING_SECRET: ${BEACON_STREAM_SIGNING_SECRET:?set_in_preview.env} + # Metrics and ready state are network-private; do not publish this port. + BEACON_STREAM_METRICS_PORT: 9090 + BEACON_STREAM_METRICS_BIND_HOST: 0.0.0.0 + volumes: + - ${BEACON_STREAM_ARTIFACTS_HOST_PATH:?set_in_preview.env}:/media/artifacts:ro + ports: + # Host nginx (or an explicit local test) is the sole listener boundary. + - "127.0.0.1:${BEACON_STREAM_HOST_PORT:-18080}:8080" + expose: + - "9090" + networks: + - stream_observability + # Docker does not publish host ports for a container attached only to an + # `internal` network. This separate edge bridge permits the explicit + # loopback binding above without exposing private readiness/metrics. + - stream_edge + - stream_control + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + reservations: + cpus: "0.25" + memory: 128M + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" + +networks: + stream_observability: + name: earlybirds_stream_observability + internal: true + stream_edge: + name: earlybirds_stream_edge + stream_control: + name: earlybirds_stream_control_internal + internal: true diff --git a/services/beacon-stream/media/artifacts/README.md b/services/beacon-stream/media/artifacts/README.md new file mode 100644 index 00000000..364ea3a1 --- /dev/null +++ b/services/beacon-stream/media/artifacts/README.md @@ -0,0 +1,21 @@ +# Approved delivery artifacts + +This directory contains metadata only in Git. Media bytes live on the host or +object storage and are mounted read-only at runtime. + +An artifact directory is eligible for the origin only when its `artifact.json` +has an explicit, recorded approval and all referenced immutable six-second +segments pass `scripts/verify-artifact.mjs`. The approval must happen after the +audio A/B review; this repository deliberately contains no encoder command, +codec choice, or sample-rate/channel/gain transform. + +`artifact.json` must provide: + +- an immutable source master SHA-256; +- an approved derivative SHA-256 and audio review record; +- fixed UTC epoch, six-second segment duration and a finite segment count; +- a SHA-256 inventory for every segment under `segments/`. + +Segments are never replaced in place. Corrections create a new artifact ID and +a new immutable directory. The running origin receives only the selected +artifact directory as a read-only mount. diff --git a/services/beacon-stream/media/inventory.example.json b/services/beacon-stream/media/inventory.example.json new file mode 100644 index 00000000..6b6a7cd5 --- /dev/null +++ b/services/beacon-stream/media/inventory.example.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-08-06T00:00:00.000Z", + "master": { + "immutable": true, + "path": "/mnt/beacon-data/beacon-records/luz_de_manana_20260624-155633.wav", + "sha256": "RECORD_WITH_scripts/inventory.mjs_BEFORE_ANY_DERIVATIVE_WORK", + "bytes": 0 + }, + "notes": [ + "This is an example, not a source of truth or an audio artifact.", + "The master is read only. It is never copied, encoded, renamed or overwritten by this service." + ] +} diff --git a/services/beacon-stream/package.json b/services/beacon-stream/package.json new file mode 100644 index 00000000..4c0f9d85 --- /dev/null +++ b/services/beacon-stream/package.json @@ -0,0 +1,19 @@ +{ + "name": "harmonic-beacon-stream-origin", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Isolated, deterministic HLS origin for the approved EarlyBirds Beacon artifact", + "scripts": { + "start": "node src/server.mjs", + "test": "node --test test/*.test.mjs", + "check": "node --check src/server.mjs && node --check src/inventory.mjs && node --check scripts/inventory.mjs && node --check scripts/build-artifact.mjs && node --check scripts/verify-artifact.mjs", + "inventory": "node scripts/inventory.mjs", + "verify-artifact": "node scripts/verify-artifact.mjs", + "canary": "node scripts/canary.mjs", + "load": "node scripts/load-hls.mjs" + }, + "engines": { + "node": ">=22" + } +} diff --git a/services/beacon-stream/preview.env.example b/services/beacon-stream/preview.env.example new file mode 100644 index 00000000..f8be866d --- /dev/null +++ b/services/beacon-stream/preview.env.example @@ -0,0 +1,8 @@ +# Copy outside Git with mode 0600. Do not put a real secret in this repository. +BEACON_STREAM_ARTIFACTS_HOST_PATH=/mnt/beacon-data/earlybirds-artifacts +BEACON_STREAM_MEDIA_ROOT=/media/artifacts +BEACON_STREAM_ARTIFACT_ID=approved-artifact-id +BEACON_STREAM_PUBLIC_ORIGIN=https://stream.harmonicbeacon.com +BEACON_STREAM_ALLOWED_ORIGINS=https://earlybirds-staging.harmonicbeacon.com +BEACON_STREAM_SIGNING_SECRET=replace-with-a-random-32-character-minimum-secret +BEACON_STREAM_HOST_PORT=18080 diff --git a/services/beacon-stream/scripts/build-artifact.mjs b/services/beacon-stream/scripts/build-artifact.mjs new file mode 100644 index 00000000..5fa3847f --- /dev/null +++ b/services/beacon-stream/scripts/build-artifact.mjs @@ -0,0 +1,84 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { sha256File } from '../src/inventory.mjs'; + +function argument(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function required(name) { + const value = argument(name); + if (!value) throw new Error(`missing ${name}`); + return value; +} + +const root = path.resolve(required('--artifact-root')); +const artifactId = required('--artifact-id'); +const derivative = path.resolve(required('--derivative')); +const masterSha256 = required('--master-sha256'); +const epochUtc = required('--epoch-utc'); +const approvedAt = required('--approved-at'); +const reviewRecord = required('--review-record'); +const playlist = await fs.readFile(path.join(root, 'package.m3u8'), 'utf8'); +const lines = playlist.split(/\r?\n/); +const mapUri = lines.find((line) => line.startsWith('#EXT-X-MAP:'))?.match(/URI="([^"]+)"/)?.[1]; +if (!mapUri) throw new Error('fMP4 initialization map is required'); + +function mediaName(uri) { + const normalized = uri.replace(/^segments\//, ''); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) throw new Error(`unsafe media URI ${uri}`); + return normalized; +} + +async function inventory(file, durationSeconds) { + const filePath = path.join(root, 'segments', file); + const stat = await fs.stat(filePath); + if (!stat.isFile() || stat.size < 1) throw new Error(`invalid media file ${file}`); + return { + file, + ...(durationSeconds === undefined ? {} : { durationSeconds }), + bytes: stat.size, + sha256: await sha256File(filePath), + }; +} + +const segmentInputs = []; +for (let index = 0; index < lines.length; index += 1) { + if (!lines[index].startsWith('#EXTINF:')) continue; + const durationSeconds = Number(lines[index].slice('#EXTINF:'.length).split(',')[0]); + const uri = lines[index + 1]; + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || !uri || uri.startsWith('#')) { + throw new Error('invalid HLS media entry'); + } + segmentInputs.push({ file: mediaName(uri), durationSeconds }); +} +if (segmentInputs.length < 1) throw new Error('at least one media segment is required'); + +const segments = []; +for (const segment of segmentInputs) segments.push(await inventory(segment.file, segment.durationSeconds)); +const loopDurationSeconds = segments.reduce((sum, segment) => sum + segment.durationSeconds, 0); +const metadata = { + schemaVersion: 2, + artifactId, + approval: { status: 'APPROVED', approvedAt, reviewRecord }, + source: { masterSha256 }, + derivative: { sha256: await sha256File(derivative) }, + timing: { + epochUtc, + targetSegmentDurationSeconds: 6, + segmentCount: segments.length, + loopDurationSeconds, + }, + initialization: await inventory(mediaName(mapUri)), + segments, +}; +const output = path.join(root, 'artifact.json'); +const temporary = `${output}.${process.pid}.tmp`; +// Approval metadata and hashes are intentionally non-secret. The origin runs +// as an unprivileged container user over a read-only media mount, so the +// inventory must remain readable when packaging was performed by root. +await fs.writeFile(temporary, `${JSON.stringify(metadata, null, 2)}\n`, { flag: 'wx', mode: 0o644 }); +await fs.rename(temporary, output); +console.log(`artifact inventory written: ${segments.length} segments, ${loopDurationSeconds.toFixed(6)} seconds`); diff --git a/services/beacon-stream/scripts/canary.mjs b/services/beacon-stream/scripts/canary.mjs new file mode 100644 index 00000000..cf7f7f31 --- /dev/null +++ b/services/beacon-stream/scripts/canary.mjs @@ -0,0 +1,30 @@ +function environment(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +const manifestUrl = environment('BEACON_CANARY_MANIFEST_URL'); +const timeoutMs = Number(process.env.BEACON_CANARY_TIMEOUT_MS ?? 10_000); +const controller = new AbortController(); +const timer = setTimeout(() => controller.abort(), timeoutMs); + +try { + const manifestResponse = await fetch(manifestUrl, { signal: controller.signal, cache: 'no-store' }); + if (!manifestResponse.ok) throw new Error(`manifest HTTP ${manifestResponse.status}`); + const manifest = await manifestResponse.text(); + if (!manifest.startsWith('#EXTM3U\n')) throw new Error('manifest is not HLS'); + const segmentUrl = manifest.split('\n').find((line) => /^https?:\/\//.test(line)); + if (!segmentUrl) throw new Error('manifest has no signed segment URL'); + const segmentResponse = await fetch(segmentUrl, { signal: controller.signal, cache: 'no-store' }); + if (!segmentResponse.ok) throw new Error(`segment HTTP ${segmentResponse.status}`); + const bytes = (await segmentResponse.arrayBuffer()).byteLength; + if (!bytes) throw new Error('segment is empty'); + console.log(JSON.stringify({ status: 'ok', segmentBytes: bytes })); +} catch (error) { + // The URL may contain an HMAC; never print it from an operator canary. + console.error(JSON.stringify({ status: 'failed', reason: error.name === 'AbortError' ? 'timeout' : error.message })); + process.exitCode = 1; +} finally { + clearTimeout(timer); +} diff --git a/services/beacon-stream/scripts/inventory.mjs b/services/beacon-stream/scripts/inventory.mjs new file mode 100644 index 00000000..dab57542 --- /dev/null +++ b/services/beacon-stream/scripts/inventory.mjs @@ -0,0 +1,38 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { sha256File } from '../src/inventory.mjs'; + +function argument(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const master = argument('--master'); +const output = argument('--output'); +if (!master || !output) { + console.error('usage: node scripts/inventory.mjs --master /read-only/master.wav --output media/inventory.json'); + process.exit(2); +} + +const masterPath = path.resolve(master); +const outputPath = path.resolve(output); +if (masterPath === outputPath) { + throw new Error('refusing to write an inventory over the master'); +} +const stat = await fs.stat(masterPath); +if (!stat.isFile()) throw new Error('master must be a regular file'); +const inventory = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + master: { + immutable: true, + path: masterPath, + sha256: await sha256File(masterPath), + bytes: stat.size, + }, +}; +await fs.mkdir(path.dirname(outputPath), { recursive: true }); +const temporary = `${outputPath}.${process.pid}.tmp`; +await fs.writeFile(temporary, `${JSON.stringify(inventory, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); +await fs.rename(temporary, outputPath); +console.log(`inventory written for ${inventory.master.bytes} immutable bytes`); diff --git a/services/beacon-stream/scripts/load-hls.mjs b/services/beacon-stream/scripts/load-hls.mjs new file mode 100644 index 00000000..8ad639a2 --- /dev/null +++ b/services/beacon-stream/scripts/load-hls.mjs @@ -0,0 +1,70 @@ +function argument(name, fallback) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : fallback; +} +function positive(name, fallback) { + const value = Number(argument(name, fallback)); + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`); + return value; +} + +const manifestUrl = argument('--manifest'); +if (!manifestUrl) throw new Error('usage: node scripts/load-hls.mjs --manifest [--clients 20] [--rounds 10] [--max-error-rate 0.01]'); +const clients = positive('--clients', '20'); +const rounds = positive('--rounds', '10'); +const maxErrorRate = Number(argument('--max-error-rate', '0.01')); +if (!(maxErrorRate >= 0 && maxErrorRate <= 1)) throw new Error('--max-error-rate must be between 0 and 1'); + +const startedAt = performance.now(); +let requests = 0; +let failures = 0; +let bytes = 0; +const durations = []; +async function request(url) { + const start = performance.now(); + requests += 1; + try { + const response = await fetch(url, { cache: 'no-store' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await response.arrayBuffer(); + if (!body.byteLength) throw new Error('empty response'); + bytes += body.byteLength; + } catch { + failures += 1; + } finally { + durations.push(performance.now() - start); + } +} +async function client() { + for (let round = 0; round < rounds; round += 1) { + const start = performance.now(); + requests += 1; + try { + const response = await fetch(manifestUrl, { cache: 'no-store' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const manifest = await response.text(); + const urls = manifest.split('\n').filter((line) => /^https?:\/\//.test(line)); + if (!urls.length) throw new Error('manifest without segment URI'); + await request(urls[urls.length - 1]); + } catch { + failures += 1; + } finally { + durations.push(performance.now() - start); + } + } +} +await Promise.all(Array.from({ length: clients }, client)); +durations.sort((a, b) => a - b); +const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : 0; +const report = { + clients, + rounds, + requests, + failures, + errorRate: requests ? failures / requests : 1, + bytes, + elapsedSeconds: (performance.now() - startedAt) / 1000, + requestDurationMs: { p95: percentile(0.95), p99: percentile(0.99) }, +}; +console.log(JSON.stringify(report)); +if (report.errorRate > maxErrorRate) process.exitCode = 1; diff --git a/services/beacon-stream/scripts/verify-artifact.mjs b/services/beacon-stream/scripts/verify-artifact.mjs new file mode 100644 index 00000000..a9538bb0 --- /dev/null +++ b/services/beacon-stream/scripts/verify-artifact.mjs @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { loadArtifact, verifyArtifactFiles } from '../src/artifact.mjs'; + +function argument(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const mediaRoot = argument('--media-root'); +const artifactId = argument('--artifact'); +if (!mediaRoot || !artifactId) { + console.error('usage: node scripts/verify-artifact.mjs --media-root /mounted/artifacts --artifact approved-artifact-id'); + process.exit(2); +} +const { root, metadata } = await loadArtifact({ mediaRoot: path.resolve(mediaRoot), artifactId }); +await verifyArtifactFiles({ root, metadata }); +console.log(`verified approved artifact ${metadata.artifactId}: ${metadata.segments.length} immutable segments`); diff --git a/services/beacon-stream/src/artifact.mjs b/services/beacon-stream/src/artifact.mjs new file mode 100644 index 00000000..37133e14 --- /dev/null +++ b/services/beacon-stream/src/artifact.mjs @@ -0,0 +1,97 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const ARTIFACT_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const UTC_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +export function validateArtifact(raw) { + assert(raw && [1, 2].includes(raw.schemaVersion), 'artifact schemaVersion must be 1 or 2'); + assert(typeof raw.artifactId === 'string' && ARTIFACT_ID.test(raw.artifactId), 'invalid artifactId'); + assert(raw.approval?.status === 'APPROVED', 'artifact is not explicitly approved for delivery'); + assert(typeof raw.approval?.approvedAt === 'string' && Number.isFinite(Date.parse(raw.approval.approvedAt)), 'approval.approvedAt is required'); + assert(typeof raw.approval?.reviewRecord === 'string' && raw.approval.reviewRecord.length > 0, 'approval.reviewRecord is required'); + assert(typeof raw.source?.masterSha256 === 'string' && SHA256.test(raw.source.masterSha256), 'source.masterSha256 must be SHA-256'); + assert(typeof raw.derivative?.sha256 === 'string' && SHA256.test(raw.derivative.sha256), 'derivative.sha256 must be SHA-256'); + assert(typeof raw.timing?.epochUtc === 'string' && UTC_TIMESTAMP.test(raw.timing.epochUtc) && Number.isFinite(Date.parse(raw.timing.epochUtc)), 'timing.epochUtc must be an explicit UTC timestamp ending in Z'); + if (raw.schemaVersion === 1) { + assert(raw.timing?.segmentDurationSeconds === 6, 'only immutable six-second segments are supported'); + } else { + assert(raw.timing?.targetSegmentDurationSeconds === 6, 'only immutable six-second target segments are supported'); + assert(Number.isFinite(raw.timing?.loopDurationSeconds) && raw.timing.loopDurationSeconds > 0, 'timing.loopDurationSeconds is required'); + assert(raw.initialization && typeof raw.initialization === 'object', 'initialization metadata is required for schemaVersion 2'); + } + assert(Number.isSafeInteger(raw.timing?.segmentCount) && raw.timing.segmentCount > 0, 'timing.segmentCount must be positive'); + assert(Array.isArray(raw.segments) && raw.segments.length === raw.timing.segmentCount, 'one segment inventory entry is required per segment'); + + const seen = new Set(); + const validateFile = (item, label) => { + assert(typeof item?.file === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(item.file), `invalid ${label} filename`); + assert(!seen.has(item.file), `duplicate media filename ${item.file}`); + seen.add(item.file); + assert(typeof item.sha256 === 'string' && SHA256.test(item.sha256), `${label} ${item.file} SHA-256 is required`); + assert(Number.isSafeInteger(item.bytes) && item.bytes > 0, `${label} ${item.file} byte count is required`); + }; + if (raw.schemaVersion === 2) validateFile(raw.initialization, 'initialization'); + for (const segment of raw.segments) { + validateFile(segment, 'segment'); + if (raw.schemaVersion === 2) { + assert(Number.isFinite(segment.durationSeconds) && segment.durationSeconds > 0 && segment.durationSeconds <= 6.1, `segment ${segment.file} duration is invalid`); + } + } + if (raw.schemaVersion === 2) { + const measuredLoopDuration = raw.segments.reduce((sum, segment) => sum + segment.durationSeconds, 0); + assert(Math.abs(measuredLoopDuration - raw.timing.loopDurationSeconds) < 0.001, 'segment durations do not match timing.loopDurationSeconds'); + } + + const segments = raw.segments.map((segment) => ({ + ...segment, + durationSeconds: raw.schemaVersion === 1 ? raw.timing.segmentDurationSeconds : segment.durationSeconds, + })); + const mediaFiles = raw.schemaVersion === 2 ? [raw.initialization, ...segments] : segments; + let elapsedSeconds = 0; + const segmentStartsSeconds = segments.map((segment) => { + const start = elapsedSeconds; + elapsedSeconds += segment.durationSeconds; + return start; + }); + + return Object.freeze({ + ...raw, + segments, + epochMs: Date.parse(raw.timing.epochUtc), + loopDurationSeconds: raw.schemaVersion === 1 + ? raw.timing.segmentDurationSeconds * raw.timing.segmentCount + : raw.timing.loopDurationSeconds, + segmentStartsSeconds, + segmentByFile: new Map(mediaFiles.map((segment, index) => [segment.file, { ...segment, index }])), + }); +} + +export async function loadArtifact({ mediaRoot, artifactId }) { + if (!ARTIFACT_ID.test(artifactId)) throw new Error('invalid artifactId'); + const root = path.resolve(mediaRoot, artifactId); + const metadata = validateArtifact(JSON.parse(await fs.readFile(path.join(root, 'artifact.json'), 'utf8'))); + assert(metadata.artifactId === artifactId, 'artifact ID does not match its directory'); + return { root, metadata }; +} + +export async function verifyArtifactFiles({ root, metadata }) { + const segmentsRoot = path.resolve(root, 'segments'); + const mediaFiles = metadata.initialization + ? [metadata.initialization, ...metadata.segments] + : metadata.segments; + for (const segment of mediaFiles) { + const filePath = path.resolve(segmentsRoot, segment.file); + if (!filePath.startsWith(`${segmentsRoot}${path.sep}`)) throw new Error(`unsafe segment path ${segment.file}`); + const bytes = await fs.readFile(filePath); + const sha256 = crypto.createHash('sha256').update(bytes).digest('hex'); + assert(bytes.byteLength === segment.bytes, `byte count changed for ${segment.file}`); + assert(sha256 === segment.sha256, `checksum changed for ${segment.file}`); + } +} diff --git a/services/beacon-stream/src/auth.mjs b/services/beacon-stream/src/auth.mjs new file mode 100644 index 00000000..e7e96320 --- /dev/null +++ b/services/beacon-stream/src/auth.mjs @@ -0,0 +1,46 @@ +import crypto from 'node:crypto'; + +export const DEFAULT_MAX_TOKEN_TTL_SECONDS = 10 * 60; + +function canonicalRequest(method, pathname, expiresAt) { + return `${method.toUpperCase()}\n${pathname}\n${expiresAt}`; +} + +export function signPath({ secret, method = 'GET', pathname, expiresAt }) { + if (!secret || secret.length < 32) { + throw new Error('BEACON_STREAM_SIGNING_SECRET must contain at least 32 characters'); + } + if (!Number.isSafeInteger(expiresAt)) { + throw new Error('expiresAt must be a Unix timestamp in whole seconds'); + } + + return crypto.createHmac('sha256', secret) + .update(canonicalRequest(method, pathname, expiresAt)) + .digest('base64url'); +} + +export function verifySignedPath({ + secret, + method = 'GET', + pathname, + expiresAt, + signature, + now = Math.floor(Date.now() / 1000), + maxTtlSeconds = DEFAULT_MAX_TOKEN_TTL_SECONDS, +}) { + if (!Number.isSafeInteger(expiresAt) || !signature || typeof signature !== 'string') return false; + // Tokens cannot be minted arbitrarily far ahead, limiting replay if a URL leaks. + if (expiresAt <= now || expiresAt > now + maxTtlSeconds) return false; + + const expectedBytes = Buffer.from(signPath({ secret, method, pathname, expiresAt })); + const suppliedBytes = Buffer.from(signature); + return expectedBytes.length === suppliedBytes.length + && crypto.timingSafeEqual(expectedBytes, suppliedBytes); +} + +export function signedUrl({ origin, secret, pathname, expiresAt, method = 'GET' }) { + const url = new URL(pathname, origin); + url.searchParams.set('exp', String(expiresAt)); + url.searchParams.set('sig', signPath({ secret, method, pathname, expiresAt })); + return url.toString(); +} diff --git a/services/beacon-stream/src/control-auth.mjs b/services/beacon-stream/src/control-auth.mjs new file mode 100644 index 00000000..d03d6f43 --- /dev/null +++ b/services/beacon-stream/src/control-auth.mjs @@ -0,0 +1,23 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; + +export const CONTROL_CLOCK_SKEW_SECONDS = 30; + +export function controlBodyHash(body) { + return createHash('sha256').update(body).digest('hex'); +} + +export function signControlRequest({ secret, method = 'PUT', pathname, timestamp, body }) { + const canonical = `${method}\n${pathname}\n${controlBodyHash(body)}\n${timestamp}`; + return createHmac('sha256', secret).update(canonical).digest('base64url'); +} + +export function verifyControlRequest({ secret, method, pathname, timestamp, body, signature, nowMs = Date.now() }) { + if (!/^\d{10}$/.test(String(timestamp)) || !/^[A-Za-z0-9_-]{43}$/.test(String(signature))) return false; + const timestampSeconds = Number(timestamp); + if (Math.abs(Math.floor(nowMs / 1000) - timestampSeconds) > CONTROL_CLOCK_SKEW_SECONDS) return false; + const expected = signControlRequest({ secret, method, pathname, timestamp: timestampSeconds, body }); + const suppliedBuffer = Buffer.from(signature, 'utf8'); + const expectedBuffer = Buffer.from(expected, 'utf8'); + return suppliedBuffer.length === expectedBuffer.length && timingSafeEqual(suppliedBuffer, expectedBuffer); +} + diff --git a/services/beacon-stream/src/inventory.mjs b/services/beacon-stream/src/inventory.mjs new file mode 100644 index 00000000..63f31de3 --- /dev/null +++ b/services/beacon-stream/src/inventory.mjs @@ -0,0 +1,13 @@ +import crypto from 'node:crypto'; +import { createReadStream } from 'node:fs'; + +/** + * Hash a media file incrementally. The immutable master can be several GB, so + * it must never be materialized as one Buffer merely to inventory it. + */ +export async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + const stream = createReadStream(filePath); + for await (const chunk of stream) hash.update(chunk); + return hash.digest('hex'); +} diff --git a/services/beacon-stream/src/manifest.mjs b/services/beacon-stream/src/manifest.mjs new file mode 100644 index 00000000..67ca9560 --- /dev/null +++ b/services/beacon-stream/src/manifest.mjs @@ -0,0 +1,91 @@ +import { signedUrl } from './auth.mjs'; + +// Listener values continuity over realtime latency. Fifty six-second entries +// retain a five-minute recovery window while the player deliberately starts +// three minutes behind the edge. This is still a small, deterministic playlist +// and avoids making one delayed request an audible interruption. +export const WINDOW_SEGMENTS = 50; + +export function currentSequence(metadata, nowMs = Date.now()) { + const elapsedSeconds = Math.max(0, (nowMs - metadata.epochMs) / 1000); + if (metadata.schemaVersion === 1) { + return Math.floor(elapsedSeconds / metadata.timing.segmentDurationSeconds); + } + const cycle = Math.floor(elapsedSeconds / metadata.loopDurationSeconds); + const position = elapsedSeconds - cycle * metadata.loopDurationSeconds; + const index = metadata.segments.findIndex((segment, candidate) => ( + position < metadata.segmentStartsSeconds[candidate] + segment.durationSeconds + )); + return cycle * metadata.timing.segmentCount + Math.max(0, index); +} + +function segmentAtSequence(metadata, sequence) { + const count = metadata.timing.segmentCount; + const index = sequence % count; + const cycle = Math.floor(sequence / count); + return { + index, + segment: metadata.segments[index], + startsAtMs: metadata.epochMs + + (cycle * metadata.loopDurationSeconds + metadata.segmentStartsSeconds[index]) * 1000, + }; +} + +export function renderManifest({ + metadata, + origin, + secret, + nowMs = Date.now(), + tokenTtlSeconds = 120, + authorizationExpiresAtSeconds = Number.POSITIVE_INFINITY, + windowSegments = WINDOW_SEGMENTS, + mediaAuthorizationQuery = null, +}) { + if (!Number.isSafeInteger(windowSegments) || windowSegments < 6 || windowSegments > 150) { + throw new Error('windowSegments must be an integer between 6 and 150'); + } + const edgeSequence = currentSequence(metadata, nowMs); + const firstSequence = Math.max(0, edgeSequence - (windowSegments - 1)); + const expiresAt = Math.min( + Math.floor(nowMs / 1000) + tokenTtlSeconds, + authorizationExpiresAtSeconds, + ); + const targetDuration = Math.ceil(Math.max(...metadata.segments.map((segment) => segment.durationSeconds))); + const discontinuitiesRemoved = Math.floor( + Math.max(0, firstSequence - 1) / metadata.timing.segmentCount, + ); + const lines = [ + '#EXTM3U', + '#EXT-X-VERSION:7', + `#EXT-X-TARGETDURATION:${targetDuration}`, + `#EXT-X-DISCONTINUITY-SEQUENCE:${discontinuitiesRemoved}`, + `#EXT-X-MEDIA-SEQUENCE:${firstSequence}`, + '#EXT-X-INDEPENDENT-SEGMENTS', + ]; + + const mediaUrl = (pathname) => { + if (mediaAuthorizationQuery) { + const url = new URL(pathname, origin); + url.searchParams.set('grantId', mediaAuthorizationQuery.grantId); + url.searchParams.set('grant', mediaAuthorizationQuery.grant); + return url.toString(); + } + return signedUrl({ origin, secret, pathname, expiresAt }); + }; + + if (metadata.initialization) { + const pathname = `/v1/hls/${metadata.artifactId}/segments/${encodeURIComponent(metadata.initialization.file)}`; + lines.push(`#EXT-X-MAP:URI="${mediaUrl(pathname)}"`); + } + + for (let sequence = firstSequence; sequence <= edgeSequence; sequence += 1) { + const { index, segment, startsAtMs } = segmentAtSequence(metadata, sequence); + if (index === 0 && sequence !== 0) lines.push('#EXT-X-DISCONTINUITY'); + lines.push(`#EXT-X-PROGRAM-DATE-TIME:${new Date(startsAtMs).toISOString()}`); + lines.push(`#EXTINF:${segment.durationSeconds.toFixed(6)},`); + const pathname = `/v1/hls/${metadata.artifactId}/segments/${encodeURIComponent(segment.file)}`; + // Native HLS does not inherit the manifest query string. Every URI is signed. + lines.push(mediaUrl(pathname)); + } + return `${lines.join('\n')}\n`; +} diff --git a/services/beacon-stream/src/media-grants.mjs b/services/beacon-stream/src/media-grants.mjs new file mode 100644 index 00000000..660f7342 --- /dev/null +++ b/services/beacon-stream/src/media-grants.mjs @@ -0,0 +1,71 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +export const MEDIA_GRANT_ID_PATTERN = /^[a-f0-9]{64}$/; +export const MEDIA_GRANT_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +export const MEDIA_GRANT_MAX_TTL_MS = 4 * 60 * 1000; +export const MEDIA_GRANT_MAX_ENTRIES = 20_000; + +function tokenHash(token) { + return createHash('sha256').update(token, 'utf8').digest(); +} + +export class MediaGrantRegistry { + #entries = new Map(); + #now; + #maxEntries; + + constructor({ now = () => Date.now(), maxEntries = MEDIA_GRANT_MAX_ENTRIES } = {}) { + this.#now = now; + this.#maxEntries = maxEntries; + } + + pruneExpired(nowMs = this.#now()) { + for (const [id, entry] of this.#entries) { + if (entry.expiresAtMs <= nowMs) this.#entries.delete(id); + } + } + + upsert({ id, tokenSha256, expiresAtMs }) { + const nowMs = this.#now(); + this.pruneExpired(nowMs); + if (!MEDIA_GRANT_ID_PATTERN.test(id) + || !/^[a-f0-9]{64}$/.test(tokenSha256) + || !Number.isSafeInteger(expiresAtMs) + || expiresAtMs <= nowMs + || expiresAtMs > nowMs + MEDIA_GRANT_MAX_TTL_MS) { + return { ok: false, reason: 'invalid' }; + } + const existing = this.#entries.get(id); + if (existing && existing.tokenSha256 !== tokenSha256) { + return { ok: false, reason: 'conflict' }; + } + if (!existing && this.#entries.size >= this.#maxEntries) { + return { ok: false, reason: 'capacity' }; + } + this.#entries.set(id, { + tokenSha256, + expiresAtMs: Math.max(existing?.expiresAtMs ?? 0, expiresAtMs), + }); + return { ok: true }; + } + + authorize({ id, token }) { + if (!MEDIA_GRANT_ID_PATTERN.test(id) || !MEDIA_GRANT_TOKEN_PATTERN.test(token)) return false; + const entry = this.#entries.get(id); + if (!entry) return false; + const nowMs = this.#now(); + if (entry.expiresAtMs <= nowMs) { + this.#entries.delete(id); + return false; + } + const supplied = tokenHash(token); + const expected = Buffer.from(entry.tokenSha256, 'hex'); + return supplied.length === expected.length && timingSafeEqual(supplied, expected); + } + + get size() { + this.pruneExpired(); + return this.#entries.size; + } +} + diff --git a/services/beacon-stream/src/metrics.mjs b/services/beacon-stream/src/metrics.mjs new file mode 100644 index 00000000..38f05b71 --- /dev/null +++ b/services/beacon-stream/src/metrics.mjs @@ -0,0 +1,42 @@ +export class Metrics { + constructor() { + this.startedAt = Date.now(); + this.requests = new Map(); + this.bytesServed = 0; + this.requestDurationSeconds = []; + } + + observe({ route, status, bytes = 0, durationMs }) { + const key = `${route}|${status}`; + this.requests.set(key, (this.requests.get(key) ?? 0) + 1); + this.bytesServed += bytes; + this.requestDurationSeconds.push(durationMs / 1000); + if (this.requestDurationSeconds.length > 10_000) this.requestDurationSeconds.shift(); + } + + render() { + const lines = [ + '# HELP beacon_stream_uptime_seconds Process uptime in seconds.', + '# TYPE beacon_stream_uptime_seconds gauge', + `beacon_stream_uptime_seconds ${(Date.now() - this.startedAt) / 1000}`, + '# HELP beacon_stream_http_requests_total HTTP requests handled by stable route and status.', + '# TYPE beacon_stream_http_requests_total counter', + ]; + for (const [key, count] of [...this.requests.entries()].sort()) { + const [route, status] = key.split('|'); + lines.push(`beacon_stream_http_requests_total{route="${route}",status="${status}"} ${count}`); + } + const values = [...this.requestDurationSeconds].sort((a, b) => a - b); + const quantile = (q) => values.length ? values[Math.min(values.length - 1, Math.floor(values.length * q))] : 0; + lines.push( + '# HELP beacon_stream_http_request_duration_seconds Recent in-process request duration estimates.', + '# TYPE beacon_stream_http_request_duration_seconds gauge', + `beacon_stream_http_request_duration_seconds{quantile="0.95"} ${quantile(0.95)}`, + `beacon_stream_http_request_duration_seconds{quantile="0.99"} ${quantile(0.99)}`, + '# HELP beacon_stream_bytes_served_total Authenticated segment bytes served.', + '# TYPE beacon_stream_bytes_served_total counter', + `beacon_stream_bytes_served_total ${this.bytesServed}`, + ); + return `${lines.join('\n')}\n`; + } +} diff --git a/services/beacon-stream/src/server.mjs b/services/beacon-stream/src/server.mjs new file mode 100644 index 00000000..e34e8f58 --- /dev/null +++ b/services/beacon-stream/src/server.mjs @@ -0,0 +1,282 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { loadArtifact, verifyArtifactFiles } from './artifact.mjs'; +import { verifySignedPath } from './auth.mjs'; +import { verifyControlRequest } from './control-auth.mjs'; +import { renderManifest } from './manifest.mjs'; +import { MediaGrantRegistry, MEDIA_GRANT_ID_PATTERN } from './media-grants.mjs'; +import { Metrics } from './metrics.mjs'; + +function send(response, status, body = '', headers = {}) { + response.writeHead(status, { + 'X-Content-Type-Options': 'nosniff', + ...headers, + }); + response.end(body); +} + +function tokenFrom(url) { + const expiresAt = Number(url.searchParams.get('exp')); + return { expiresAt, signature: url.searchParams.get('sig') }; +} + +export function parseAllowedOrigins(value) { + const origins = new Set(); + for (const item of String(value ?? '').split(',')) { + const candidate = item.trim(); + if (!candidate) continue; + const parsed = new URL(candidate); + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.username + || parsed.password + || parsed.pathname !== '/' + || parsed.search + || parsed.hash) { + throw new Error('BEACON_STREAM_ALLOWED_ORIGINS contains an invalid origin'); + } + origins.add(parsed.origin); + } + if (origins.size === 0) { + throw new Error('BEACON_STREAM_ALLOWED_ORIGINS must contain at least one origin'); + } + return origins; +} + +function crossOriginHeaders(request, allowedOrigins) { + const origin = request.headers.origin; + if (!origin || !allowedOrigins.has(origin)) return {}; + return { + 'Access-Control-Allow-Origin': origin, + Vary: 'Origin', + }; +} + +function authorized({ request, url, secret }) { + return verifySignedPath({ + secret, + // HEAD is an HTTP metadata view of the same signed GET resource. Browsers + // may probe media before their first GET, so validate it against GET rather + // than requiring a second signature that the manifest cannot carry. + method: request.method === 'HEAD' ? 'GET' : request.method, + pathname: url.pathname, + ...tokenFrom(url), + }); +} + +function mediaGrantFrom(url) { + return { + id: url.searchParams.get('grantId') ?? '', + token: url.searchParams.get('grant') ?? '', + }; +} + +function authorizedMedia({ request, url, secret, mediaGrants }) { + const grant = mediaGrantFrom(url); + if (grant.id || grant.token) return mediaGrants.authorize(grant); + return authorized({ request, url, secret }); +} + +async function readBoundedBody(request, maximumBytes = 1024) { + const chunks = []; + let bytes = 0; + for await (const chunk of request) { + bytes += chunk.length; + if (bytes > maximumBytes) throw new Error('body_too_large'); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function routeName(pathname) { + if (pathname === '/healthz') return 'health'; + if (pathname.startsWith('/internal/v1/listener/media-grants/')) return 'grant_control'; + if (pathname.endsWith('/live.m3u8')) return 'manifest'; + if (pathname.includes('/segments/')) return 'segment'; + return 'unknown'; +} + +function mediaContentType(file) { + if (file.endsWith('.mp4')) return 'video/mp4'; + if (file.endsWith('.m4s')) return 'video/iso.segment'; + return 'application/octet-stream'; +} + +export function createPublicHandler({ artifactRoot, metadata, publicOrigin, signingSecret, allowedOrigins = new Set(), metrics = new Metrics(), now = () => Date.now(), mediaGrants = new MediaGrantRegistry({ now }) }) { + const manifestPath = `/v1/hls/${metadata.artifactId}/live.m3u8`; + const segmentPrefix = `/v1/hls/${metadata.artifactId}/segments/`; + + return async (request, response) => { + const startedAt = now(); + const url = new URL(request.url, 'http://listener.invalid'); + const route = routeName(url.pathname); + let status = 500; + let bytes = 0; + const cors = crossOriginHeaders(request, allowedOrigins); + const respond = (responseStatus, body = '', headers = {}) => ( + send(response, responseStatus, body, { ...cors, ...headers }) + ); + try { + const controlPrefix = '/internal/v1/listener/media-grants/'; + if (request.method === 'PUT' && url.pathname.startsWith(controlPrefix)) { + const grantId = url.pathname.slice(controlPrefix.length); + const body = await readBoundedBody(request); + if (!MEDIA_GRANT_ID_PATTERN.test(grantId) + || !verifyControlRequest({ + secret: signingSecret, + method: 'PUT', + pathname: url.pathname, + timestamp: request.headers['x-beacon-control-timestamp'], + signature: request.headers['x-beacon-control-signature'], + body, + nowMs: now(), + })) { + status = 403; + respond(status, 'forbidden\n', { 'Cache-Control': 'no-store' }); + return; + } + let payload; + try { payload = JSON.parse(body.toString('utf8')); } catch { payload = null; } + const result = payload && mediaGrants.upsert({ + id: grantId, + tokenSha256: payload.tokenSha256, + expiresAtMs: payload.expiresAtMs, + }); + if (!result?.ok) { + status = result?.reason === 'capacity' ? 503 : result?.reason === 'conflict' ? 409 : 400; + respond(status, status === 503 ? 'unavailable\n' : 'invalid grant\n', { 'Cache-Control': 'no-store' }); + return; + } + status = 204; + respond(status, '', { 'Cache-Control': 'no-store' }); + return; + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + status = 405; + respond(status, 'method not allowed\n', { Allow: 'GET, HEAD' }); + return; + } + if (url.pathname === '/healthz') { + status = 200; + respond(status, 'ok\n', { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); + return; + } + if (url.pathname === manifestPath) { + if (!authorizedMedia({ request, url, secret: signingSecret, mediaGrants })) { + status = 403; + respond(status, 'forbidden\n', { 'Cache-Control': 'no-store' }); + return; + } + const grant = mediaGrantFrom(url); + const manifest = renderManifest({ + metadata, + origin: publicOrigin, + secret: signingSecret, + nowMs: now(), + // A segment grant is derived from this manifest grant and must never + // remain usable after the upstream Listener lease horizon. + authorizationExpiresAtSeconds: tokenFrom(url).expiresAt, + mediaAuthorizationQuery: grant.id ? { grantId: grant.id, grant: grant.token } : null, + }); + status = 200; + bytes = request.method === 'HEAD' ? 0 : Buffer.byteLength(manifest); + respond(status, request.method === 'HEAD' ? '' : manifest, { + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + 'Cache-Control': 'private, no-store', + }); + return; + } + if (url.pathname.startsWith(segmentPrefix)) { + if (!authorizedMedia({ request, url, secret: signingSecret, mediaGrants })) { + status = 403; + respond(status, 'forbidden\n', { 'Cache-Control': 'no-store' }); + return; + } + const file = decodeURIComponent(url.pathname.slice(segmentPrefix.length)); + if (!metadata.segmentByFile.has(file)) { + status = 404; + respond(status, 'not found\n', { 'Cache-Control': 'no-store' }); + return; + } + const segmentPath = path.resolve(artifactRoot, 'segments', file); + const segmentsRoot = path.resolve(artifactRoot, 'segments'); + if (!segmentPath.startsWith(`${segmentsRoot}${path.sep}`)) { + status = 404; + respond(status, 'not found\n', { 'Cache-Control': 'no-store' }); + return; + } + const segment = await fs.readFile(segmentPath); + status = 200; + bytes = request.method === 'HEAD' ? 0 : segment.byteLength; + respond(status, request.method === 'HEAD' ? '' : segment, { + 'Content-Type': mediaContentType(file), + 'Cache-Control': 'private, no-store', + 'Content-Length': String(segment.byteLength), + }); + return; + } + status = 404; + respond(status, 'not found\n', { 'Cache-Control': 'no-store' }); + } catch (error) { + // Do not expose filesystem paths, credentials or signed URLs. + status = error instanceof Error && error.message === 'body_too_large' ? 413 : 500; + if (!response.headersSent) respond(status, status === 413 ? 'payload too large\n' : 'internal server error\n', { 'Cache-Control': 'no-store' }); + } finally { + metrics.observe({ route, status, bytes, durationMs: Math.max(0, now() - startedAt) }); + } + }; +} + +export function createInternalHandler({ metadata, metrics }) { + return (request, response) => { + const url = new URL(request.url, 'http://internal.invalid'); + if (request.method !== 'GET') return send(response, 405, 'method not allowed\n', { Allow: 'GET' }); + if (url.pathname === '/readyz') { + return send(response, 200, `${JSON.stringify({ status: 'ready', artifactId: metadata.artifactId, epochUtc: metadata.timing.epochUtc })}\n`, { + 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', + }); + } + if (url.pathname === '/metrics') { + return send(response, 200, metrics.render(), { + 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store', + }); + } + return send(response, 404, 'not found\n', { 'Cache-Control': 'no-store' }); + }; +} + +export async function startFromEnvironment(environment = process.env) { + const mediaRoot = environment.BEACON_STREAM_MEDIA_ROOT; + const artifactId = environment.BEACON_STREAM_ARTIFACT_ID; + const signingSecret = environment.BEACON_STREAM_SIGNING_SECRET; + const publicOrigin = environment.BEACON_STREAM_PUBLIC_ORIGIN; + const allowedOriginsValue = environment.BEACON_STREAM_ALLOWED_ORIGINS; + if (!mediaRoot || !artifactId || !signingSecret || !publicOrigin || !allowedOriginsValue) { + throw new Error('BEACON_STREAM_MEDIA_ROOT, BEACON_STREAM_ARTIFACT_ID, BEACON_STREAM_SIGNING_SECRET, BEACON_STREAM_PUBLIC_ORIGIN and BEACON_STREAM_ALLOWED_ORIGINS are required'); + } + const allowedOrigins = parseAllowedOrigins(allowedOriginsValue); + const { root: artifactRoot, metadata } = await loadArtifact({ mediaRoot, artifactId }); + await verifyArtifactFiles({ root: artifactRoot, metadata }); + const metrics = new Metrics(); + const publicServer = http.createServer(createPublicHandler({ artifactRoot, metadata, publicOrigin, signingSecret, allowedOrigins, metrics })); + const internalServer = http.createServer(createInternalHandler({ metadata, metrics })); + const publicPort = Number(environment.BEACON_STREAM_PORT ?? 8080); + const internalPort = Number(environment.BEACON_STREAM_METRICS_PORT ?? 9090); + const internalHost = environment.BEACON_STREAM_METRICS_BIND_HOST ?? '127.0.0.1'; + await Promise.all([ + new Promise((resolve) => publicServer.listen(publicPort, '0.0.0.0', resolve)), + new Promise((resolve) => internalServer.listen(internalPort, internalHost, resolve)), + ]); + return { publicServer, internalServer, metadata }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + startFromEnvironment().then(({ metadata }) => { + // This deliberately includes only non-sensitive deployment state. + console.log(`beacon-stream ready artifact=${metadata.artifactId}`); + }).catch(() => { + // Validation details can include a mounted path. Keep startup logs non-sensitive. + console.error('beacon-stream failed startup validation'); + process.exitCode = 1; + }); +} diff --git a/services/beacon-stream/test/artifact.test.mjs b/services/beacon-stream/test/artifact.test.mjs new file mode 100644 index 00000000..7993b231 --- /dev/null +++ b/services/beacon-stream/test/artifact.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import test from 'node:test'; +import { loadArtifact, verifyArtifactFiles } from '../src/artifact.mjs'; +import { metadata, temporaryArtifact, temporaryVariableArtifact, variableMetadata } from './helpers.mjs'; + +test('requires explicit approval and exactly six-second immutable segment metadata', () => { + assert.throws(() => metadata({ approval: { status: 'PENDING' } }), /not explicitly approved/); + assert.throws(() => metadata({ timing: { epochUtc: '2026-08-06T00:00:00.000Z', segmentDurationSeconds: 5, segmentCount: 3 } }), /six-second/); + assert.throws(() => metadata({ timing: { epochUtc: '2026-08-06T00:00:00', segmentDurationSeconds: 6, segmentCount: 3 } }), /UTC timestamp/); +}); + +test('accepts an fMP4 initialization file and measured final segment duration', async () => { + const item = variableMetadata(); + assert.equal(item.loopDurationSeconds, 16); + assert.deepEqual(item.segmentStartsSeconds, [0, 6, 12]); + assert.equal(item.segmentByFile.has('init.mp4'), true); + const artifact = await temporaryVariableArtifact(); + await verifyArtifactFiles(await loadArtifact({ mediaRoot: artifact.mediaRoot, artifactId: 'approved-v2' })); +}); + +test('rejects variable segment timing that does not equal the loop duration', () => { + assert.throws(() => variableMetadata({ + timing: { epochUtc: '2026-08-06T00:00:00.000Z', targetSegmentDurationSeconds: 6, segmentCount: 3, loopDurationSeconds: 18 }, + }), /do not match/); +}); + +test('loads and checksum-verifies every immutable segment', async () => { + const { mediaRoot, artifactRoot } = await temporaryArtifact(); + const loaded = await loadArtifact({ mediaRoot, artifactId: 'approved-v1' }); + await verifyArtifactFiles(loaded); + await fs.writeFile(`${artifactRoot}/segments/00001.m4s`, 'bad'); + await assert.rejects(() => verifyArtifactFiles(loaded), /checksum changed/); +}); diff --git a/services/beacon-stream/test/auth.test.mjs b/services/beacon-stream/test/auth.test.mjs new file mode 100644 index 00000000..53de8f5d --- /dev/null +++ b/services/beacon-stream/test/auth.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { signPath, verifySignedPath } from '../src/auth.mjs'; + +const secret = 'x'.repeat(32); +const pathname = '/v1/hls/approved-v1/live.m3u8'; + +test('validates a short-lived canonical signed path', () => { + const signature = signPath({ secret, pathname, expiresAt: 1_100 }); + assert.equal(verifySignedPath({ secret, pathname, expiresAt: 1_100, signature, now: 1_000 }), true); +}); + +test('rejects altered, expired and excessively distant signed paths', () => { + const signature = signPath({ secret, pathname, expiresAt: 1_100 }); + assert.equal(verifySignedPath({ secret, pathname: `${pathname}/other`, expiresAt: 1_100, signature, now: 1_000 }), false); + assert.equal(verifySignedPath({ secret, pathname, expiresAt: 1_000, signature, now: 1_000 }), false); + const farSignature = signPath({ secret, pathname, expiresAt: 2_000 }); + assert.equal(verifySignedPath({ secret, pathname, expiresAt: 2_000, signature: farSignature, now: 1_000 }), false); +}); diff --git a/services/beacon-stream/test/build-artifact.test.mjs b/services/beacon-stream/test/build-artifact.test.mjs new file mode 100644 index 00000000..ae827ff5 --- /dev/null +++ b/services/beacon-stream/test/build-artifact.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +test('writes a non-secret inventory readable by the unprivileged origin', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beacon-build-artifact-')); + const segments = path.join(root, 'segments'); + const derivative = path.join(root, 'approved.m4a'); + await fs.mkdir(segments); + await Promise.all([ + fs.writeFile(path.join(segments, 'init.mp4'), 'init'), + fs.writeFile(path.join(segments, '00000.m4s'), 'one'), + fs.writeFile(derivative, 'derivative'), + fs.writeFile(path.join(root, 'package.m3u8'), [ + '#EXTM3U', + '#EXT-X-MAP:URI="segments/init.mp4"', + '#EXTINF:6.000000,', + '00000.m4s', + '', + ].join('\n')), + ]); + + const result = spawnSync(process.execPath, [ + new URL('../scripts/build-artifact.mjs', import.meta.url).pathname, + '--artifact-root', root, + '--artifact-id', 'approved-readable', + '--derivative', derivative, + '--master-sha256', 'a'.repeat(64), + '--epoch-utc', '2026-08-06T00:00:00.000Z', + '--approved-at', '2026-08-06T18:07:40.000Z', + '--review-record', 'approved fixture', + ], { encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + const inventory = path.join(root, 'artifact.json'); + assert.equal((await fs.stat(inventory)).mode & 0o777, 0o644); + assert.equal(JSON.parse(await fs.readFile(inventory, 'utf8')).artifactId, 'approved-readable'); +}); diff --git a/services/beacon-stream/test/helpers.mjs b/services/beacon-stream/test/helpers.mjs new file mode 100644 index 00000000..d7a04ea8 --- /dev/null +++ b/services/beacon-stream/test/helpers.mjs @@ -0,0 +1,69 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { validateArtifact } from '../src/artifact.mjs'; + +export const SHA = 'a'.repeat(64); + +export function metadata(overrides = {}) { + return validateArtifact({ + schemaVersion: 1, + artifactId: 'approved-v1', + approval: { status: 'APPROVED', approvedAt: '2026-08-06T00:00:00.000Z', reviewRecord: 'audio-review-reference' }, + source: { masterSha256: SHA }, + derivative: { sha256: 'b'.repeat(64) }, + timing: { epochUtc: '2026-08-06T00:00:00.000Z', segmentDurationSeconds: 6, segmentCount: 3 }, + segments: [ + { file: '00000.m4s', bytes: 3, sha256: crypto.createHash('sha256').update('one').digest('hex') }, + { file: '00001.m4s', bytes: 3, sha256: crypto.createHash('sha256').update('two').digest('hex') }, + { file: '00002.m4s', bytes: 5, sha256: crypto.createHash('sha256').update('three').digest('hex') }, + ], + ...overrides, + }); +} + +export function variableMetadata(overrides = {}) { + return validateArtifact({ + schemaVersion: 2, + artifactId: 'approved-v2', + approval: { status: 'APPROVED', approvedAt: '2026-08-06T00:00:00.000Z', reviewRecord: 'audio-review-reference' }, + source: { masterSha256: SHA }, + derivative: { sha256: 'b'.repeat(64) }, + timing: { epochUtc: '2026-08-06T00:00:00.000Z', targetSegmentDurationSeconds: 6, segmentCount: 3, loopDurationSeconds: 16 }, + initialization: { file: 'init.mp4', bytes: 4, sha256: crypto.createHash('sha256').update('init').digest('hex') }, + segments: [ + { file: '00000.m4s', durationSeconds: 6, bytes: 3, sha256: crypto.createHash('sha256').update('one').digest('hex') }, + { file: '00001.m4s', durationSeconds: 6, bytes: 3, sha256: crypto.createHash('sha256').update('two').digest('hex') }, + { file: '00002.m4s', durationSeconds: 4, bytes: 5, sha256: crypto.createHash('sha256').update('three').digest('hex') }, + ], + ...overrides, + }); +} + +export async function temporaryArtifact() { + const mediaRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beacon-stream-')); + const artifactRoot = path.join(mediaRoot, 'approved-v1'); + await fs.mkdir(path.join(artifactRoot, 'segments'), { recursive: true }); + await Promise.all([ + fs.writeFile(path.join(artifactRoot, 'segments', '00000.m4s'), 'one'), + fs.writeFile(path.join(artifactRoot, 'segments', '00001.m4s'), 'two'), + fs.writeFile(path.join(artifactRoot, 'segments', '00002.m4s'), 'three'), + ]); + await fs.writeFile(path.join(artifactRoot, 'artifact.json'), `${JSON.stringify(metadata(), null, 2)}\n`); + return { mediaRoot, artifactRoot }; +} + +export async function temporaryVariableArtifact() { + const mediaRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beacon-stream-v2-')); + const artifactRoot = path.join(mediaRoot, 'approved-v2'); + await fs.mkdir(path.join(artifactRoot, 'segments'), { recursive: true }); + await Promise.all([ + fs.writeFile(path.join(artifactRoot, 'segments', 'init.mp4'), 'init'), + fs.writeFile(path.join(artifactRoot, 'segments', '00000.m4s'), 'one'), + fs.writeFile(path.join(artifactRoot, 'segments', '00001.m4s'), 'two'), + fs.writeFile(path.join(artifactRoot, 'segments', '00002.m4s'), 'three'), + ]); + await fs.writeFile(path.join(artifactRoot, 'artifact.json'), `${JSON.stringify(variableMetadata(), null, 2)}\n`); + return { mediaRoot, artifactRoot }; +} diff --git a/services/beacon-stream/test/inventory.test.mjs b/services/beacon-stream/test/inventory.test.mjs new file mode 100644 index 00000000..c67dd26c --- /dev/null +++ b/services/beacon-stream/test/inventory.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { sha256File } from '../src/inventory.mjs'; + +test('hashes media through the incremental read stream helper', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'beacon-inventory-')); + const fixture = path.join(directory, 'immutable-master.wav'); + const contents = Buffer.alloc(1024 * 1024, 7); + await fs.writeFile(fixture, contents); + const expected = crypto.createHash('sha256').update(contents).digest('hex'); + assert.equal(await sha256File(fixture), expected); +}); diff --git a/services/beacon-stream/test/manifest.test.mjs b/services/beacon-stream/test/manifest.test.mjs new file mode 100644 index 00000000..d8c6e4a3 --- /dev/null +++ b/services/beacon-stream/test/manifest.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderManifest, WINDOW_SEGMENTS } from '../src/manifest.mjs'; +import { verifySignedPath } from '../src/auth.mjs'; +import { metadata, variableMetadata } from './helpers.mjs'; + +const secret = 's'.repeat(32); + +test('builds a deterministic wall-clock manifest and signs each segment URI', () => { + const item = metadata(); + const epoch = item.epochMs; + const manifest = renderManifest({ metadata: item, origin: 'https://stream.example.test', secret, nowMs: epoch + 42_000 }); + assert.match(manifest, /#EXT-X-MEDIA-SEQUENCE:0/); + assert.match(manifest, /#EXT-X-DISCONTINUITY/); + assert.match(manifest, /#EXT-X-PROGRAM-DATE-TIME:2026-08-06T00:00:36.000Z/); + const urls = manifest.split('\n').filter((line) => line.startsWith('https://')); + assert.equal(urls.length, 8); + for (const stringUrl of urls) { + const url = new URL(stringUrl); + assert.equal(verifySignedPath({ secret, pathname: url.pathname, expiresAt: Number(url.searchParams.get('exp')), signature: url.searchParams.get('sig'), now: Math.floor((epoch + 42_000) / 1000) }), true); + } +}); + +test('retains at least the three-minute player target once enough program time exists', () => { + const item = metadata(); + const manifest = renderManifest({ + metadata: item, + origin: 'https://stream.example.test', + secret, + nowMs: item.epochMs + 10 * 60_000, + }); + const urls = manifest.split('\n').filter((line) => line.startsWith('https://')); + const retainedSeconds = manifest.split('\n') + .filter((line) => line.startsWith('#EXTINF:')) + .reduce((total, line) => total + Number(line.slice('#EXTINF:'.length, -1)), 0); + assert.equal(WINDOW_SEGMENTS, 50); + assert.equal(urls.length, WINDOW_SEGMENTS); + assert.ok(retainedSeconds >= 180, `retained only ${retainedSeconds}s`); +}); + +test('never signs a segment beyond the inbound manifest authorization horizon', () => { + const item = metadata(); + const nowMs = item.epochMs + 42_000; + const authorizationExpiresAtSeconds = Math.floor(nowMs / 1000) + 7; + const manifest = renderManifest({ + metadata: item, + origin: 'https://stream.example.test', + secret, + nowMs, + tokenTtlSeconds: 120, + authorizationExpiresAtSeconds, + }); + + const urls = manifest.split('\n').filter((line) => line.startsWith('https://')); + assert.ok(urls.length > 0); + for (const stringUrl of urls) { + assert.equal(Number(new URL(stringUrl).searchParams.get('exp')), authorizationExpiresAtSeconds); + } +}); + +test('carries one stable opaque media grant across every map and segment URL', () => { + const item = variableMetadata(); + const grantId = 'a'.repeat(64); + const grant = 'b'.repeat(43); + const manifest = renderManifest({ + metadata: item, + origin: 'https://stream.example.test', + secret, + nowMs: item.epochMs + 50_000, + mediaAuthorizationQuery: { grantId, grant }, + }); + const urls = [ + ...manifest.split('\n').filter((line) => line.startsWith('https://')), + manifest.match(/#EXT-X-MAP:URI="([^"]+)"/)?.[1], + ].filter(Boolean); + assert.ok(urls.length > 1); + for (const value of urls) { + const url = new URL(value); + assert.equal(url.searchParams.get('grantId'), grantId); + assert.equal(url.searchParams.get('grant'), grant); + assert.equal(url.searchParams.has('exp'), false); + assert.equal(url.searchParams.has('sig'), false); + } +}); + +test('renders a signed fMP4 map and preserves a short final segment across loops', () => { + const item = variableMetadata(); + const epoch = item.epochMs; + const manifest = renderManifest({ metadata: item, origin: 'https://stream.example.test', secret, nowMs: epoch + 50_000 }); + assert.match(manifest, /#EXT-X-MEDIA-SEQUENCE:0/); + assert.match(manifest, /#EXT-X-MAP:URI="https:\/\/stream\.example\.test\/v1\/hls\/approved-v2\/segments\/init\.mp4/); + assert.match(manifest, /#EXT-X-DISCONTINUITY/); + assert.match(manifest, /#EXTINF:4\.000000,/); + assert.match(manifest, /#EXT-X-PROGRAM-DATE-TIME:2026-08-06T00:00:48\.000Z/); + const mapUrl = new URL(manifest.match(/#EXT-X-MAP:URI="([^"]+)"/)?.[1]); + assert.equal(verifySignedPath({ secret, pathname: mapUrl.pathname, expiresAt: Number(mapUrl.searchParams.get('exp')), signature: mapUrl.searchParams.get('sig'), now: Math.floor((epoch + 50_000) / 1000) }), true); +}); + +test('keeps a retained segment on the same discontinuity sequence across window reloads', () => { + const item = variableMetadata(); + const epoch = item.epochMs; + const before = renderManifest({ metadata: item, origin: 'https://stream.example.test', secret, nowMs: epoch + 62_000, windowSegments: 6 }); + const after = renderManifest({ metadata: item, origin: 'https://stream.example.test', secret, nowMs: epoch + 65_000, windowSegments: 6 }); + + assert.match(before, /#EXT-X-DISCONTINUITY-SEQUENCE:1\n#EXT-X-MEDIA-SEQUENCE:6/); + assert.match(after, /#EXT-X-DISCONTINUITY-SEQUENCE:2\n#EXT-X-MEDIA-SEQUENCE:7/); + // Sequence 7 is retained. Its effective discontinuity number is the base + // plus explicit tags before it: 1 + 1 before, then 2 + 0 after. + const effectiveSequence = (manifest) => { + const base = Number(manifest.match(/#EXT-X-DISCONTINUITY-SEQUENCE:(\d+)/)?.[1]); + const beforeRetainedSegment = manifest.slice(0, manifest.indexOf('00001.m4s')); + return base + (beforeRetainedSegment.match(/#EXT-X-DISCONTINUITY\n/g) ?? []).length; + }; + assert.equal(effectiveSequence(before), 2); + assert.equal(effectiveSequence(after), 2); +}); diff --git a/services/beacon-stream/test/media-grants.test.mjs b/services/beacon-stream/test/media-grants.test.mjs new file mode 100644 index 00000000..5248ebb5 --- /dev/null +++ b/services/beacon-stream/test/media-grants.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; +import { MediaGrantRegistry, MEDIA_GRANT_MAX_TTL_MS } from '../src/media-grants.mjs'; + +const id = 'a'.repeat(64); +const token = 'b'.repeat(43); +const hash = createHash('sha256').update(token).digest('hex'); + +test('media grants are bounded, monotonic, opaque and expire fail-closed', () => { + let now = 1_800_000_000_000; + const registry = new MediaGrantRegistry({ now: () => now, maxEntries: 1 }); + assert.deepEqual(registry.upsert({ id, tokenSha256: hash, expiresAtMs: now + 60_000 }), { ok: true }); + assert.equal(registry.authorize({ id, token }), true); + assert.equal(registry.authorize({ id, token: 'c'.repeat(43) }), false); + assert.deepEqual(registry.upsert({ id, tokenSha256: hash, expiresAtMs: now + 30_000 }), { ok: true }); + assert.deepEqual(registry.upsert({ id, tokenSha256: 'd'.repeat(64), expiresAtMs: now + 60_000 }), { ok: false, reason: 'conflict' }); + assert.deepEqual(registry.upsert({ id: 'e'.repeat(64), tokenSha256: hash, expiresAtMs: now + 60_000 }), { ok: false, reason: 'capacity' }); + assert.deepEqual(registry.upsert({ id: 'f'.repeat(64), tokenSha256: hash, expiresAtMs: now + MEDIA_GRANT_MAX_TTL_MS + 1 }), { ok: false, reason: 'invalid' }); + now += 60_001; + assert.equal(registry.authorize({ id, token }), false); + assert.equal(registry.size, 0); +}); + diff --git a/services/beacon-stream/test/server.test.mjs b/services/beacon-stream/test/server.test.mjs new file mode 100644 index 00000000..b2545966 --- /dev/null +++ b/services/beacon-stream/test/server.test.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import test from 'node:test'; +import { createPublicHandler, createInternalHandler, parseAllowedOrigins } from '../src/server.mjs'; +import { signedUrl, signPath } from '../src/auth.mjs'; +import { Metrics } from '../src/metrics.mjs'; +import { signControlRequest } from '../src/control-auth.mjs'; +import { metadata, temporaryArtifact, temporaryVariableArtifact, variableMetadata } from './helpers.mjs'; + +const secret = 'z'.repeat(32); + +async function listen(handler) { + const server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + return { server, origin: `http://127.0.0.1:${port}` }; +} + +test('only exposes minimal health publicly and protects manifest and every segment', async (t) => { + const { artifactRoot } = await temporaryArtifact(); + const item = metadata(); + const metrics = new Metrics(); + const allowedOrigin = 'https://earlybirds-staging.example.test'; + const { server, origin } = await listen(createPublicHandler({ + artifactRoot, + metadata: item, + publicOrigin: 'https://stream.example.test', + signingSecret: secret, + allowedOrigins: new Set([allowedOrigin]), + metrics, + })); + t.after(() => server.close()); + assert.equal((await fetch(`${origin}/healthz`)).status, 200); + assert.equal((await fetch(`${origin}/metrics`)).status, 404); + assert.equal((await fetch(`${origin}/v1/hls/approved-v1/live.m3u8`)).status, 403); + const pathname = '/v1/hls/approved-v1/live.m3u8'; + const expiry = Math.floor(Date.now() / 1000) + 60; + const signature = signPath({ secret, pathname, expiresAt: expiry }); + const response = await fetch(`${origin}${pathname}?exp=${expiry}&sig=${signature}`, { + headers: { Origin: allowedOrigin }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('access-control-allow-origin'), allowedOrigin); + assert.equal(response.headers.get('vary'), 'Origin'); + const head = await fetch(`${origin}${pathname}?exp=${expiry}&sig=${signature}`, { + method: 'HEAD', + headers: { Origin: allowedOrigin }, + }); + assert.equal(head.status, 200); + assert.equal(head.headers.get('access-control-allow-origin'), allowedOrigin); + const manifest = await response.text(); + const segmentUrl = manifest.split('\n').find((line) => line.startsWith('https://')); + assert.ok(segmentUrl); + const productionUrl = new URL(segmentUrl); + assert.equal(Number(productionUrl.searchParams.get('exp')), expiry); + const localUrl = new URL(`${origin}${productionUrl.pathname}${productionUrl.search}`); + const segment = await fetch(localUrl, { headers: { Origin: allowedOrigin } }); + assert.equal(segment.status, 200); + assert.equal(segment.headers.get('access-control-allow-origin'), allowedOrigin); + assert.ok(['one', 'two', 'three'].includes(await segment.text())); + + const disallowed = await fetch(localUrl, { + headers: { Origin: 'https://untrusted.example.test' }, + }); + assert.equal(disallowed.status, 200); + assert.equal(disallowed.headers.get('access-control-allow-origin'), null); +}); + +test('serves an fMP4 initialization map and media segments with browser-compatible content types', async (t) => { + const artifact = await temporaryVariableArtifact(); + const item = variableMetadata(); + const server = http.createServer(createPublicHandler({ + artifactRoot: artifact.artifactRoot, + metadata: item, + publicOrigin: 'https://stream.example.test', + signingSecret: secret, + allowedOrigins: new Set(['https://listener.example.test']), + now: () => item.epochMs + 10_000, + })); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + + const address = server.address(); + const expiresAt = Math.floor(Date.now() / 1000) + 60; + for (const [file, expected] of [['init.mp4', 'video/mp4'], ['00000.m4s', 'video/iso.segment']]) { + const pathname = `/v1/hls/${item.artifactId}/segments/${file}`; + const publicUrl = new URL(signedUrl({ origin: 'https://stream.example.test', secret, pathname, expiresAt })); + publicUrl.hostname = '127.0.0.1'; + publicUrl.port = String(address.port); + publicUrl.protocol = 'http:'; + const response = await fetch(publicUrl); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), expected); + } +}); + +test('accepts only explicit canonical HTTP origins for browser media fetches', () => { + assert.deepEqual( + [...parseAllowedOrigins('https://earlybirds.example.test, http://localhost:3000')], + ['https://earlybirds.example.test', 'http://localhost:3000'], + ); + assert.throws(() => parseAllowedOrigins(''), /at least one origin/); + assert.throws(() => parseAllowedOrigins('https://user@example.test'), /invalid origin/); + assert.throws(() => parseAllowedOrigins('https://example.test/path'), /invalid origin/); +}); + +test('publishes readiness and Prometheus metrics only on the internal listener', async (t) => { + const metrics = new Metrics(); + const { server, origin } = await listen(createInternalHandler({ metadata: metadata(), metrics })); + t.after(() => server.close()); + assert.equal((await fetch(`${origin}/readyz`)).status, 200); + const body = await (await fetch(`${origin}/metrics`)).text(); + assert.match(body, /beacon_stream_http_requests_total/); +}); + +test('serves a registered media grant without consulting Listener and expires it locally', async (t) => { + const { artifactRoot } = await temporaryArtifact(); + const item = metadata(); + let now = item.epochMs + 42_000; + const { server, origin } = await listen(createPublicHandler({ + artifactRoot, + metadata: item, + publicOrigin: 'https://stream.example.test', + signingSecret: secret, + allowedOrigins: new Set(['https://listen.example.test']), + now: () => now, + })); + t.after(() => server.close()); + const grantId = 'a'.repeat(64); + const grant = 'b'.repeat(43); + const pathname = `/internal/v1/listener/media-grants/${grantId}`; + const body = JSON.stringify({ + tokenSha256: (await import('node:crypto')).createHash('sha256').update(grant).digest('hex'), + expiresAtMs: now + 180_000, + }); + const timestamp = Math.floor(now / 1000); + const signature = signControlRequest({ secret, pathname, timestamp, body }); + const registered = await fetch(`${origin}${pathname}`, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + 'x-beacon-control-timestamp': String(timestamp), + 'x-beacon-control-signature': signature, + }, + body, + }); + assert.equal(registered.status, 204); + + const manifestUrl = `${origin}/v1/hls/${item.artifactId}/live.m3u8?grantId=${grantId}&grant=${grant}`; + const response = await fetch(manifestUrl); + assert.equal(response.status, 200); + const manifest = await response.text(); + const publicSegment = new URL(manifest.split('\n').find((line) => line.startsWith('https://'))); + const segmentUrl = new URL(`${origin}${publicSegment.pathname}${publicSegment.search}`); + assert.equal((await fetch(segmentUrl)).status, 200); + + // No callback to Listener occurs on either media request. The origin keeps + // serving solely from the local grant until its exact lease horizon. + now += 179_999; + assert.equal((await fetch(manifestUrl)).status, 200); + now += 1; + assert.equal((await fetch(manifestUrl)).status, 403); + assert.equal((await fetch(segmentUrl)).status, 403); +}); + +test('rejects mutated, stale and oversized grant-control requests', async (t) => { + const { artifactRoot } = await temporaryArtifact(); + const item = metadata(); + const now = item.epochMs + 42_000; + const { server, origin } = await listen(createPublicHandler({ + artifactRoot, metadata: item, publicOrigin: 'https://stream.example.test', signingSecret: secret, now: () => now, + })); + t.after(() => server.close()); + const pathname = `/internal/v1/listener/media-grants/${'c'.repeat(64)}`; + const body = JSON.stringify({ tokenSha256: 'd'.repeat(64), expiresAtMs: now + 60_000 }); + const timestamp = Math.floor(now / 1000); + const headers = { + 'x-beacon-control-timestamp': String(timestamp), + 'x-beacon-control-signature': signControlRequest({ secret, pathname, timestamp, body }), + }; + assert.equal((await fetch(`${origin}${pathname}`, { method: 'PUT', headers, body: `${body} ` })).status, 403); + assert.equal((await fetch(`${origin}${pathname}`, { + method: 'PUT', headers: { ...headers, 'x-beacon-control-timestamp': String(timestamp - 31) }, body, + })).status, 403); + assert.equal((await fetch(`${origin}${pathname}`, { method: 'PUT', headers, body: 'x'.repeat(1025) })).status, 413); +}); diff --git a/src/app/.well-known/jwks.json/route.ts b/src/app/.well-known/jwks.json/route.ts new file mode 100644 index 00000000..c8f5b183 --- /dev/null +++ b/src/app/.well-known/jwks.json/route.ts @@ -0,0 +1,15 @@ +import { accountAuth } from '@/lib/account/auth'; +import { isAccountHost } from '@/lib/account/config'; + +export async function GET(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const response = await accountAuth().api.getJwks(); + return Response.json(response, { + headers: { + 'Cache-Control': 'public, max-age=300', + 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'", + }, + }); +} diff --git a/src/app/.well-known/openid-configuration/__tests__/route.test.ts b/src/app/.well-known/openid-configuration/__tests__/route.test.ts new file mode 100644 index 00000000..8d224422 --- /dev/null +++ b/src/app/.well-known/openid-configuration/__tests__/route.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@better-auth/oauth-provider', () => ({ + oauthProviderOpenIdConfigMetadata: vi.fn(() => () => Response.json({ + issuer: 'https://account.harmonicbeacon.com', + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'], + introspection_endpoint_auth_methods_supported: ['client_secret_post'], + revocation_endpoint_auth_methods_supported: ['none'], + })), +})); +vi.mock('@/lib/account/auth', () => ({ accountAuth: () => ({}) })); + +import { GET } from '../route'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('Account discovery confidential-client metadata', () => { + it('advertises only client_secret_basic at every authenticated endpoint', async () => { + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com'); + const response = await GET(new Request( + 'https://account.harmonicbeacon.com/.well-known/openid-configuration', + { headers: { host: 'account.harmonicbeacon.com' } }, + )); + expect(response.status).toBe(200); + const metadata = await response.json(); + expect(metadata.jwks_uri).toBe('https://account.harmonicbeacon.com/.well-known/jwks.json'); + expect(metadata.token_endpoint_auth_methods_supported).toEqual(['client_secret_basic']); + expect(metadata.introspection_endpoint_auth_methods_supported).toEqual(['client_secret_basic']); + expect(metadata.revocation_endpoint_auth_methods_supported).toEqual(['client_secret_basic']); + }); +}); diff --git a/src/app/.well-known/openid-configuration/route.test.ts b/src/app/.well-known/openid-configuration/route.test.ts new file mode 100644 index 00000000..12414433 --- /dev/null +++ b/src/app/.well-known/openid-configuration/route.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@better-auth/oauth-provider', () => ({ + oauthProviderOpenIdConfigMetadata: () => async () => Response.json({ + issuer: 'https://account.harmonicbeacon.com', + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + }), +})); +vi.mock('@/lib/account/auth', () => ({ accountAuth: () => ({}) })); + +import { GET } from './route'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('Account discovery confidential-client metadata', () => { + it('advertises client_secret_basic exclusively for every client-authenticated endpoint', async () => { + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com'); + const response = await GET(new Request( + 'https://account.harmonicbeacon.com/.well-known/openid-configuration', + { headers: { host: 'account.harmonicbeacon.com' } }, + )); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + jwks_uri: 'https://account.harmonicbeacon.com/.well-known/jwks.json', + token_endpoint_auth_methods_supported: ['client_secret_basic'], + introspection_endpoint_auth_methods_supported: ['client_secret_basic'], + revocation_endpoint_auth_methods_supported: ['client_secret_basic'], + }); + }); +}); diff --git a/src/app/.well-known/openid-configuration/route.ts b/src/app/.well-known/openid-configuration/route.ts new file mode 100644 index 00000000..7efe5380 --- /dev/null +++ b/src/app/.well-known/openid-configuration/route.ts @@ -0,0 +1,28 @@ +import { oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider'; + +import { accountAuth } from '@/lib/account/auth'; +import { accountOrigin, isAccountHost } from '@/lib/account/config'; + +export async function GET(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const response = await oauthProviderOpenIdConfigMetadata(accountAuth(), { + headers: { + 'Cache-Control': 'public, max-age=300', + 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'", + }, + })(request); + if (!response.ok) return response; + const metadata = await response.json() as Record; + return Response.json({ + ...metadata, + // oauth-provider 1.6.30 derives this path from Better Auth's mounted + // base path, while our reviewed public JWKS route is issuer-rooted. + // Publish only the route that is actually exposed by the Account edge. + jwks_uri: `${accountOrigin()}/.well-known/jwks.json`, + token_endpoint_auth_methods_supported: ['client_secret_basic'], + introspection_endpoint_auth_methods_supported: ['client_secret_basic'], + revocation_endpoint_auth_methods_supported: ['client_secret_basic'], + }, { headers: response.headers }); +} diff --git a/src/app/__tests__/layout-locale.test.tsx b/src/app/__tests__/layout-locale.test.tsx new file mode 100644 index 00000000..22c46f8c --- /dev/null +++ b/src/app/__tests__/layout-locale.test.tsx @@ -0,0 +1,205 @@ +import { readFileSync } from 'node:fs'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + headers: vi.fn(), + requestLocale: vi.fn(), + requestBrowserLocale: vi.fn(), + locallyKnownAccountSession: vi.fn(), + locallyKnownListenerNavigationIdentity: vi.fn(), + validateListenerAccountRPEnvironment: vi.fn(), +})); + +vi.mock('next/headers', () => ({ headers: mocks.headers })); +vi.mock('@/lib/i18n-server', () => ({ + requestLocale: mocks.requestLocale, + requestBrowserLocale: mocks.requestBrowserLocale, +})); +vi.mock('@/lib/account/auth', () => ({ + locallyKnownAccountSession: mocks.locallyKnownAccountSession, +})); +vi.mock('@/lib/listener/account-rp', () => ({ + locallyKnownListenerNavigationIdentity: mocks.locallyKnownListenerNavigationIdentity, + validateListenerAccountRPEnvironment: mocks.validateListenerAccountRPEnvironment, +})); +vi.mock('next/font/local', () => ({ + default: () => ({ variable: 'local-font' }), +})); +vi.mock('@/context/LocaleContext', () => ({ + LocaleProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock('sonner', () => ({ Toaster: () => null })); + +import RootLayout, { generateMetadata, generateViewport } from '../layout'; +import { ListenerIdentityCacheBoundary } from '@/components/brand/ListenerIdentityCacheBoundary'; + +function requestHeaders(host: string, acceptLanguage: string): Headers { + return new Headers({ host, 'accept-language': acceptLanguage }); +} + +describe('root document locale boundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.locallyKnownAccountSession.mockResolvedValue(false); + mocks.locallyKnownListenerNavigationIdentity.mockResolvedValue(null); + mocks.validateListenerAccountRPEnvironment.mockReturnValue(false); + delete process.env.BEACON_ACCOUNT_RUNTIME; + delete process.env.BEACON_ACCOUNT_BASE_URL; + }); + + it('matches the canonical Listener SSR document to browser-language content', async () => { + mocks.headers.mockResolvedValue(requestHeaders( + 'listen.harmonicbeacon.com', + 'en-US,en;q=0.9,es;q=0.7', + )); + mocks.requestLocale.mockResolvedValue('es'); + mocks.requestBrowserLocale.mockResolvedValue('en'); + + const result = await RootLayout({ children:
    }); + + expect(result.props.lang).toBe('en'); + expect(result.props['data-lang']).toBe('en'); + expect(result.props['data-hb-surface']).toBe('listener'); + expect(result.props.suppressHydrationWarning).toBe(true); + expect(mocks.requestLocale).not.toHaveBeenCalled(); + expect(mocks.requestBrowserLocale).toHaveBeenCalledOnce(); + }); + + it('preserves the existing event locale decision on every non-Listener host', async () => { + mocks.headers.mockResolvedValue(requestHeaders( + 'live.harmonicbeacon.com', + 'en-US,en;q=0.9', + )); + mocks.requestLocale.mockResolvedValue('es'); + + const result = await RootLayout({ children:
    }); + + expect(result.props.lang).toBe('es'); + expect(result.props['data-lang']).toBe('es'); + expect(result.props['data-hb-surface']).toBeUndefined(); + expect(mocks.requestLocale).toHaveBeenCalledOnce(); + expect(mocks.requestBrowserLocale).not.toHaveBeenCalled(); + }); + + it('matches the Account document language to the middleware-resolved explicit locale', async () => { + const incoming = requestHeaders('account.harmonicbeacon.com', 'es-AR,es;q=0.9'); + incoming.set('x-hb-account-locale', 'en'); + mocks.headers.mockResolvedValue(incoming); + mocks.requestBrowserLocale.mockResolvedValue('es'); + + const result = await RootLayout({ children:
    }); + + expect(result.props.lang).toBe('en'); + expect(result.props['data-hb-surface']).toBe('account'); + expect(mocks.requestLocale).not.toHaveBeenCalled(); + expect(mocks.requestBrowserLocale).not.toHaveBeenCalled(); + }); + + it('enhances Account staging navigation and reflects only a local signed-in boolean', async () => { + process.env.BEACON_ACCOUNT_RUNTIME = '1'; + process.env.BEACON_ACCOUNT_BASE_URL = 'https://account-staging.harmonicbeacon.com'; + const incoming = requestHeaders('account-staging.harmonicbeacon.com', 'en-US'); + incoming.set('x-hb-account-locale', 'en'); + mocks.headers.mockResolvedValue(incoming); + mocks.locallyKnownAccountSession.mockResolvedValue(true); + + const result = await RootLayout({ children:
    }); + const body = result.props.children; + const navigation = body.props.children[0]; + expect(navigation.props.accountHref).toBe('https://account-staging.harmonicbeacon.com/account'); + expect(navigation.props.accountSignedIn).toBe(true); + expect(mocks.locallyKnownAccountSession).toHaveBeenCalledOnce(); + expect(mocks.locallyKnownListenerNavigationIdentity).not.toHaveBeenCalled(); + }); + + it('uses only the local Listener projection for the signed-in navigation hint', async () => { + mocks.validateListenerAccountRPEnvironment.mockReturnValue(true); + mocks.headers.mockResolvedValue(requestHeaders( + 'earlybirds-staging.harmonicbeacon.com', + 'en-US', + )); + mocks.requestBrowserLocale.mockResolvedValue('en'); + mocks.locallyKnownListenerNavigationIdentity.mockResolvedValue({ displayName: 'Nico' }); + + const result = await RootLayout({ children:
    }); + const bodyChildren = result.props.children.props.children; + const navigation = bodyChildren[0]; + + expect(navigation.props.accountHref).toBe('https://account-staging.harmonicbeacon.com/account'); + expect(navigation.props.accountSignedIn).toBe(true); + expect(navigation.props.accountMenu.props.displayName).toBe('Nico'); + expect(bodyChildren[1].type).toBe(ListenerIdentityCacheBoundary); + expect(mocks.locallyKnownListenerNavigationIdentity).toHaveBeenCalledOnce(); + expect(mocks.locallyKnownAccountSession).not.toHaveBeenCalled(); + }); + + it('exposes production Account only when the Listener RP environment is enabled', async () => { + const incoming = requestHeaders('listen.harmonicbeacon.com', 'en-US'); + mocks.headers.mockResolvedValue(incoming); + mocks.requestBrowserLocale.mockResolvedValue('en'); + mocks.validateListenerAccountRPEnvironment.mockReturnValue(true); + mocks.locallyKnownListenerNavigationIdentity.mockResolvedValue({ displayName: 'Nico' }); + + const result = await RootLayout({ children:
    }); + const bodyChildren = result.props.children.props.children; + const navigation = bodyChildren[0]; + + expect(navigation.props.accountHref).toBe('https://account.harmonicbeacon.com/account'); + expect(navigation.props.accountSignedIn).toBe(true); + expect(navigation.props.accountMenu.props.displayName).toBe('Nico'); + expect(mocks.locallyKnownListenerNavigationIdentity).toHaveBeenCalledOnce(); + }); + + it('does not expose an Account control on production before Account production exists', async () => { + mocks.headers.mockResolvedValue(requestHeaders('listen.harmonicbeacon.com', 'en-US')); + mocks.requestBrowserLocale.mockResolvedValue('en'); + + const result = await RootLayout({ children:
    }); + const bodyChildren = result.props.children.props.children; + const navigation = bodyChildren[0]; + + expect(navigation.props.accountHref).toBeNull(); + expect(navigation.props.accountSignedIn).toBe(false); + expect(bodyChildren[1]).toBeNull(); + expect(mocks.locallyKnownListenerNavigationIdentity).not.toHaveBeenCalled(); + expect(mocks.locallyKnownAccountSession).not.toHaveBeenCalled(); + }); + + it.each([ + ['listen.harmonicbeacon.com', '#16120D'], + ['listen.harmonicbeacon.com:443', '#16120D'], + ['live.harmonicbeacon.com', '#07120f'], + ['harmonicbeacon.com', '#07120f'], + ['earlybirds-staging.harmonicbeacon.com', '#07120f'], + ])('scopes the browser theme color for %s', async (host, themeColor) => { + mocks.headers.mockResolvedValue(requestHeaders(host, 'en-US')); + + await expect(generateViewport()).resolves.toEqual({ + width: 'device-width', + initialScale: 1, + themeColor, + }); + }); + + it.each([ + ['account.harmonicbeacon.com', 'Account | Harmonic Beacon'], + ['account-staging.harmonicbeacon.com', 'Account | Harmonic Beacon'], + ['live.harmonicbeacon.com', 'Harmonic Projection | Harmonic Beacon'], + ])('scopes document metadata for %s', async (host, title) => { + mocks.headers.mockResolvedValue(requestHeaders(host, 'en-US')); + + const result = await generateMetadata(); + + expect(result.title).toBe(title); + expect(result.openGraph?.title).toBe(title); + }); + + it('pins warm overscroll and Inter to the exact Listener document marker', () => { + const css = readFileSync('src/app/globals.css', 'utf8'); + + expect(css).toContain("html[data-hb-surface='listener'] body"); + expect(css).toContain('background: var(--hb-bg-0);'); + expect(css).toContain('font-family: var(--hb-font-sans);'); + }); +}); diff --git a/src/app/account/logout/page.tsx b/src/app/account/logout/page.tsx new file mode 100644 index 00000000..872a3b1e --- /dev/null +++ b/src/app/account/logout/page.tsx @@ -0,0 +1,30 @@ +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import AccountLogoutClient from '@/components/account/AccountLogoutClient'; +import { ACCOUNT_NAV_RETURN_TO, isAccountHost } from '@/lib/account/config'; +import { requestBrowserLocale } from '@/lib/i18n-server'; + +export const dynamic = 'force-dynamic'; + +export default async function AccountLogoutPage({ searchParams }: { + searchParams: Promise>; +}) { + const incoming = await headers(); + if (!isAccountHost(incoming.get('host'))) notFound(); + const query = await searchParams; + const requestedLang = Array.isArray(query.lang) ? query.lang[0] : query.lang; + const locale = requestedLang === 'es' || requestedLang === 'en' + ? requestedLang : await requestBrowserLocale(incoming); + const rawReturnTo = Array.isArray(query.return_to) ? query.return_to[0] : query.return_to; + const returnTo = rawReturnTo && ACCOUNT_NAV_RETURN_TO.has(rawReturnTo) + ? rawReturnTo : 'https://harmonicbeacon.com/'; + const rawMode = Array.isArray(query.mode) ? query.mode[0] : query.mode; + const mode = rawMode === 'all' ? 'all' as const : 'current' as const; + const rawInitiation = Array.isArray(query.initiation) ? query.initiation[0] : query.initiation; + const initiation = typeof rawInitiation === 'string' && rawInitiation.length <= 2048 + ? rawInitiation : null; + return
    ; +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx new file mode 100644 index 00000000..1bc5c26c --- /dev/null +++ b/src/app/account/page.tsx @@ -0,0 +1,42 @@ +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import AccountClient from '@/components/account/AccountClient'; +import { currentAccountSession } from '@/lib/account/auth'; +import { ACCOUNT_NAV_RETURN_TO, accountSocialProviderConfiguration, isAccountHost } from '@/lib/account/config'; +import { requestBrowserLocale } from '@/lib/i18n-server'; + +export const dynamic = 'force-dynamic'; + +export default async function AccountPage({ searchParams }: { + searchParams: Promise>; +}) { + const incoming = await headers(); + if (!isAccountHost(incoming.get('host'))) notFound(); + const query = await searchParams; + const requestedLang = Array.isArray(query.lang) ? query.lang[0] : query.lang; + const locale = requestedLang === 'es' || requestedLang === 'en' + ? requestedLang : await requestBrowserLocale(incoming); + const rawReturnTo = Array.isArray(query.return_to) ? query.return_to[0] : query.return_to; + const returnTo = rawReturnTo && ACCOUNT_NAV_RETURN_TO.has(rawReturnTo) ? rawReturnTo : null; + const session = await currentAccountSession(new Headers(incoming)); + const providers = accountSocialProviderConfiguration(); + return ( +
    +
    + Harmonic Beacon +

    {locale === 'es' ? 'Cuenta' : 'Account'}

    +

    {locale === 'es' ? 'Tu identidad Beacon' : 'Your Beacon identity'}

    +
    + +
    + ); +} diff --git a/src/app/api/account/auth/[...all]/route.test.ts b/src/app/api/account/auth/[...all]/route.test.ts new file mode 100644 index 00000000..47567592 --- /dev/null +++ b/src/app/api/account/auth/[...all]/route.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const authHandler = vi.hoisted(() => vi.fn()); +const getSession = vi.hoisted(() => vi.fn()); +const transaction = vi.hoisted(() => vi.fn()); +const revokeAccountSession = vi.hoisted(() => vi.fn()); +const userFindUnique = vi.hoisted(() => vi.fn()); +const sessionFindUnique = vi.hoisted(() => vi.fn()); +const sessionDeleteMany = vi.hoisted(() => vi.fn()); +const ensureVerificationMailQueued = vi.hoisted(() => vi.fn()); +const processVerificationMailOutbox = vi.hoisted(() => vi.fn()); +const after = vi.hoisted(() => vi.fn()); +vi.mock('next/server', () => ({ after })); +vi.mock('@/lib/account/auth', () => ({ + accountAuth: () => ({ handler: authHandler, api: { getSession } }), +})); +vi.mock('@/lib/account/authority-db', () => ({ accountAuthorityDatabaseReady: () => true })); +vi.mock('@/lib/account/revocation', () => ({ revokeAccountSession })); +vi.mock('@/lib/account/rate-limit', () => ({ consumeAccountRateLimit: () => true })); +vi.mock('@/lib/account/mail-outbox', () => ({ + ensureVerificationMailQueued, + processVerificationMailOutbox, +})); +vi.mock('@/lib/account/timing', () => ({ enforceAccountCredentialFloor: vi.fn() })); +vi.mock('@/lib/db', () => ({ + prisma: { + $transaction: transaction, + earlyBirdAuthSession: { findUnique: sessionFindUnique, deleteMany: sessionDeleteMany }, + earlyBirdUser: { findUnique: userFindUnique }, + }, +})); + +import { POST } from './route'; +import { GET } from './route'; + +const origin = 'https://account.harmonicbeacon.com'; +const secret = 'complete-client-secret-at-least-thirty-two-characters'; +const basic = `Basic ${Buffer.from(`hb-listener:${secret}`).toString('base64')}`; + +function tokenRequest(body: Record, authorization?: string) { + return new Request(`${origin}/api/account/auth/oauth2/token`, { + method: 'POST', + headers: { + host: 'account.harmonicbeacon.com', + 'content-type': 'application/x-www-form-urlencoded', + ...(authorization ? { authorization } : {}), + }, + body: new URLSearchParams(body), + }); +} + +describe('Account catch-all route confidential OAuth boundary', () => { + beforeEach(() => { + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', origin); + vi.stubEnv('BEACON_ACCOUNT_RATE_SECRET', 'route-test-rate-secret-at-least-thirty-two-characters'); + vi.stubEnv('BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER', secret); + getSession.mockResolvedValue(null); + authHandler.mockResolvedValue(Response.json({ access_token: 'opaque' })); + transaction.mockImplementation(async (callback) => callback({})); + revokeAccountSession.mockResolvedValue(undefined); + sessionDeleteMany.mockResolvedValue({ count: 0 }); + ensureVerificationMailQueued.mockResolvedValue(undefined); + processVerificationMailOutbox.mockResolvedValue(undefined); + }); + afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); }); + + it('forwards an exact client_secret_basic token request', async () => { + const response = await POST(tokenRequest({ + grant_type: 'authorization_code', code: 'code', redirect_uri: 'https://listen.harmonicbeacon.com/api/account/callback', + }, basic)); + expect(response.status).toBe(200); + expect(authHandler).toHaveBeenCalledOnce(); + }); + + it('rejects body client secrets and unauthenticated requests before Better Auth', async () => { + for (const request of [ + tokenRequest({ client_id: 'hb-listener', client_secret: secret }), + tokenRequest({ grant_type: 'authorization_code' }), + tokenRequest({ client_secret: secret }, basic), + ]) expect((await POST(request)).status).toBe(404); + expect(authHandler).not.toHaveBeenCalled(); + }); + + it('preserves every Better Auth cookie and validates the exact host-only session cookie', async () => { + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.append('Set-Cookie', '__Host-hb_account_session=opaque-session; Path=/; HttpOnly; Secure; SameSite=Lax'); + headers.append('Set-Cookie', 'hb_account_auxiliary=opaque-state; Path=/; HttpOnly; Secure; SameSite=Lax'); + authHandler.mockResolvedValueOnce(Response.json({ redirect: false }, { headers })); + userFindUnique.mockResolvedValueOnce({ id: 'account-1', securityRevision: 3 }); + getSession + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ user: { id: 'account-1' }, session: { id: 'session-1' } }); + transaction.mockImplementationOnce(async (callback) => callback({ + $queryRaw: vi.fn(), + earlyBirdAuthSession: { + findUnique: vi.fn().mockResolvedValue({ + id: 'session-1', userId: 'account-1', securityRevision: 3, + user: { securityRevision: 3 }, + }), + deleteMany: vi.fn(), + }, + })); + + const response = await POST(new Request(`${origin}/api/account/auth/sign-in/email`, { + method: 'POST', + headers: { + host: 'account.harmonicbeacon.com', origin, + 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', + }, + body: JSON.stringify({ email: 'listener@example.invalid', password: '12345678' }), + })); + + expect(response.status).toBe(200); + expect(response.headers.getSetCookie()).toEqual([ + '__Host-hb_account_session=opaque-session; Path=/; HttpOnly; Secure; SameSite=Lax', + 'hb_account_auxiliary=opaque-state; Path=/; HttpOnly; Secure; SameSite=Lax', + ]); + expect(response.headers.getSetCookie().join('\n')).not.toContain('__Secure-__Host-'); + expect(getSession.mock.calls[1]?.[0]?.headers.get('cookie')) + .toBe('__Host-hb_account_session=opaque-session'); + }); + + it('uses the completed RP callback when the credential Location still points to Account', async () => { + const callback = 'https://listen.harmonicbeacon.com/api/account/callback' + + '?code=opaque-code&state=opaque-state'; + const headers = new Headers({ Location: '/account' }); + headers.append('Set-Cookie', + '__Host-hb_account_session=oauth-session.signature; Path=/; HttpOnly; Secure; SameSite=Lax'); + authHandler.mockResolvedValueOnce(Response.json({ + redirect: true, + url: callback, + }, { headers })); + userFindUnique.mockResolvedValueOnce({ id: 'account-1', securityRevision: 3 }); + getSession + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ user: { id: 'account-1' }, session: { id: 'session-1' } }); + transaction.mockImplementationOnce(async (callbackTransaction) => callbackTransaction({ + $queryRaw: vi.fn(), + earlyBirdAuthSession: { + findUnique: vi.fn().mockResolvedValue({ + id: 'session-1', userId: 'account-1', securityRevision: 3, + user: { securityRevision: 3 }, + }), + deleteMany: vi.fn(), + }, + })); + + const response = await POST(new Request(`${origin}/api/account/auth/sign-in/email`, { + method: 'POST', + headers: { + host: 'account.harmonicbeacon.com', origin, + 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', + }, + body: JSON.stringify({ + email: 'listener@example.invalid', password: '12345678', + oauth_query: 'signed-provider-query', + }), + })); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ status: 'authenticated', redirect: callback }); + expect(response.headers.getSetCookie()).toHaveLength(1); + }); + + it.each([ + ['underlying success', 200], + ['underlying rejection', 422], + ])('returns the same exact cookie-free signup acceptance for %s', async (_label, status) => { + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.append('Set-Cookie', '__Host-hb_account_session=must-not-leave-signup; Path=/; HttpOnly; Secure'); + authHandler.mockResolvedValueOnce(Response.json({ token: 'opaque' }, { status, headers })); + + const response = await POST(new Request(`${origin}/api/account/auth/sign-up/email`, { + method: 'POST', + headers: { + host: 'account.harmonicbeacon.com', origin, + 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', + 'x-hb-locale': 'es', + }, + body: JSON.stringify({ + name: 'Test Listener', email: 'listener@example.invalid', password: '12345678', + }), + })); + + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ status: 'accepted' }); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.getSetCookie()).toEqual([]); + expect(ensureVerificationMailQueued).toHaveBeenCalledWith('listener@example.invalid', 'es'); + expect(after).toHaveBeenCalledOnce(); + }); + + it('rejects an unforwardable successful sign-in and removes only its exact new session token', async () => { + const headers = new Headers(); + headers.append('Set-Cookie', '__Host-hb_account_session=newly-created-session-token.signature; Path=/; HttpOnly; Secure; SameSite=Lax'); + authHandler.mockResolvedValueOnce(Response.json({ + token: 'newly-created-session-token', redirect: false, + }, { headers })); + userFindUnique.mockResolvedValueOnce({ id: 'account-1', securityRevision: 3 }); + getSession.mockResolvedValueOnce(null); + + const response = await POST(new Request(`${origin}/api/account/auth/sign-in/email`, { + method: 'POST', + headers: { + host: 'account.harmonicbeacon.com', origin, + 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', + }, + body: JSON.stringify({ email: 'listener@example.invalid', password: '12345678' }), + })); + + expect(response.status).toBe(401); + expect(response.headers.getSetCookie()).toEqual([]); + expect(sessionDeleteMany).toHaveBeenCalledOnce(); + expect(sessionDeleteMany).toHaveBeenCalledWith({ + where: { token: 'newly-created-session-token' }, + }); + expect(sessionDeleteMany).not.toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ userId: 'account-1' }), + })); + }); + + it('wraps verified end-session success in a signed exact frontchannel redirect', async () => { + const payload = Buffer.from(JSON.stringify({ + iss: origin, aud: 'hb-listener', sid: 'central-sid', + })).toString('base64url'); + const query = new URLSearchParams({ + id_token_hint: `header.${payload}.signature`, client_id: 'hb-listener', + post_logout_redirect_uri: 'https://listen.harmonicbeacon.com/api/account/frontchannel-logout', + state: 'bounded_logout_state_1234', + }); + authHandler.mockResolvedValueOnce(new Response(null, { status: 204 })); + const response = await GET(new Request( + `${origin}/api/account/auth/oauth2/end-session?${query}`, + { headers: { host: 'account.harmonicbeacon.com' } }, + )); + expect(response.status).toBe(302); + const location = new URL(response.headers.get('location')!); + expect(location.origin + location.pathname) + .toBe('https://listen.harmonicbeacon.com/api/account/frontchannel-logout'); + expect(location.searchParams.get('logout_token')).toBeTruthy(); + expect(location.searchParams.get('state')).toBe('bounded_logout_state_1234'); + expect(revokeAccountSession).toHaveBeenCalledWith({}, 'central-sid'); + }); + + it('does not issue a signed frontchannel redirect when authoritative revocation fails', async () => { + const payload = Buffer.from(JSON.stringify({ + iss: origin, aud: 'hb-listener', sid: 'central-sid', + })).toString('base64url'); + const query = new URLSearchParams({ + id_token_hint: `header.${payload}.signature`, client_id: 'hb-listener', + post_logout_redirect_uri: 'https://listen.harmonicbeacon.com/api/account/frontchannel-logout', + state: 'bounded_logout_state_1234', + }); + authHandler.mockResolvedValueOnce(new Response(null, { status: 204 })); + transaction.mockRejectedValueOnce(new Error('database unavailable')); + + const response = await GET(new Request( + `${origin}/api/account/auth/oauth2/end-session?${query}`, + { headers: { host: 'account.harmonicbeacon.com' } }, + )); + expect(response.status).toBe(503); + expect(response.headers.get('location')).toBeNull(); + }); +}); diff --git a/src/app/api/account/auth/[...all]/route.ts b/src/app/api/account/auth/[...all]/route.ts new file mode 100644 index 00000000..42b4f08d --- /dev/null +++ b/src/app/api/account/auth/[...all]/route.ts @@ -0,0 +1,225 @@ +import { after } from 'next/server'; +import { Prisma } from '@prisma/client'; + +import { accountAuth } from '@/lib/account/auth'; +import { prisma } from '@/lib/db'; +import { + accountEnvironment, + accountOrigin, + ACCOUNT_SESSION_COOKIE, + activeAccountStaticClients, +} from '@/lib/account/config'; +import { accountAuthorityDatabaseReady } from '@/lib/account/authority-db'; +import { accountCredentialRequestAllowed, accountRequestAllowed } from '@/lib/account/request-boundary'; +import { accountEndSessionRequest } from '@/lib/account/request-boundary'; +import { accountFrontchannelURL } from '@/lib/account/frontchannel-token'; +import { revokeAccountSession } from '@/lib/account/revocation'; +import { enforceAccountCredentialFloor } from '@/lib/account/timing'; +import { + ensureVerificationMailQueued, + processVerificationMailOutbox, +} from '@/lib/account/mail-outbox'; + +async function safeRPRedirect(response: Response): Promise { + const body = await response.clone().json().catch(() => null) as { + url?: unknown; redirect_uri?: unknown; + } | null; + // Better Auth can preserve the credential callback (`/account`) in the + // Location header while returning the completed OAuth RP callback in its + // JSON body. A non-RP Location must not shadow a later valid RP result. + const candidates = [ + response.headers.get('location'), + typeof body?.redirect_uri === 'string' ? body.redirect_uri : null, + typeof body?.url === 'string' ? body.url : null, + ]; + const clients = activeAccountStaticClients(); + for (const candidate of candidates) { + if (!candidate) continue; + try { + const parsed = new URL(candidate); + if (!parsed.username && !parsed.password && !parsed.hash && clients.some((client) => { + const registered = new URL(client.redirectUri); + return registered.origin === parsed.origin && registered.pathname === parsed.pathname; + })) return parsed.toString(); + } catch { /* Continue to the provider's next result shape. */ } + } + return null; +} + +async function genericCredentialResponse(response: Response, path: string): Promise { + const signup = path === '/api/account/auth/sign-up/email'; + const successful = response.ok; + const headers = new Headers({ 'Cache-Control': 'private, no-store' }); + if (successful && !signup) { + for (const cookie of response.headers.getSetCookie()) { + headers.append('Set-Cookie', cookie); + } + } + const redirect = successful && !signup ? await safeRPRedirect(response) : null; + return Response.json({ + status: signup ? 'accepted' : successful ? 'authenticated' : 'unavailable', + ...(redirect ? { redirect } : {}), + }, { + status: signup ? 202 : successful ? 200 : 401, + headers, + }); +} + +async function credentialSignInRevisionStillValid(input: { + request: Request; + response: Response; + accountId: string; + securityRevision: number; +}): Promise { + if (!input.response.ok) return true; + const sessionCookies = input.response.headers.getSetCookie().filter((cookie) => + cookie.startsWith(`${ACCOUNT_SESSION_COOKIE}=`)); + const body = await input.response.clone().json().catch(() => null) as { token?: unknown } | null; + const bodyToken = typeof body?.token === 'string' && body.token.length <= 512 + ? body.token : null; + if (sessionCookies.length !== 1) { + if (bodyToken) { + await prisma.earlyBirdAuthSession.deleteMany({ where: { token: bodyToken } }) + .catch(() => undefined); + } + return false; + } + const token = sessionCookies[0].slice(ACCOUNT_SESSION_COOKIE.length + 1).split(';', 1)[0]; + if (!token) return false; + const headers = new Headers(input.request.headers); + headers.set('cookie', `${ACCOUNT_SESSION_COOKIE}=${token}`); + const created = await accountAuth().api.getSession({ headers }).catch(() => null); + if (!created || created.user.id !== input.accountId) { + // Better Auth may have persisted a session before an output seam + // failed. Delete only the exact token it just returned; never revoke + // pre-existing sessions for the account. + if (bodyToken) { + await prisma.earlyBirdAuthSession.deleteMany({ where: { token: bodyToken } }) + .catch(() => undefined); + } + return false; + } + return prisma.$transaction(async (transaction) => { + // This lock gives reset/change a total order with the final sign-in + // check. If sign-in wins, the later mutation revokes this session; if + // the mutation wins, the old-password sign-in is rejected here. + await transaction.$queryRaw`SELECT "id" FROM "early_bird_users" WHERE "id" = ${input.accountId} FOR UPDATE`; + const persisted = await transaction.earlyBirdAuthSession.findUnique({ + where: { id: created.session.id }, + select: { + id: true, userId: true, securityRevision: true, + user: { select: { securityRevision: true } }, + }, + }); + const valid = Boolean(persisted && persisted.userId === input.accountId && + persisted.securityRevision === input.securityRevision && + persisted.user.securityRevision === input.securityRevision); + if (!valid && persisted) { + await transaction.earlyBirdAuthSession.deleteMany({ where: { id: persisted.id } }); + } + return valid; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); +} + +async function handler(request: Request): Promise { + const startedAt = Date.now(); + const path = new URL(request.url).pathname; + const credentialRequest = request.method === 'POST' && + (path === '/api/account/auth/sign-up/email' || path === '/api/account/auth/sign-in/email'); + const credentialBody = credentialRequest + ? await request.clone().json().catch(() => null) as { email?: unknown } | null + : null; + if (!await accountAuthorityDatabaseReady()) { + return Response.json({ error: 'service_unavailable' }, { + status: 503, headers: { 'Cache-Control': 'no-store' }, + }); + } + if (!await accountRequestAllowed(request)) { + return Response.json({ error: 'not_found' }, { + status: 404, + headers: { 'Cache-Control': 'no-store' }, + }); + } + if (!await accountCredentialRequestAllowed(request)) { + if (credentialRequest) await enforceAccountCredentialFloor(startedAt); + return Response.json({ error: 'request_unavailable' }, { + status: 429, + headers: { 'Cache-Control': 'no-store', 'Retry-After': '60' }, + }); + } + const revisionFence = path === '/api/account/auth/sign-in/email' && + typeof credentialBody?.email === 'string' + ? await prisma.earlyBirdUser.findUnique({ + where: { email: credentialBody.email.trim().toLowerCase() }, + select: { id: true, securityRevision: true }, + }) + : null; + const browserSession = await accountAuth().api.getSession({ headers: request.headers }); + if (browserSession) { + const persisted = await prisma.earlyBirdAuthSession.findUnique({ + where: { id: browserSession.session.id }, select: { authorityEnvironment: true }, + }); + if (persisted?.authorityEnvironment !== accountEnvironment()) { + return Response.json({ error: 'invalid_session' }, { + status: 401, headers: { 'Cache-Control': 'no-store' }, + }); + } + } + let response = await accountAuth().handler(request); + if (path === '/api/account/auth/oauth2/end-session') { + const admitted = accountEndSessionRequest(request); + if (!admitted || response.status < 200 || response.status >= 400) return response; + try { + // oauth-provider 1.6.30 treats session deletion as best-effort. + // Frontchannel success is stricter: only advertise logout after our + // own transaction has removed the exact sid and its bearer tokens. + await prisma.$transaction(async (transaction) => { + await revokeAccountSession(transaction, admitted.sid); + }); + } catch { + return Response.json({ error: 'service_unavailable' }, { + status: 503, headers: { 'Cache-Control': 'private, no-store' }, + }); + } + const signed = new URL(accountFrontchannelURL({ + url: admitted.postLogoutRedirectUri, + issuer: accountOrigin(), + audience: admitted.clientId, + sid: admitted.sid, + clientSecret: admitted.clientSecret, + })); + signed.searchParams.set('state', admitted.state); + return new Response(null, { + status: 302, + headers: { + Location: signed.toString(), + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', + }, + }); + } + if (!credentialRequest) return response; + if (revisionFence && !await credentialSignInRevisionStillValid({ + request, response, + accountId: revisionFence.id, + securityRevision: revisionFence.securityRevision, + })) { + response = Response.json({ error: 'request_unavailable' }, { + status: 401, headers: { 'Cache-Control': 'private, no-store' }, + }); + } + if (path === '/api/account/auth/sign-up/email' && typeof credentialBody?.email === 'string') { + const explicitLocale = request.headers.get('x-hb-locale'); + const locale = explicitLocale === 'es' || explicitLocale === 'en' + ? explicitLocale + : request.headers.get('accept-language')?.toLowerCase().startsWith('es') + ? 'es' as const : 'en' as const; + await ensureVerificationMailQueued(credentialBody.email, locale).catch(() => undefined); + after(async () => { await processVerificationMailOutbox().catch(() => undefined); }); + } + await enforceAccountCredentialFloor(startedAt); + return genericCredentialResponse(response, path); +} + +export const GET = handler; +export const POST = handler; diff --git a/src/app/api/account/callback/route.test.ts b/src/app/api/account/callback/route.test.ts new file mode 100644 index 00000000..8ee237af --- /dev/null +++ b/src/app/api/account/callback/route.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +const complete = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/listener/account-rp', async (importOriginal) => ({ + ...await importOriginal(), + completeListenerAccountCallback: complete, +})); + +import { GET } from './route'; + +function callback(cookie: string) { + return new Request('https://listen.harmonicbeacon.com/api/account/callback?code=code&state=state', { + headers: { host: 'listen.harmonicbeacon.com', cookie }, + }); +} + +describe('Listener RP callback attempt-cookie boundary', () => { + it.each([ + '__Host-hb_listener_account_attempt=%', + '__Host-hb_listener_account_attempt=one; __Host-hb_listener_account_attempt=two', + `__Host-hb_listener_account_attempt=${'a'.repeat(2049)}`, + ])('turns malformed/duplicate/oversized state into a clean auth error', async (cookie) => { + complete.mockResolvedValueOnce(null); + const response = await GET(callback(cookie)); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/?authError=1'); + expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); + expect(complete).toHaveBeenCalledWith(expect.objectContaining({ attemptCookie: undefined })); + }); +}); diff --git a/src/app/api/account/callback/route.ts b/src/app/api/account/callback/route.ts new file mode 100644 index 00000000..18711195 --- /dev/null +++ b/src/app/api/account/callback/route.ts @@ -0,0 +1,30 @@ +import { + completeListenerAccountCallback, + listenerAccountCookie, + listenerAttemptCookie, + readListenerAccountAttemptCookie, +} from '@/lib/listener/account-rp'; +import { isCanonicalListenerHost, isListenerStagingHost } from '@/lib/listener/public-discovery'; + +export async function GET(request: Request): Promise { + const headers = new Headers(request.headers); + if (!isCanonicalListenerHost(headers) && !isListenerStagingHost(headers)) return new Response(null, { status: 404 }); + const url = new URL(request.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const attemptCookie = readListenerAccountAttemptCookie(headers); + const completed = code && state ? await completeListenerAccountCallback({ + headers, code, state, attemptCookie: attemptCookie ?? undefined, + }).catch(() => null) : null; + const responseHeaders = new Headers({ + Location: completed ? '/' : '/?authError=1', + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', + }); + responseHeaders.append('Set-Cookie', listenerAttemptCookie('', 0)); + if (completed) responseHeaders.append('Set-Cookie', listenerAccountCookie(completed.token)); + return new Response(null, { + status: 302, + headers: responseHeaders, + }); +} diff --git a/src/app/api/account/email-action/route.test.ts b/src/app/api/account/email-action/route.test.ts new file mode 100644 index 00000000..342d7ff7 --- /dev/null +++ b/src/app/api/account/email-action/route.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ complete: vi.fn(), rate: vi.fn() })); +vi.mock('@/lib/account/credential-actions', () => ({ completeEmailAction: mocks.complete })); +vi.mock('@/lib/account/rate-limit', () => ({ consumeAccountRateLimit: mocks.rate })); +vi.mock('@/lib/account/config', () => ({ + isAccountHost: () => true, + accountRateSecret: () => 'rate-secret-at-least-thirty-two-characters', +})); + +import { POST } from './route'; + +function request(token: string) { + return new Request('https://account.harmonicbeacon.com/api/account/email-action', { + method: 'POST', + headers: { host: 'account.harmonicbeacon.com', 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }); +} + +describe('Account email action admission', () => { + beforeEach(() => vi.clearAllMocks()); + + it('rejects malformed tokens without touching durable action authority', async () => { + expect((await POST(request('invalid'))).status).toBe(400); + expect(mocks.rate).not.toHaveBeenCalled(); + expect(mocks.complete).not.toHaveBeenCalled(); + }); + + it('does not enter token transactions when durable admission is blocked', async () => { + mocks.rate.mockResolvedValue(false); + expect((await POST(request('A'.repeat(43)))).status).toBe(429); + expect(mocks.complete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/account/email-action/route.ts b/src/app/api/account/email-action/route.ts new file mode 100644 index 00000000..c8778ba6 --- /dev/null +++ b/src/app/api/account/email-action/route.ts @@ -0,0 +1,34 @@ +import { completeEmailAction } from '@/lib/account/credential-actions'; +import { accountRateSecret, isAccountHost } from '@/lib/account/config'; +import { digestAccountActionToken } from '@/lib/account/action-tokens'; +import { consumeAccountRateLimit } from '@/lib/account/rate-limit'; + +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const body = await request.json().catch(() => null) as { + token?: unknown; + } | null; + if (typeof body?.token !== 'string' || !/^[A-Za-z0-9_-]{40,64}$/.test(body.token)) { + return Response.json({ status: false }, { status: 400 }); + } + const secret = accountRateSecret(); + if (!secret || !await consumeAccountRateLimit({ + request, + email: digestAccountActionToken(body.token), + purpose: 'email-action', + secret, + maxPerEmail: 6, + maxPerOrigin: 30, + maxGlobal: 2_000, + })) return Response.json({ status: false }, { + status: 429, + headers: { 'Cache-Control': 'private, no-store', 'Retry-After': '60' }, + }); + const status = await completeEmailAction(body.token); + return Response.json({ status }, { + status: status ? 200 : 400, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} diff --git a/src/app/api/account/email/change/request/route.ts b/src/app/api/account/email/change/request/route.ts new file mode 100644 index 00000000..af677362 --- /dev/null +++ b/src/app/api/account/email/change/request/route.ts @@ -0,0 +1,14 @@ +import { requestEmailChange } from '@/lib/account/credential-actions'; +import { isAccountHost } from '@/lib/account/config'; + +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const body = await request.json().catch(() => null) as { email?: unknown; password?: unknown } | null; + const status = await requestEmailChange(request, body?.email, body?.password); + return Response.json({ status }, { + status: status ? 202 : 400, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} diff --git a/src/app/api/account/frontchannel-logout/__tests__/route.test.ts b/src/app/api/account/frontchannel-logout/__tests__/route.test.ts new file mode 100644 index 00000000..12150fd0 --- /dev/null +++ b/src/app/api/account/frontchannel-logout/__tests__/route.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = vi.hoisted(() => ({ deleteMany: vi.fn() })); +vi.mock('@/lib/db', () => ({ prisma: { listenerAccountSession: db } })); + +import { signAccountFrontchannelLogout } from '@/lib/account/frontchannel-token'; +import { GET } from '../route'; + +describe('Listener signed frontchannel logout boundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENABLED', '1'); + db.deleteMany.mockResolvedValue({ count: 1 }); + }); + afterEach(() => vi.unstubAllEnvs()); + + it.each([ + ['listen.harmonicbeacon.com', 'https://account.harmonicbeacon.com', 'hb-listener', 'p'], + ['earlybirds-staging.harmonicbeacon.com', 'https://account-staging.harmonicbeacon.com', 'hb-listener-staging', 's'], + ])('trusts only a signed matching Account issuer on %s', async (host, issuer, audience, secretChar) => { + const staging = host.startsWith('earlybirds-staging'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENVIRONMENT', staging ? 'staging' : 'production'); + vi.stubEnv(staging ? 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING' + : 'BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', secretChar.repeat(32)); + vi.stubEnv(staging ? 'BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING' + : 'BEACON_LISTENER_ACCOUNT_STATE_SECRET', (staging ? 'b' : 'a').repeat(32)); + const logoutToken = signAccountFrontchannelLogout({ + issuer, audience, sid: 'central-session', clientSecret: secretChar.repeat(32), + }); + const response = await GET(new Request( + `https://${host}/api/account/frontchannel-logout?logout_token=${encodeURIComponent(logoutToken)}`, + { headers: { host } }, + )); + expect(response.status).toBe(204); + expect(response.headers.get('content-security-policy')) + .toBe(`default-src 'none'; frame-ancestors ${issuer}`); + expect(response.headers.get('set-cookie')) + .toContain('__Host-hb_listener_account_auto_handoff=1'); + expect(db.deleteMany).toHaveBeenCalledWith({ + where: { issuer, sid: 'central-session' }, + }); + }); + + it('does not turn an unsigned cross-site GET into logout CSRF', async () => { + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENVIRONMENT', 'production'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', 'p'.repeat(32)); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_STATE_SECRET', 'a'.repeat(32)); + const response = await GET(new Request( + 'https://listen.harmonicbeacon.com/api/account/frontchannel-logout', + { headers: { host: 'listen.harmonicbeacon.com', cookie: '__Host-hb_listener_account=local-cookie' } }, + )); + expect(response.status).toBe(400); + expect(db.deleteMany).not.toHaveBeenCalled(); + }); + + it('does not revoke a session for a tampered cross-site token', async () => { + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENVIRONMENT', 'production'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', 'p'.repeat(32)); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_STATE_SECRET', 'a'.repeat(32)); + const valid = signAccountFrontchannelLogout({ + issuer: 'https://account.harmonicbeacon.com', audience: 'hb-listener', + sid: 'central-session', clientSecret: 'p'.repeat(32), + }); + const [payload, signature] = valid.split('.'); + const tampered = `${payload}.${signature[0] === 'A' ? 'B' : 'A'}${signature.slice(1)}`; + const response = await GET(new Request( + `https://listen.harmonicbeacon.com/api/account/frontchannel-logout?logout_token=${tampered}`, + { headers: { host: 'listen.harmonicbeacon.com' } }, + )); + expect(response.status).toBe(400); + expect(db.deleteMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/account/frontchannel-logout/route.ts b/src/app/api/account/frontchannel-logout/route.ts new file mode 100644 index 00000000..54abb6b1 --- /dev/null +++ b/src/app/api/account/frontchannel-logout/route.ts @@ -0,0 +1,40 @@ +import { prisma } from '@/lib/db'; +import { verifyAccountFrontchannelLogout } from '@/lib/account/frontchannel-token'; +import { + listenerAccountCookie, + listenerAutomaticHandoffCookie, + listenerAccountRPConfig, +} from '@/lib/listener/account-rp'; +import { isCanonicalListenerHost, isListenerStagingHost } from '@/lib/listener/public-discovery'; + +export async function GET(request: Request): Promise { + const headers = new Headers(request.headers); + if (!isCanonicalListenerHost(headers) && !isListenerStagingHost(headers)) { + return new Response(null, { status: 404 }); + } + const config = listenerAccountRPConfig(headers); + const token = new URL(request.url).searchParams.get('logout_token') ?? ''; + const authority = verifyAccountFrontchannelLogout({ + token, issuer: config.issuer, audience: config.clientId, + clientSecret: config.clientSecret, + }); + if (!authority) return new Response(null, { + status: 400, headers: { 'Cache-Control': 'private, no-store' }, + }); + // This endpoint is loaded in a cross-site hidden iframe, so SameSite=Lax + // correctly withholds the Listener cookie. The signed issuer/sid binding is + // the revocation authority; clearing the browser cookie remains best-effort. + await prisma.listenerAccountSession.deleteMany({ + where: { issuer: authority.iss, sid: authority.sid }, + }); + const responseHeaders = new Headers({ + 'Cache-Control': 'private, no-store', + 'Content-Security-Policy': `default-src 'none'; frame-ancestors ${config.issuer}`, + }); + responseHeaders.append('Set-Cookie', listenerAccountCookie('', 0)); + responseHeaders.append('Set-Cookie', listenerAutomaticHandoffCookie('1')); + return new Response(null, { + status: 204, + headers: responseHeaders, + }); +} diff --git a/src/app/api/account/health/ready/route.ts b/src/app/api/account/health/ready/route.ts new file mode 100644 index 00000000..be5f049a --- /dev/null +++ b/src/app/api/account/health/ready/route.ts @@ -0,0 +1,83 @@ +import { prisma } from '@/lib/db'; +import { accountAuthorityDatabaseReady } from '@/lib/account/authority-db'; +import { + accountRateSecret, + accountSecret, + accountSocialProviderConfiguration, + accountStaticClientSecrets, + activeAccountStaticClients, + isAccountHost, +} from '@/lib/account/config'; +import { hashAccountClientSecret } from '@/lib/account/client-secret'; +import { accountMailReady } from '@/lib/account/mail'; +import { accountMailOutboxReady } from '@/lib/account/mail-outbox'; + +export async function GET(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const checks: Record = { + runtime: process.env.BEACON_ACCOUNT_RUNTIME === '1', + authSecret: false, + rateSecret: accountRateSecret() !== null, + mail: (await accountMailReady()) && accountMailOutboxReady(), + databaseIssuer: await accountAuthorityDatabaseReady(), + clients: false, + jwks: false, + providers: false, + }; + try { checks.authSecret = accountSecret().length >= 32; } catch { checks.authSecret = false; } + try { + accountSocialProviderConfiguration(); + checks.providers = true; + } catch { checks.providers = false; } + try { + const configured = new Map(accountStaticClientSecrets().map((client) => [client.clientId, client.clientSecret])); + const expected = activeAccountStaticClients(); + const persisted = await prisma.beaconOAuthClient.findMany({ + where: { disabled: false }, + select: { + clientId: true, clientSecret: true, redirectUris: true, + postLogoutRedirectUris: true, disabled: true, skipConsent: true, + enableEndSession: true, subjectType: true, type: true, public: true, + requirePKCE: true, tokenEndpointAuthMethod: true, + grantTypes: true, responseTypes: true, scopes: true, + }, + }); + checks.clients = persisted.length === expected.length && expected.every((client) => { + const row = persisted.find((candidate) => candidate.clientId === client.clientId); + const secret = configured.get(client.clientId); + return Boolean(row && secret && row.clientSecret === hashAccountClientSecret(secret) && + row.disabled === false && row.skipConsent === true && row.enableEndSession === true && + row.subjectType === 'public' && row.type === 'web' && row.public === false && + row.requirePKCE === true && row.tokenEndpointAuthMethod === 'client_secret_basic' && + row.grantTypes.length === 1 && row.grantTypes[0] === 'authorization_code' && + row.responseTypes.length === 1 && row.responseTypes[0] === 'code' && + row.scopes.length === 2 && row.scopes[0] === 'openid' && row.scopes[1] === 'profile' && + row.redirectUris.length === 1 && row.redirectUris[0] === client.redirectUri && + row.postLogoutRedirectUris.length === 1 && + row.postLogoutRedirectUris[0] === client.postLogoutRedirectUri); + }); + checks.jwks = await prisma.beaconJwks.count({ where: { + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + } }) > 0; + } catch { checks.clients = false; checks.jwks = false; } + const ready = Object.values(checks).every(Boolean); + const publicChecks = { + database: checks.databaseIssuer ? 'ok' : 'error', + mail: checks.mail ? 'ok' : 'error', + issuer: checks.runtime && checks.authSecret && checks.rateSecret ? 'ok' : 'error', + jwks: checks.jwks ? 'ok' : 'error', + clients: checks.clients ? 'ok' : 'error', + providers: checks.providers ? 'ok' : 'error', + }; + return Response.json({ + status: ready ? 'ok' : 'error', + gitSha: process.env.BEACON_GIT_SHA ?? 'unknown', + schemaVersion: process.env.BEACON_DATABASE_SCHEMA_VERSION ?? 'unknown', + checks: publicChecks, + }, { + status: ready ? 200 : 503, + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/src/app/api/account/health/route.ts b/src/app/api/account/health/route.ts new file mode 100644 index 00000000..309c97f2 --- /dev/null +++ b/src/app/api/account/health/route.ts @@ -0,0 +1,10 @@ +import { isAccountHost } from '@/lib/account/config'; + +export async function GET(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + return Response.json({ status: 'ok', service: 'beacon-account' }, { + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/src/app/api/account/login/route.test.ts b/src/app/api/account/login/route.test.ts new file mode 100644 index 00000000..d5a5d9eb --- /dev/null +++ b/src/app/api/account/login/route.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GET } from './route'; + +function request(path = '/api/account/login', host = 'listen.harmonicbeacon.com') { + return new Request(`http://127.0.0.1:3000${path}`, { headers: { host } }); +} + +describe('Listener Account login handoff', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENVIRONMENT', 'production'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', 'p'.repeat(32)); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_STATE_SECRET', 's'.repeat(32)); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('preserves explicit sign-in and clears any logout suppression', async () => { + const response = await GET(request()); + expect(response.status).toBe(302); + expect(response.headers.get('location')) + .toMatch(/^https:\/\/account\.harmonicbeacon\.com\/api\/account\/auth\/oauth2\/authorize\?/); + expect(response.headers.get('set-cookie')).toContain('__Host-hb_listener_account_attempt='); + expect(response.headers.get('set-cookie')) + .toContain('__Host-hb_listener_account_auto_handoff=;'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('preflights a healthy issuer before the automatic top-level handoff', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + const response = await GET(request('/api/account/login?auto=1')); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toContain('account.harmonicbeacon.com'); + expect(response.headers.get('set-cookie')).toContain('__Host-hb_listener_account_attempt='); + expect(response.headers.get('set-cookie')) + .not.toContain('__Host-hb_listener_account_auto_handoff=;'); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('returns to a bounded visible retry when Account is unavailable', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 503 })); + const response = await GET(request('/api/account/login?auto=1')); + expect(response.status).toBe(302); + expect(response.headers.get('location')) + .toBe('https://listen.harmonicbeacon.com/?accountUnavailable=1'); + expect(response.headers.get('set-cookie')) + .toContain('__Host-hb_listener_account_auto_handoff=1'); + expect(response.headers.get('set-cookie')) + .not.toContain('__Host-hb_listener_account_attempt='); + }); + + it('does not accept an internal or sibling Host', async () => { + expect((await GET(request('/api/account/login?auto=1', '127.0.0.1:3000'))).status) + .toBe(404); + expect((await GET(request('/api/account/login?auto=1', 'live.harmonicbeacon.com'))).status) + .toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/account/login/route.ts b/src/app/api/account/login/route.ts new file mode 100644 index 00000000..b3c2af0f --- /dev/null +++ b/src/app/api/account/login/route.ts @@ -0,0 +1,58 @@ +import { + createListenerAccountAuthorization, + listenerAttemptCookie, + listenerAutomaticHandoffCookie, + listenerAccountRPConfig, +} from '@/lib/listener/account-rp'; +import { isCanonicalListenerHost, isListenerStagingHost } from '@/lib/listener/public-discovery'; + +async function accountReadyForAutomaticHandoff(headers: Headers): Promise { + try { + const config = listenerAccountRPConfig(headers); + const response = await fetch(new URL('/api/account/health/ready', config.issuer), { + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(2_000), + }); + if (!response.ok) return false; + const body = await response.json().catch(() => null) as { status?: unknown } | null; + return body?.status === 'ok'; + } catch { + return false; + } +} + +export async function GET(request: Request): Promise { + const headers = new Headers(request.headers); + if (!isCanonicalListenerHost(headers) && !isListenerStagingHost(headers)) return new Response(null, { status: 404 }); + const automaticValues = new URL(request.url).searchParams.getAll('auto'); + const automatic = automaticValues.length === 1 && automaticValues[0] === '1'; + if (automatic && !await accountReadyForAutomaticHandoff(headers)) { + const listenerOrigin = isListenerStagingHost(headers) + ? 'https://earlybirds-staging.harmonicbeacon.com' + : 'https://listen.harmonicbeacon.com'; + return new Response(null, { + status: 302, + headers: { + Location: `${listenerOrigin}/?accountUnavailable=1`, + 'Set-Cookie': listenerAutomaticHandoffCookie('1', 60), + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', + }, + }); + } + try { + const authorization = createListenerAccountAuthorization(headers); + const responseHeaders = new Headers({ + Location: authorization.url.toString(), + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', + }); + responseHeaders.append('Set-Cookie', listenerAttemptCookie(authorization.cookie)); + if (!automatic) responseHeaders.append('Set-Cookie', listenerAutomaticHandoffCookie('', 0)); + return new Response(null, { + status: 302, + headers: responseHeaders, + }); + } catch { return Response.json({ error: 'identity_unavailable' }, { status: 503 }); } +} diff --git a/src/app/api/account/logout/all/route.ts b/src/app/api/account/logout/all/route.ts new file mode 100644 index 00000000..9920b482 --- /dev/null +++ b/src/app/api/account/logout/all/route.ts @@ -0,0 +1,48 @@ +import { prisma } from '@/lib/db'; +import { currentAccountSession } from '@/lib/account/auth'; +import { + ACCOUNT_SESSION_COOKIE, activeAccountStaticClients, accountOrigin, + accountStaticClientSecrets, isAccountHost, +} from '@/lib/account/config'; +import { accountFrontchannelURL } from '@/lib/account/frontchannel-token'; +import { accountLogoutInitiationValid } from '@/lib/account/logout-initiation'; +import { revokeAllAccountSessions } from '@/lib/account/revocation'; + +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const session = await currentAccountSession(request.headers); + if (!session) return Response.json({ error: 'unauthorized' }, { status: 401 }); + const body = await request.json().catch(() => null) as { initiation?: unknown } | null; + if (body && Object.prototype.hasOwnProperty.call(body, 'initiation') && + typeof body.initiation !== 'string') return Response.json({ error: 'invalid_initiation' }, { + status: 403, headers: { 'Cache-Control': 'private, no-store' }, + }); + const initiation = typeof body?.initiation === 'string' ? body.initiation : undefined; + if (!accountLogoutInitiationValid({ + token: initiation, sid: session.session.id, mode: 'all', + })) return Response.json({ error: 'invalid_initiation' }, { + status: 403, headers: { 'Cache-Control': 'private, no-store' }, + }); + const secrets = new Map(accountStaticClientSecrets().map((client) => [client.clientId, client.clientSecret])); + const frontchannel = activeAccountStaticClients().flatMap((client) => { + const clientSecret = secrets.get(client.clientId); + return clientSecret ? [accountFrontchannelURL({ + url: client.postLogoutRedirectUri, issuer: accountOrigin(), + audience: client.clientId, sid: session.session.id, clientSecret, + })] : []; + }); + await prisma.$transaction(async (transaction) => { + await transaction.earlyBirdUser.update({ + where: { id: session.user.id }, data: { securityRevision: { increment: 1 } }, + }); + await revokeAllAccountSessions(transaction, session.user.id); + }); + return Response.json({ + frontchannel, + }, { headers: { + 'Cache-Control': 'private, no-store', + 'Set-Cookie': `${ACCOUNT_SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`, + } }); +} diff --git a/src/app/api/account/logout/current/route.ts b/src/app/api/account/logout/current/route.ts new file mode 100644 index 00000000..6e6ba6a4 --- /dev/null +++ b/src/app/api/account/logout/current/route.ts @@ -0,0 +1,43 @@ +import { currentAccountSession } from '@/lib/account/auth'; +import { prisma } from '@/lib/db'; +import { ACCOUNT_SESSION_COOKIE, activeAccountStaticClients, isAccountHost } from '@/lib/account/config'; +import { accountOrigin, accountStaticClientSecrets } from '@/lib/account/config'; +import { accountFrontchannelURL } from '@/lib/account/frontchannel-token'; +import { accountLogoutInitiationValid } from '@/lib/account/logout-initiation'; +import { revokeAccountSession } from '@/lib/account/revocation'; + +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const session = await currentAccountSession(request.headers); + if (!session) return Response.json({ frontchannel: [] }, { status: 401 }); + const body = await request.json().catch(() => null) as { initiation?: unknown } | null; + if (body && Object.prototype.hasOwnProperty.call(body, 'initiation') && + typeof body.initiation !== 'string') return Response.json({ error: 'invalid_initiation' }, { + status: 403, headers: { 'Cache-Control': 'private, no-store' }, + }); + const initiation = typeof body?.initiation === 'string' ? body.initiation : undefined; + if (!accountLogoutInitiationValid({ + token: initiation, sid: session.session.id, mode: 'current', + })) return Response.json({ error: 'invalid_initiation' }, { + status: 403, headers: { 'Cache-Control': 'private, no-store' }, + }); + const secrets = new Map(accountStaticClientSecrets().map((client) => [client.clientId, client.clientSecret])); + const frontchannel = activeAccountStaticClients().flatMap((client) => { + const clientSecret = secrets.get(client.clientId); + return clientSecret ? [accountFrontchannelURL({ + url: client.postLogoutRedirectUri, issuer: accountOrigin(), + audience: client.clientId, sid: session.session.id, clientSecret, + })] : []; + }); + await prisma.$transaction((transaction) => revokeAccountSession(transaction, session.session.id)); + return Response.json({ + frontchannel, + }, { + headers: { + 'Cache-Control': 'private, no-store', + 'Set-Cookie': `${ACCOUNT_SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`, + }, + }); +} diff --git a/src/app/api/account/password/change/route.ts b/src/app/api/account/password/change/route.ts new file mode 100644 index 00000000..2fafd692 --- /dev/null +++ b/src/app/api/account/password/change/route.ts @@ -0,0 +1,21 @@ +import { changeAccountPassword } from '@/lib/account/credential-actions'; +import { ACCOUNT_SESSION_COOKIE, isAccountHost } from '@/lib/account/config'; + +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const body = await request.json().catch(() => null) as { + currentPassword?: unknown; newPassword?: unknown; + } | null; + const status = await changeAccountPassword(request, body?.currentPassword, body?.newPassword); + return Response.json({ status }, { + status: status ? 200 : 400, + headers: { + 'Cache-Control': 'private, no-store', + ...(status ? { + 'Set-Cookie': `${ACCOUNT_SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`, + } : {}), + }, + }); +} diff --git a/src/app/api/account/password/reset/complete/route.ts b/src/app/api/account/password/reset/complete/route.ts new file mode 100644 index 00000000..c1e57684 --- /dev/null +++ b/src/app/api/account/password/reset/complete/route.ts @@ -0,0 +1,28 @@ +import { completePasswordReset } from '@/lib/account/credential-actions'; +import { accountRateSecret, isAccountHost } from '@/lib/account/config'; +import { digestAccountActionToken } from '@/lib/account/action-tokens'; +import { consumeAccountRateLimit } from '@/lib/account/rate-limit'; + +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const body = await request.json().catch(() => null) as { token?: unknown; password?: unknown } | null; + const rateSecret = accountRateSecret(); + if (!rateSecret || !await consumeAccountRateLimit({ + request, + email: typeof body?.token === 'string' + ? digestAccountActionToken(body.token) + : 'malformed', + purpose: 'reset-complete', secret: rateSecret, + maxPerEmail: 3, maxPerOrigin: 20, maxGlobal: 1_000, + })) return Response.json({ status: false }, { + status: 429, headers: { 'Cache-Control': 'private, no-store' }, + }); + const status = typeof body?.token === 'string' && typeof body.password === 'string' && + await completePasswordReset(body.token, body.password); + return Response.json({ status }, { + status: status ? 200 : 400, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} diff --git a/src/app/api/account/password/reset/request/route.ts b/src/app/api/account/password/reset/request/route.ts new file mode 100644 index 00000000..419a21cb --- /dev/null +++ b/src/app/api/account/password/reset/request/route.ts @@ -0,0 +1,14 @@ +import { requestPasswordReset } from '@/lib/account/credential-actions'; +import { isAccountHost } from '@/lib/account/config'; + +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const body = await request.json().catch(() => null) as { email?: unknown } | null; + await requestPasswordReset(request, body?.email); + return Response.json({ status: true }, { + status: 202, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} diff --git a/src/app/api/account/profile/route.ts b/src/app/api/account/profile/route.ts new file mode 100644 index 00000000..3783c8cc --- /dev/null +++ b/src/app/api/account/profile/route.ts @@ -0,0 +1,30 @@ +import { prisma } from '@/lib/db'; +import { currentAccountSession } from '@/lib/account/auth'; +import { isAccountHost } from '@/lib/account/config'; +import { normalizeBeaconDisplayName } from '@/lib/account/profile'; + +function noStore(body: unknown, status = 200) { + return Response.json(body, { status, headers: { 'Cache-Control': 'private, no-store' } }); +} +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host)) { + return new Response(null, { status: 404 }); + } + const session = await currentAccountSession(request.headers); + if (!session) return noStore({ error: 'unauthorized' }, 401); + const body = await request.json().catch(() => null) as { displayName?: unknown; revision?: unknown } | null; + const displayName = normalizeBeaconDisplayName(body?.displayName); + if (!displayName || !Number.isSafeInteger(body?.revision) || Number(body?.revision) < 1) { + return noStore({ error: 'invalid_request' }, 400); + } + const updated = await prisma.beaconProfile.updateMany({ + where: { accountId: session.user.id, revision: Number(body?.revision) }, + data: { displayName, revision: { increment: 1 } }, + }); + if (updated.count !== 1) return noStore({ error: 'revision_conflict' }, 409); + const profile = await prisma.beaconProfile.findUniqueOrThrow({ + where: { accountId: session.user.id }, + select: { accountId: true, displayName: true, revision: true }, + }); + return noStore(profile); +} diff --git a/src/app/api/account/session-status/__tests__/route.test.ts b/src/app/api/account/session-status/__tests__/route.test.ts new file mode 100644 index 00000000..30930b14 --- /dev/null +++ b/src/app/api/account/session-status/__tests__/route.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = vi.hoisted(() => ({ findUnique: vi.fn() })); +const ready = vi.hoisted(() => vi.fn()); +const limited = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: { earlyBirdAuthSession: { findUnique: db.findUnique } }, +})); +vi.mock('@/lib/account/authority-db', () => ({ accountAuthorityDatabaseReady: ready })); +vi.mock('@/lib/account/rate-limit', () => ({ consumeAccountRateLimit: limited })); + +import { POST } from '../route'; + +const secret = 'complete-rp-secret-that-is-at-least-thirty-two-characters'; + +function request(body = new URLSearchParams({ sid: 'central-session', sub: 'opaque-account' }), + contentType = 'application/x-www-form-urlencoded') { + return new Request('https://account.harmonicbeacon.com/api/account/session-status', { + method: 'POST', + headers: { + host: 'account.harmonicbeacon.com', + authorization: `Basic ${Buffer.from(`hb-listener:${secret}`).toString('base64')}`, + 'content-type': contentType, + }, + body, + }); +} + +describe('Account RP session status backchannel', () => { + afterEach(() => vi.unstubAllEnvs()); + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('BEACON_ACCOUNT_BASE_URL', 'https://account.harmonicbeacon.com'); + vi.stubEnv('BEACON_ACCOUNT_CLIENT_SECRET_HB_LISTENER', secret); + vi.stubEnv('BEACON_ACCOUNT_CLIENT_SECRET_HB_LIVE', `${secret}-live`); + vi.stubEnv('BEACON_ACCOUNT_RATE_SECRET', `${secret}-rate`); + ready.mockResolvedValue(true); + limited.mockResolvedValue(true); + db.findUnique.mockResolvedValue({ + userId: 'opaque-account', + expiresAt: new Date(Date.now() + 60_000), + securityRevision: 3, + authorityEnvironment: 'production', + user: { securityRevision: 3 }, + }); + }); + + it('returns the exact issuer-bound active response with no PII', async () => { + const response = await POST(request()); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ + active: true, + iss: 'https://account.harmonicbeacon.com', + sub: 'opaque-account', + sid: 'central-session', + }); + }); + + it('rejects non-form requests before parsing credentials or state', async () => { + const response = await POST(request(new URLSearchParams(), 'application/json')); + expect(response.status).toBe(415); + expect(await response.json()).toEqual({ active: false }); + expect(db.findUnique).not.toHaveBeenCalled(); + }); + + it('returns no subject/session fields when inactive', async () => { + db.findUnique.mockResolvedValue(null); + const response = await POST(request()); + expect(await response.json()).toEqual({ active: false }); + }); + + it('compares the full confidential-client secret without stripping a prefix', async () => { + const wrong = request(); + wrong.headers.set('authorization', `Basic ${Buffer.from( + `hb-listener:${secret.replace('complete-', '')}`, + ).toString('base64')}`); + const response = await POST(wrong); + expect(response.status).toBe(401); + expect(db.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/account/session-status/route.ts b/src/app/api/account/session-status/route.ts new file mode 100644 index 00000000..e6b67fbd --- /dev/null +++ b/src/app/api/account/session-status/route.ts @@ -0,0 +1,74 @@ +import { timingSafeEqual } from 'node:crypto'; + +import { prisma } from '@/lib/db'; +import { accountAuthorityDatabaseReady } from '@/lib/account/authority-db'; +import { + accountEnvironment, + accountOrigin, + accountRateSecret, + accountStaticClientSecrets, + activeAccountStaticClients, + isAccountHost, +} from '@/lib/account/config'; +import { consumeAccountRateLimit } from '@/lib/account/rate-limit'; + +function credentials(request: Request) { + const header = request.headers.get('authorization'); + if (!header?.startsWith('Basic ')) return null; + try { + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); + const separator = decoded.indexOf(':'); + return separator > 0 ? { + clientId: decoded.slice(0, separator), + secret: decoded.slice(separator + 1), + } : null; + } catch { return null; } +} + +function equal(left: string, right: string) { + const a = Buffer.from(left); const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +export async function POST(request: Request): Promise { + if (!isAccountHost(request.headers.get('host') ?? new URL(request.url).host) || + !await accountAuthorityDatabaseReady()) return new Response(null, { status: 404 }); + if (request.headers.get('content-type') !== 'application/x-www-form-urlencoded') { + return Response.json({ active: false }, { + status: 415, headers: { 'Cache-Control': 'no-store' }, + }); + } + const presented = credentials(request); + const active = activeAccountStaticClients(); + const secrets = accountStaticClientSecrets(); + const definition = presented && active.find((client) => client.clientId === presented.clientId); + const expected = definition && secrets.find((client) => client.clientId === definition.clientId)?.clientSecret; + if (!presented || !definition || !expected || !equal(presented.secret, expected)) { + return Response.json({ active: false }, { status: 401, headers: { 'Cache-Control': 'no-store' } }); + } + const body = await request.formData().catch(() => null); + const sid = body?.get('sid'); const sub = body?.get('sub'); + if (typeof sid !== 'string' || typeof sub !== 'string' || sid.length > 128 || sub.length > 256) { + return Response.json({ active: false }, { status: 400, headers: { 'Cache-Control': 'no-store' } }); + } + const rateSecret = accountRateSecret(); + if (!rateSecret || !await consumeAccountRateLimit({ + request, email: sub, purpose: `session-status-${definition.clientId}`, + secret: rateSecret, maxPerEmail: 100, maxGlobal: 100_000, + includeOriginBucket: false, + })) return Response.json({ active: false }, { status: 429, headers: { 'Cache-Control': 'no-store' } }); + const session = await prisma.earlyBirdAuthSession.findUnique({ + where: { id: sid }, select: { + userId: true, expiresAt: true, securityRevision: true, authorityEnvironment: true, + user: { select: { securityRevision: true } }, + }, + }); + const isActive = Boolean(session && session.userId === sub && session.expiresAt > new Date() && + session.securityRevision === session.user.securityRevision && + session.authorityEnvironment === accountEnvironment()); + return Response.json(isActive + ? { active: true, iss: accountOrigin(), sub, sid } + : { active: false }, { + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/src/app/api/analytics/identity-link/__tests__/route.test.ts b/src/app/api/analytics/identity-link/__tests__/route.test.ts new file mode 100644 index 00000000..3d956ea2 --- /dev/null +++ b/src/app/api/analytics/identity-link/__tests__/route.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + currentAccountSession: vi.fn(), currentEarlyBirdSession: vi.fn(), emitAnalyticsEvent: vi.fn(), isAccountHost: vi.fn(), +})); +vi.mock('@/lib/account/auth', () => ({ currentAccountSession: mocks.currentAccountSession })); +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession })); +vi.mock('@/lib/analytics-server', () => ({ emitAnalyticsEvent: mocks.emitAnalyticsEvent })); +vi.mock('@/lib/account/config', () => ({ isAccountHost: mocks.isAccountHost })); + +import { POST } from '../route'; + +const visitor = '10000000-0000-4000-8000-000000000001'; +const session = '10000000-0000-4000-8000-000000000002'; +const request = (body: unknown, host = 'account.harmonicbeacon.com') => new Request('https://example.invalid/api/analytics/identity-link', { + method: 'POST', headers: { host, 'content-type': 'application/json' }, body: JSON.stringify(body), +}); + +describe('analytics identity link', () => { + beforeEach(() => { vi.clearAllMocks(); mocks.emitAnalyticsEvent.mockResolvedValue(true); }); + it('links only opaque browser IDs to the authenticated Account on the server', async () => { + mocks.isAccountHost.mockReturnValue(true); + mocks.currentAccountSession.mockResolvedValue({ user: { id: 'canonical-account-id' } }); + const response = await POST(request({ visitor_id: visitor, session_id: session })); + expect(response.status).toBe(204); + expect(mocks.emitAnalyticsEvent).toHaveBeenCalledWith(expect.objectContaining({ + eventName: 'identity.linked', accountId: 'canonical-account-id', visitorId: visitor, sessionId: session, + })); + }); + it('rejects unauthenticated and client-declared extra identity fields', async () => { + mocks.isAccountHost.mockReturnValue(false); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + expect((await POST(request({ visitor_id: visitor, session_id: session }, 'listen.harmonicbeacon.com'))).status).toBe(401); + expect((await POST(request({ visitor_id: visitor, session_id: session, account_subject: 'x' }))).status).toBe(400); + expect(mocks.emitAnalyticsEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/analytics/identity-link/route.ts b/src/app/api/analytics/identity-link/route.ts new file mode 100644 index 00000000..f2426941 --- /dev/null +++ b/src/app/api/analytics/identity-link/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; + +import { currentAccountSession } from '@/lib/account/auth'; +import { emitAnalyticsEvent } from '@/lib/analytics-server'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { isAccountHost } from '@/lib/account/config'; +import { analyticsBrowserConfig } from '@/lib/analytics-browser'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export async function POST(request: Request) { + let body: unknown; + try { body = await request.json(); } catch { return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); + } + const values = body as Record; + if (Object.keys(values).some((key) => !['visitor_id', 'session_id'].includes(key)) || + typeof values.visitor_id !== 'string' || !UUID.test(values.visitor_id) || + typeof values.session_id !== 'string' || !UUID.test(values.session_id)) { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); + } + const headers = new Headers(request.headers); + const accountId = isAccountHost(headers.get('host')) + ? (await currentAccountSession(headers).catch(() => null))?.user.id + : (await currentEarlyBirdSession(headers).catch(() => null))?.user.id; + if (!accountId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + + // This endpoint links opaque browser IDs to the authenticated account. It + // never returns or exposes the server-side account subject to the browser. + await emitAnalyticsEvent({ + eventName: 'identity.linked', source: 'account', surface: isAccountHost(headers.get('host')) ? 'account' : 'listen', + accountId, visitorId: values.visitor_id, sessionId: values.session_id, + environment: analyticsBrowserConfig(headers)?.environment, + properties: { link_reason: 'login' }, + }); + return new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } }); +} diff --git a/src/app/api/early-birds/access-state/__tests__/route.test.ts b/src/app/api/early-birds/access-state/__tests__/route.test.ts new file mode 100644 index 00000000..8bea1f89 --- /dev/null +++ b/src/app/api/early-birds/access-state/__tests__/route.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + getEarlyBirdListeningAccess: vi.fn(), +})); +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/access', () => ({ + getEarlyBirdListeningAccess: mocks.getEarlyBirdListeningAccess, + serializeEarlyBirdListeningAccess: (access: Record) => ({ + allowed: access.allowed, + kind: access.kind, + allowedUntil: (access.allowedUntil as Date | null)?.toISOString() ?? null, + quota: access.quota, + }), +})); +vi.mock('@/lib/early-birds/membership-presentation', () => ({ + listenerMembershipPresentation: () => ({ kind: 'founder', provider: 'paypal', state: 'ending' }), +})); +import { GET } from '../route'; + +const request = new NextRequest('https://listen.harmonicbeacon.com/api/early-birds/access-state'); + +describe('Listener access-state API', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('returns only private server-authoritative boundary state', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ + allowed: true, + kind: 'membership', + allowedUntil: new Date('2026-09-07T12:00:00.000Z'), + membership: { allowed: true, projection: { state: 'CANCELLED_PENDING_END' } }, + quota: null, + serverNow: new Date('2026-08-07T15:31:00.000Z'), + }); + + const response = await GET(request); + const payload = await response.json(); + + expect(response.headers.get('cache-control')).toContain('no-store'); + expect(payload).toMatchObject({ + serverNow: '2026-08-07T15:31:00.000Z', + access: { kind: 'membership', allowedUntil: '2026-09-07T12:00:00.000Z' }, + membershipState: 'ending', + }); + expect(JSON.stringify(payload)).not.toContain('listener-1'); + }); + + it('requires a Listener session', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + expect((await GET(request)).status).toBe(401); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); + }); + + it('reports anonymous Free for All mode without creating quota or membership state', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + + const response = await GET(request); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('no-store'); + expect(payload.access).toEqual({ kind: 'free-for-all', quota: null }); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/early-birds/access-state/route.ts b/src/app/api/early-birds/access-state/route.ts new file mode 100644 index 00000000..998629b5 --- /dev/null +++ b/src/app/api/early-birds/access-state/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { + getEarlyBirdListeningAccess, + serializeEarlyBirdListeningAccess, +} from '@/lib/early-birds/access'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; +import { listenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; + +export const dynamic = 'force-dynamic'; + +const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }; + +export async function GET(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + if (earlyBirdsFreeForAll()) { + return NextResponse.json({ + serverNow: new Date().toISOString(), + access: { kind: 'free-for-all', quota: null }, + }, { headers: PRIVATE_HEADERS }); + } + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) { + return NextResponse.json({ error: 'Sign in required.' }, { status: 401, headers: PRIVATE_HEADERS }); + } + try { + const access = await getEarlyBirdListeningAccess(session.user.id); + return NextResponse.json({ + serverNow: access.serverNow.toISOString(), + access: serializeEarlyBirdListeningAccess(access), + membershipState: listenerMembershipPresentation( + access.membership.projection, + access.serverNow, + ).state, + }, { headers: PRIVATE_HEADERS }); + } catch { + return NextResponse.json({ error: 'Listener access unavailable.' }, { + status: 503, + headers: PRIVATE_HEADERS, + }); + } +} diff --git a/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts b/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts new file mode 100644 index 00000000..6e6e56d0 --- /dev/null +++ b/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { GET, POST } from '../route'; + +describe('removed Listener-local identity authority', () => { + it.each([GET, POST])('fails every legacy provider and magic-link path closed', async (handler) => { + const response = await handler(new Request( + 'https://listen.harmonicbeacon.com/api/early-birds/auth/sign-in/social', + { method: handler === POST ? 'POST' : 'GET' }, + )); + expect(response.status).toBe(404); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(await response.json()).toEqual({ error: 'not_found' }); + }); +}); diff --git a/src/app/api/early-birds/auth/[...all]/route.ts b/src/app/api/early-birds/auth/[...all]/route.ts new file mode 100644 index 00000000..2e1ca47b --- /dev/null +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -0,0 +1,11 @@ +/** Legacy Listener OAuth/magic-link authority is intentionally gone. */ +function unavailable(_request: Request): Response { + void _request; + return Response.json({ error: 'not_found' }, { + status: 404, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} + +export const GET = unavailable; +export const POST = unavailable; diff --git a/src/app/api/early-birds/drop-ins/[language]/__tests__/route.test.ts b/src/app/api/early-birds/drop-ins/[language]/__tests__/route.test.ts new file mode 100644 index 00000000..5cba0021 --- /dev/null +++ b/src/app/api/early-birds/drop-ins/[language]/__tests__/route.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Readable } from 'node:stream'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + getEarlyBirdListeningAccess: vi.fn(), + stat: vi.fn(), + open: vi.fn(), + createReadStream: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ stat: mocks.stat, open: mocks.open })); +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/access', () => ({ + getEarlyBirdListeningAccess: mocks.getEarlyBirdListeningAccess, +})); + +import { GET, HEAD } from '../route'; + +const context = (language: string) => ({ params: Promise.resolve({ language }) }); + +beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_DROPIN_ES_PATH', '/media/drop-ins/amara.m4a'); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ allowed: true }); + mocks.stat.mockResolvedValue({ size: 10, isFile: () => true }); + mocks.createReadStream.mockImplementation(({ start, end }: { start: number; end: number }) => ( + Readable.from([Buffer.from('0123456789').subarray(start, end + 1)]) + )); + mocks.open.mockResolvedValue({ createReadStream: mocks.createReadStream }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('private EarlyBird drop-in media', () => { + it('requires an entitled Listener before reading media', async () => { + mocks.currentEarlyBirdSession.mockResolvedValue(null); + expect((await GET(new NextRequest('https://listener.test/api/early-birds/drop-ins/es'), context('es'))).status).toBe(401); + expect(mocks.stat).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); + + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ allowed: false }); + expect((await GET(new NextRequest('https://listener.test/api/early-birds/drop-ins/es'), context('es'))).status).toBe(403); + expect(mocks.stat).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); + }); + + it('reports transient authority failure as unavailable rather than inactive access', async () => { + mocks.getEarlyBirdListeningAccess.mockRejectedValueOnce(new Error('serialization conflict')); + + const response = await GET( + new NextRequest('https://listener.test/api/early-birds/drop-ins/es'), + context('es'), + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ error: 'Listener access unavailable.' }); + expect(mocks.stat).not.toHaveBeenCalled(); + }); + + it('serves the configured drop-in anonymously only in Free for All mode', async () => { + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + + const response = await GET( + new NextRequest('https://listener.test/api/early-birds/drop-ins/es'), + context('es'), + ); + + expect(response.status).toBe(200); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); + }); + + it('streams only the selected byte range from an immutable server-selected path', async () => { + const request = new NextRequest('https://listener.test/api/early-birds/drop-ins/es', { + headers: { range: 'bytes=2-5' }, + }); + const response = await GET(request, context('es')); + expect(response.status).toBe(206); + expect(response.headers.get('content-range')).toBe('bytes 2-5/10'); + expect(response.headers.get('content-type')).toBe('audio/mp4'); + await expect(response.text()).resolves.toBe('2345'); + expect(mocks.stat).toHaveBeenCalledWith('/media/drop-ins/amara.m4a'); + expect(mocks.open).toHaveBeenCalledWith('/media/drop-ins/amara.m4a', 'r'); + expect(mocks.createReadStream).toHaveBeenCalledWith({ start: 2, end: 5, autoClose: true }); + }); + + it('answers HEAD from metadata without opening or reading the media', async () => { + const request = new NextRequest('https://listener.test/api/early-birds/drop-ins/es'); + const head = await HEAD(new NextRequest(request.url), context('es')); + expect(head.status).toBe(200); + expect(head.headers.get('content-length')).toBe('10'); + await expect(head.text()).resolves.toBe(''); + expect(mocks.stat).toHaveBeenCalledWith('/media/drop-ins/amara.m4a'); + expect(mocks.open).not.toHaveBeenCalled(); + expect(mocks.createReadStream).not.toHaveBeenCalled(); + }); + + it('streams a full GET without buffering the complete file in the route', async () => { + const response = await GET( + new NextRequest('https://listener.test/api/early-birds/drop-ins/es'), + context('es'), + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-length')).toBe('10'); + await expect(response.text()).resolves.toBe('0123456789'); + expect(mocks.createReadStream).toHaveBeenCalledWith({ start: 0, end: 9, autoClose: true }); + }); + + it('fails closed for unknown languages, invalid paths and ranges', async () => { + expect((await GET(new NextRequest('https://listener.test/api/early-birds/drop-ins/fr'), context('fr'))).status).toBe(404); + vi.stubEnv('EARLY_BIRDS_DROPIN_ES_PATH', 'relative.m4a'); + expect((await GET(new NextRequest('https://listener.test/api/early-birds/drop-ins/es'), context('es'))).status).toBe(404); + vi.stubEnv('EARLY_BIRDS_DROPIN_ES_PATH', '/media/drop-ins/amara.m4a'); + const invalid = new NextRequest('https://listener.test/api/early-birds/drop-ins/es', { + headers: { range: 'bytes=99-' }, + }); + expect((await GET(invalid, context('es'))).status).toBe(416); + }); + + it('fails closed when media metadata or file opening is unavailable', async () => { + mocks.stat.mockRejectedValueOnce(new Error('missing')); + expect((await GET( + new NextRequest('https://listener.test/api/early-birds/drop-ins/es'), + context('es'), + )).status).toBe(404); + + mocks.open.mockRejectedValueOnce(new Error('permission denied')); + expect((await GET( + new NextRequest('https://listener.test/api/early-birds/drop-ins/es'), + context('es'), + )).status).toBe(404); + }); +}); diff --git a/src/app/api/early-birds/drop-ins/[language]/route.ts b/src/app/api/early-birds/drop-ins/[language]/route.ts new file mode 100644 index 00000000..c56f334e --- /dev/null +++ b/src/app/api/early-birds/drop-ins/[language]/route.ts @@ -0,0 +1,126 @@ +import { isAbsolute } from 'node:path'; +import { open, stat } from 'node:fs/promises'; +import { Readable } from 'node:stream'; + +import { NextRequest, NextResponse } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { getEarlyBirdListeningAccess } from '@/lib/early-birds/access'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const PRIVATE_HEADERS = { + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'private, no-store, max-age=0', + 'Content-Type': 'audio/mp4', + 'X-Content-Type-Options': 'nosniff', +}; + +function unavailable() { + return NextResponse.json({ error: 'Drop-in unavailable.' }, { + status: 404, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} + +async function serve( + request: NextRequest, + context: { params: Promise<{ language: string }> }, + head: boolean, +) { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + const freeForAll = earlyBirdsFreeForAll(); + const session = freeForAll + ? null + : await currentEarlyBirdSession(request.headers).catch(() => null); + if (!freeForAll && !session) { + return NextResponse.json({ error: 'Sign in required.' }, { + status: 401, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } + let access = null; + try { + access = session ? await getEarlyBirdListeningAccess(session.user.id) : null; + } catch { + return NextResponse.json({ error: 'Listener access unavailable.' }, { + status: 503, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } + if (!freeForAll && !access?.allowed) { + return NextResponse.json({ error: 'Listening access inactive.' }, { + status: 403, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } + + const { language } = await context.params; + const configuredPath = language === 'es' + ? process.env.EARLY_BIRDS_DROPIN_ES_PATH + : language === 'en' ? process.env.EARLY_BIRDS_DROPIN_EN_PATH : undefined; + if (!configuredPath || !isAbsolute(configuredPath)) return unavailable(); + + try { + const metadata = await stat(configuredPath); + if (!metadata.isFile()) return unavailable(); + const fileSize = metadata.size; + const match = request.headers.get('range')?.match(/^bytes=(\d+)-(\d*)$/); + let start = 0; + let end = fileSize - 1; + let status = 200; + if (match) { + start = Number(match[1]); + end = match[2] ? Math.min(Number(match[2]), end) : end; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= fileSize) { + return new NextResponse(null, { + status: 416, + headers: { ...PRIVATE_HEADERS, 'Content-Range': `bytes */${fileSize}` }, + }); + } + status = 206; + } + const contentLength = Math.max(0, end - start + 1); + if (head || contentLength === 0) { + return new NextResponse(null, { + status, + headers: { + ...PRIVATE_HEADERS, + 'Content-Length': String(contentLength), + ...(status === 206 ? { 'Content-Range': `bytes ${start}-${end}/${fileSize}` } : {}), + }, + }); + } + + const file = await open(configuredPath, 'r'); + const body = Readable.toWeb(file.createReadStream({ + start, + end, + autoClose: true, + })) as ReadableStream; + return new NextResponse(body, { + status, + headers: { + ...PRIVATE_HEADERS, + 'Content-Length': String(contentLength), + ...(status === 206 ? { 'Content-Range': `bytes ${start}-${end}/${fileSize}` } : {}), + }, + }); + } catch { + return unavailable(); + } +} + +export function GET(request: NextRequest, context: { params: Promise<{ language: string }> }) { + return serve(request, context, false); +} + +export function HEAD(request: NextRequest, context: { params: Promise<{ language: string }> }) { + return serve(request, context, true); +} diff --git a/src/app/api/early-birds/free/redeem/__tests__/route.test.ts b/src/app/api/early-birds/free/redeem/__tests__/route.test.ts new file mode 100644 index 00000000..47d74aad --- /dev/null +++ b/src/app/api/early-birds/free/redeem/__tests__/route.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +import { + EARLY_BIRD_INVITATION_COOKIE, + LISTENER_INVITATION_COOKIE, +} from '@/lib/early-birds/invitation-cookie'; + +const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); +const redeemFreeThroughCanonicalGateway = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/membership-gateway', () => ({ + EarlyBirdMembershipGatewayUnavailableError: class extends Error {}, + redeemFreeThroughCanonicalGateway, +})); + +import { POST } from '../route'; + +const TOKEN = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)}`; + +function request( + token: string | null = TOKEN, + namespace: 'legacy' | 'canonical' = 'legacy', + origin = 'https://listen.harmonicbeacon.com', + hostname = 'listen.harmonicbeacon.com', + cookieHeader?: string, +) { + const headers = new Headers(); + if (cookieHeader) headers.set('cookie', cookieHeader); + else if (token) headers.set('cookie', `${EARLY_BIRD_INVITATION_COOKIE}=${token}`); + if (origin) headers.set('origin', origin); + headers.set('host', hostname); + headers.set('x-forwarded-proto', 'https'); + const pathname = namespace === 'canonical' + ? '/api/listener/free/redeem' + : '/api/early-birds/free/redeem'; + return new NextRequest(`https://${hostname}${pathname}`, { + method: 'POST', + headers, + }); +} + +beforeEach(() => vi.stubEnv('EARLY_BIRDS_ENABLED', '1')); +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('EarlyBird Free redemption boundary', () => { + it('stops before auth and canonical membership while public entry is disabled', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '0'); + const response = await POST(request()); + + expect(response.status).toBe(503); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(redeemFreeThroughCanonicalGateway).not.toHaveBeenCalled(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + }); + + it('never sends an invitation to the canonical authority before EarlyBird auth', async () => { + currentEarlyBirdSession.mockResolvedValue(null); + const response = await POST(request()); + expect(response.status).toBe(401); + expect(redeemFreeThroughCanonicalGateway).not.toHaveBeenCalled(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + }); + + it.each([ + [null, 'listen.harmonicbeacon.com'], + ['https://attacker.invalid', 'listen.harmonicbeacon.com'], + ['https://listen.harmonicbeacon.com', 'live.harmonicbeacon.com'], + ])('rejects a missing/cross-origin or off-host mutation before auth: %s %s', async (origin, hostname) => { + const response = await POST(request(TOKEN, 'canonical', origin ?? '', hostname)); + + expect(response.status).toBe(403); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(redeemFreeThroughCanonicalGateway).not.toHaveBeenCalled(); + }); + + it('rejects a direct plaintext request even when Host and Origin match', async () => { + const response = await POST(new NextRequest( + 'http://listen.harmonicbeacon.com/api/listener/free/redeem', + { + method: 'POST', + headers: { + origin: 'http://listen.harmonicbeacon.com', + host: 'listen.harmonicbeacon.com', + cookie: `${EARLY_BIRD_INVITATION_COOKIE}=${TOKEN}`, + }, + }, + )); + + expect(response.status).toBe(403); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + }); + + it('rejects the exact staging host because redemption belongs to the canonical session origin', async () => { + const response = await POST(request( + TOKEN, + 'canonical', + 'https://earlybirds-staging.harmonicbeacon.com', + 'earlybirds-staging.harmonicbeacon.com', + )); + + expect(response.status).toBe(403); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(redeemFreeThroughCanonicalGateway).not.toHaveBeenCalled(); + }); + + it('passes the opaque token and account id to the canonical gateway after auth', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + redeemFreeThroughCanonicalGateway.mockResolvedValue({ + ok: true, + replayed: false, + alreadyEntitled: false, + }); + const token = TOKEN; + const response = await POST(request(token)); + expect(response.status).toBe(200); + expect(redeemFreeThroughCanonicalGateway).toHaveBeenCalledWith('listener-1', token); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + landing: '/early-birds', + }); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toMatchObject({ + value: '', + maxAge: 0, + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + }); + expect(response.cookies.get(LISTENER_INVITATION_COOKIE)).toMatchObject({ + value: '', + maxAge: 0, + path: '/', + }); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + }); + + it('redeems a canonical-only cookie during the compatibility window', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + redeemFreeThroughCanonicalGateway.mockResolvedValue({ + ok: true, + replayed: false, + alreadyEntitled: false, + }); + const response = await POST(request( + TOKEN, + 'canonical', + undefined, + undefined, + `${LISTENER_INVITATION_COOKIE}=${TOKEN}`, + )); + + expect(response.status).toBe(200); + expect(redeemFreeThroughCanonicalGateway).toHaveBeenCalledWith('listener-1', TOKEN); + }); + + it('fails closed and clears both generations when dual cookies conflict', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + const other = `ebi_v1.${'d'.repeat(32)}.${'e'.repeat(32)}.${'f'.repeat(32)}`; + const response = await POST(request( + TOKEN, + 'canonical', + undefined, + undefined, + `${LISTENER_INVITATION_COOKIE}=${other}; ${EARLY_BIRD_INVITATION_COOKIE}=${TOKEN}`, + )); + + expect(response.status).toBe(409); + expect(redeemFreeThroughCanonicalGateway).not.toHaveBeenCalled(); + expect(response.cookies.get(LISTENER_INVITATION_COOKIE)?.maxAge).toBe(0); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)?.maxAge).toBe(0); + }); + + it('returns the canonical landing only to the canonical alias', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + redeemFreeThroughCanonicalGateway.mockResolvedValue({ + ok: true, + replayed: true, + alreadyEntitled: true, + }); + + const canonical = await POST(request(TOKEN, 'canonical')); + await expect(canonical.json()).resolves.toMatchObject({ landing: '/listener' }); + + const legacy = await POST(request(TOKEN, 'legacy')); + await expect(legacy.json()).resolves.toMatchObject({ landing: '/early-birds' }); + }); + + it('does not accept an invitation token from a request body', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + const response = await POST(new NextRequest( + 'https://listen.harmonicbeacon.com/api/early-birds/free/redeem', + { + method: 'POST', + headers: { + origin: 'https://listen.harmonicbeacon.com', + host: 'listen.harmonicbeacon.com', + 'x-forwarded-proto': 'https', + 'content-type': 'application/json', + }, + body: JSON.stringify({ token: TOKEN }), + }, + )); + + expect(response.status).toBe(409); + expect(redeemFreeThroughCanonicalGateway).not.toHaveBeenCalled(); + }); + + it('clears a terminally rejected token without leaking cross-account use or revocation', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + redeemFreeThroughCanonicalGateway.mockResolvedValue({ ok: false, reason: 'unavailable' }); + const response = await POST(request()); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ error: 'Invitation unavailable.' }); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toMatchObject({ + value: '', + maxAge: 0, + }); + expect(response.cookies.get(LISTENER_INVITATION_COOKIE)).toMatchObject({ + value: '', + maxAge: 0, + }); + }); + + it('retains the short invitation cookie when the canonical authority is unavailable', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + redeemFreeThroughCanonicalGateway.mockRejectedValue(new Error('timeout')); + + const response = await POST(request()); + + expect(response.status).toBe(503); + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + expect(response.cookies.get(LISTENER_INVITATION_COOKIE)).toBeUndefined(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + }); +}); diff --git a/src/app/api/early-birds/free/redeem/route.ts b/src/app/api/early-birds/free/redeem/route.ts new file mode 100644 index 00000000..23adc532 --- /dev/null +++ b/src/app/api/early-birds/free/redeem/route.ts @@ -0,0 +1,87 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; +import { + clearedListenerInvitationCookies, + earlyBirdInvitationCookieHost, + listenerInvitationFromCookieHeader, +} from '@/lib/early-birds/invitation-cookie'; +import { + EarlyBirdMembershipGatewayUnavailableError, + redeemFreeThroughCanonicalGateway, +} from '@/lib/early-birds/membership-gateway'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; + +export const dynamic = 'force-dynamic'; + +function sensitive(response: NextResponse): NextResponse { + response.headers.set('Cache-Control', 'private, no-store'); + response.headers.set('Referrer-Policy', 'no-referrer'); + return response; +} + +function json(body: Record, status: number): NextResponse { + return sensitive(NextResponse.json(body, { status })); +} + +function terminalInvitationUnavailable(): NextResponse { + const response = json({ error: 'Invitation unavailable.' }, 409); + for (const cookie of clearedListenerInvitationCookies()) response.cookies.set(cookie); + return response; +} + +function sameOriginInvitationRequest(request: NextRequest): boolean { + const host = request.headers.get('host')?.trim().toLowerCase() ?? ''; + const protocol = request.headers.get('x-forwarded-proto')?.trim().toLowerCase() + ?? request.nextUrl.protocol.replace(/:$/, '').toLowerCase(); + if (protocol !== 'https' || !earlyBirdInvitationCookieHost(host)) return false; + const origin = request.headers.get('origin'); + if (!origin) return false; + try { + const parsed = new URL(origin); + return parsed.protocol === 'https:' && parsed.origin === `https://${host}`; + } catch { + return false; + } +} + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return sensitive(earlyBirdsUnavailableResponse()); + + if (!sameOriginInvitationRequest(request)) { + return json({ error: 'Invalid request.' }, 403); + } + + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) return json({ error: 'Sign in required.' }, 401); + + const token = listenerInvitationFromCookieHeader(request.headers.get('cookie')); + if (!token) { + return terminalInvitationUnavailable(); + } + + let result; + try { + result = await redeemFreeThroughCanonicalGateway(session.user.id, token); + } catch (error) { + if (error instanceof EarlyBirdMembershipGatewayUnavailableError) { + return json({ error: 'Membership service unavailable.' }, 503); + } + return json({ error: 'Membership service unavailable.' }, 503); + } + if (!result.ok) { + return terminalInvitationUnavailable(); + } + const landing = request.nextUrl.pathname === LISTENER_NAMESPACE.canonical.api.freeRedeem + ? LISTENER_NAMESPACE.canonical.home + : LISTENER_NAMESPACE.legacy.home; + const response = sensitive(NextResponse.json({ + ok: true, + landing, + replayed: result.replayed, + alreadyEntitled: result.alreadyEntitled, + })); + for (const cookie of clearedListenerInvitationCookies()) response.cookies.set(cookie); + return response; +} diff --git a/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts b/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts new file mode 100644 index 00000000..284d15fd --- /dev/null +++ b/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + heartbeatEarlyBirdStreamLease: vi.fn(), + heartbeatFreeForAllStreamLease: vi.fn(), + LeaseInactive: class extends Error { + constructor(readonly reason: 'evicted' | 'expired' | 'missing' = 'missing') { + super('inactive'); + } + }, + AccessDenied: class extends Error {}, + RefreshRequired: class extends Error {}, +})); + +vi.mock('@/lib/early-birds/auth', () => ({ + currentEarlyBirdSession: mocks.currentEarlyBirdSession, +})); +vi.mock('@/lib/early-birds/stream', () => ({ + heartbeatEarlyBirdStreamLease: mocks.heartbeatEarlyBirdStreamLease, + heartbeatFreeForAllStreamLease: mocks.heartbeatFreeForAllStreamLease, + EarlyBirdLeaseInactiveError: mocks.LeaseInactive, + EarlyBirdAccessDeniedError: mocks.AccessDenied, + EarlyBirdLeaseRefreshRequiredError: mocks.RefreshRequired, +})); +vi.mock('@/lib/listener/presence', () => ({ + resolveListenerMacroRegion: vi.fn().mockResolvedValue('UNKNOWN'), +})); + +import { POST } from '../route'; + +const LEASE_ID = '00000000-0000-4000-8000-000000000003'; + +function request(intent: 'play' | 'prepare' = 'play', presence: 'idle' | 'listening' = 'listening') { + return new NextRequest('https://listener.example.test/api/early-birds/stream/heartbeat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + leaseId: LEASE_ID, + leaseGeneration: 2, + presenceSequence: 3, + intent, + presence, + }), + }); +} + +beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('EarlyBird stream heartbeat route', () => { + it('distinguishes a real eviction from ordinary expiry', async () => { + mocks.heartbeatEarlyBirdStreamLease + .mockRejectedValueOnce(new mocks.LeaseInactive('evicted')) + .mockRejectedValueOnce(new mocks.LeaseInactive('expired')); + + const displaced = await POST(request()); + expect(displaced.status).toBe(410); + await expect(displaced.json()).resolves.toEqual({ + error: 'Device displaced.', + reason: 'displaced', + }); + + const expired = await POST(request()); + expect(expired.status).toBe(410); + await expect(expired.json()).resolves.toEqual({ + error: 'Listening lease expired.', + reason: 'expired', + }); + }); + + it('returns the stable direct-origin grant for an active lease renewal', async () => { + const directGrant = `https://stream.harmonicbeacon.com/v1/hls/approved/live.m3u8?grantId=${'a'.repeat(64)}&grant=${'b'.repeat(43)}`; + mocks.heartbeatEarlyBirdStreamLease.mockResolvedValue({ + serverNow: new Date('2026-08-06T12:00:00.000Z'), + accessKind: 'free-quota', + quota: { policy: 'personal-7-day-v1', status: 'listening' }, + leaseGeneration: 2, + presenceSequence: 3, + leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), + stream: { + manifestUrl: directGrant, + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }, + }); + const response = await POST(request()); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + stream: { manifestUrl: directGrant }, + }); + expect(mocks.heartbeatEarlyBirdStreamLease).toHaveBeenCalledWith( + 'listener-1', LEASE_ID, 2, 3, undefined, undefined, true, + { state: 'LISTENING', macroRegion: 'UNKNOWN' }, + ); + }); + + it('renews an anonymous lease without consulting auth in Free for All mode', async () => { + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + mocks.heartbeatFreeForAllStreamLease.mockResolvedValue({ + serverNow: new Date('2026-08-06T12:00:00.000Z'), + accessKind: 'free-for-all', + quota: null, + leaseGeneration: 2, + presenceSequence: 3, + leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), + stream: { + manifestUrl: `/api/early-birds/stream/manifest?leaseId=${LEASE_ID}`, + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }, + }); + + expect((await POST(request())).status).toBe(200); + expect(mocks.heartbeatFreeForAllStreamLease).toHaveBeenCalledWith( + LEASE_ID, + 2, + 3, + undefined, + undefined, + { state: 'LISTENING', macroRegion: 'UNKNOWN' }, + ); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.heartbeatEarlyBirdStreamLease).not.toHaveBeenCalled(); + }); + + it('renews a prepared source without promoting its eviction priority', async () => { + mocks.heartbeatEarlyBirdStreamLease.mockResolvedValue({ + serverNow: new Date('2026-08-06T12:00:00.000Z'), + accessKind: 'free-quota', + quota: { policy: 'personal-7-day-v1', status: 'available' }, + leaseGeneration: 2, + presenceSequence: 3, + leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), + stream: { + manifestUrl: `/api/early-birds/stream/manifest?leaseId=${LEASE_ID}`, + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }, + }); + + expect((await POST(request('prepare', 'idle'))).status).toBe(200); + expect(mocks.heartbeatEarlyBirdStreamLease).toHaveBeenCalledWith( + 'listener-1', LEASE_ID, 2, 3, undefined, undefined, false, + { state: 'IDLE', macroRegion: 'UNKNOWN' }, + ); + }); + + it('does not let an unknown presence value manufacture listening state', async () => { + mocks.heartbeatEarlyBirdStreamLease.mockResolvedValue({ + serverNow: new Date('2026-08-06T12:00:00.000Z'), + accessKind: 'free-quota', + quota: { policy: 'personal-7-day-v1', status: 'available' }, + leaseGeneration: 2, + presenceSequence: 3, + leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), + stream: { + manifestUrl: `/api/early-birds/stream/manifest?leaseId=${LEASE_ID}`, + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }, + }); + const malformedPresence = new NextRequest( + 'https://listener.example.test/api/early-birds/stream/heartbeat', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + leaseId: LEASE_ID, + leaseGeneration: 2, + presenceSequence: 3, + intent: 'prepare', + presence: 'radiant', + }), + }, + ); + + expect((await POST(malformedPresence)).status).toBe(400); + expect(mocks.heartbeatEarlyBirdStreamLease).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/early-birds/stream/heartbeat/route.ts b/src/app/api/early-birds/stream/heartbeat/route.ts new file mode 100644 index 00000000..d8138aac --- /dev/null +++ b/src/app/api/early-birds/stream/heartbeat/route.ts @@ -0,0 +1,124 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { clientAddress } from '@/lib/client-address'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; +import { + EarlyBirdAccessDeniedError, + EarlyBirdLeaseInactiveError, + EarlyBirdLeaseRefreshRequiredError, + heartbeatFreeForAllStreamLease, + heartbeatEarlyBirdStreamLease, +} from '@/lib/early-birds/stream'; +import { resolveListenerMacroRegion } from '@/lib/listener/presence'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + + const freeForAll = earlyBirdsFreeForAll(); + const session = freeForAll + ? null + : await currentEarlyBirdSession(request.headers).catch(() => null); + if (!freeForAll && !session) { + return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + } + + let leaseId: string; + let leaseGeneration: number; + let presenceSequence: number; + let intent: 'play' | 'prepare'; + let presence: 'IDLE' | 'LISTENING'; + try { + const body = await request.json() as { + leaseId?: unknown; + intent?: unknown; + presence?: unknown; + leaseGeneration?: unknown; + presenceSequence?: unknown; + }; + leaseId = typeof body.leaseId === 'string' ? body.leaseId : ''; + leaseGeneration = typeof body.leaseGeneration === 'number' ? body.leaseGeneration : 0; + presenceSequence = typeof body.presenceSequence === 'number' ? body.presenceSequence : -1; + intent = body.intent === 'prepare' ? 'prepare' : 'play'; + if (body.presence !== 'idle' && body.presence !== 'listening') { + return NextResponse.json({ error: 'Invalid presence.' }, { status: 400 }); + } + presence = body.presence === 'idle' ? 'IDLE' : 'LISTENING'; + } catch { + return NextResponse.json({ error: 'Malformed request.' }, { status: 400 }); + } + if (!/^[0-9a-f-]{36}$/i.test(leaseId)) { + return NextResponse.json({ error: 'Invalid lease.' }, { status: 400 }); + } + if (!Number.isSafeInteger(leaseGeneration) || leaseGeneration < 1 + || !Number.isSafeInteger(presenceSequence) || presenceSequence < 0) { + return NextResponse.json({ + error: 'Lease refresh required.', + reason: 'refresh_required', + }, { status: 409 }); + } + + try { + const macroRegion = await resolveListenerMacroRegion(clientAddress(request.headers)); + const reportedPresence = { state: presence, macroRegion } as const; + const grant = freeForAll + ? await heartbeatFreeForAllStreamLease( + leaseId, + leaseGeneration, + presenceSequence, + undefined, + undefined, + reportedPresence, + ) + : await heartbeatEarlyBirdStreamLease( + session!.user.id, + leaseId, + leaseGeneration, + presenceSequence, + undefined, + undefined, + intent === 'play', + reportedPresence, + ); + return NextResponse.json({ + serverNow: grant.serverNow.toISOString(), + accessKind: grant.accessKind, + quota: grant.quota, + leaseGeneration: grant.leaseGeneration, + presenceSequence: grant.presenceSequence, + leaseExpiresAt: grant.leaseExpiresAt.toISOString(), + stream: { + manifestUrl: grant.stream.manifestUrl, + expiresAt: grant.stream.expiresAt.toISOString(), + }, + }); + } catch (error) { + if (error instanceof EarlyBirdLeaseRefreshRequiredError) { + return NextResponse.json({ + error: 'Lease refresh required.', + reason: 'refresh_required', + }, { status: 409 }); + } + if (error instanceof EarlyBirdLeaseInactiveError) { + const reason = error.reason === 'evicted' + ? 'displaced' + : error.reason === 'expired' ? 'expired' : 'inactive'; + return NextResponse.json({ + error: reason === 'displaced' + ? 'Device displaced.' + : reason === 'expired' ? 'Listening lease expired.' : 'Listening lease inactive.', + reason, + }, { status: 410 }); + } + if (error instanceof EarlyBirdAccessDeniedError) { + return NextResponse.json({ error: 'Listening access inactive.' }, { status: 403 }); + } + return NextResponse.json({ error: 'Stream temporarily unavailable.' }, { status: 503 }); + } +} diff --git a/src/app/api/early-birds/stream/lease/__tests__/route.test.ts b/src/app/api/early-birds/stream/lease/__tests__/route.test.ts new file mode 100644 index 00000000..5624d8db --- /dev/null +++ b/src/app/api/early-birds/stream/lease/__tests__/route.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); +const acquireEarlyBirdStreamLease = vi.hoisted(() => vi.fn()); +const acquireFreeForAllStreamLease = vi.hoisted(() => vi.fn()); +const prepareEarlyBirdStreamLease = vi.hoisted(() => vi.fn()); +const claimEarlyBirdStreamLease = vi.hoisted(() => vi.fn()); +const EarlyBirdDeviceCapacityError = vi.hoisted(() => class extends Error {}); + +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/stream', () => ({ + acquireEarlyBirdStreamLease, + acquireFreeForAllStreamLease, + prepareEarlyBirdStreamLease, + claimEarlyBirdStreamLease, + EarlyBirdAccessDeniedError: class extends Error {}, + EarlyBirdDeviceCapacityError, + EarlyBirdStreamIssuerUnavailableError: class extends Error {}, +})); + +import { POST } from '../route'; + +function request(deviceId = 'device_abcdefghijklmnopqrstuvwxyz', intent?: 'play' | 'prepare' | 'claim') { + return new NextRequest('https://live.example.test/api/early-birds/stream/lease', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceId, intent }), + }); +} + +beforeEach(() => vi.stubEnv('EARLY_BIRDS_ENABLED', '1')); +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('EarlyBird stream lease route', () => { + it('requires an EarlyBird session independent from weekend auth', async () => { + currentEarlyBirdSession.mockResolvedValue(null); + const response = await POST(request()); + expect(response.status).toBe(401); + expect(acquireEarlyBirdStreamLease).not.toHaveBeenCalled(); + }); + + it('issues an anonymous public lease only while Free for All is explicit', async () => { + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + currentEarlyBirdSession.mockResolvedValue(null); + acquireFreeForAllStreamLease.mockResolvedValue({ + serverNow: new Date('2026-08-06T12:00:00.000Z'), + accessKind: 'free-for-all', + quota: null, + leaseGeneration: 1, + presenceSequence: 0, + leaseId: '00000000-0000-4000-8000-000000000003', + leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), + evictedLeaseId: null, + stream: { + manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }, + }); + + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(acquireFreeForAllStreamLease).toHaveBeenCalledWith('device_abcdefghijklmnopqrstuvwxyz'); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(acquireEarlyBirdStreamLease).not.toHaveBeenCalled(); + }); + + it('returns only the stable direct-origin media grant', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + acquireEarlyBirdStreamLease.mockResolvedValue({ + serverNow: new Date('2026-08-06T12:00:00.000Z'), + accessKind: 'free-quota', + quota: { policy: 'personal-7-day-v1', status: 'available' }, + leaseGeneration: 2, + presenceSequence: 0, + leaseId: '00000000-0000-4000-8000-000000000003', + leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), + evictedLeaseId: '00000000-0000-4000-8000-000000000001', + stream: { + manifestUrl: `https://stream.harmonicbeacon.com/v1/hls/approved/live.m3u8?grantId=${'a'.repeat(64)}&grant=${'b'.repeat(43)}`, + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }, + }); + const response = await POST(request()); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.evictedAnotherDevice).toBe(true); + expect(body.stream.manifestUrl).toMatch(/^https:\/\/stream\.harmonicbeacon\.com\/v1\/hls\/[^?]+\/live\.m3u8\?grantId=/); + expect(body.stream.manifestUrl).not.toContain(body.leaseId); + expect(JSON.stringify(body)).not.toContain('sig='); + }); + + it('prepares playback without using the eviction-capable lease path', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + prepareEarlyBirdStreamLease.mockResolvedValue({ + serverNow: new Date('2026-08-06T12:00:00.000Z'), + accessKind: 'free-quota', + quota: { policy: 'personal-7-day-v1', status: 'not-started' }, + leaseGeneration: 3, + presenceSequence: 0, + leaseId: '00000000-0000-4000-8000-000000000003', + leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), + evictedLeaseId: null, + stream: { + manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }, + }); + + const response = await POST(request('device_abcdefghijklmnopqrstuvwxyz', 'prepare')); + + expect(response.status).toBe(200); + expect(prepareEarlyBirdStreamLease).toHaveBeenCalledWith( + 'listener-1', + 'device_abcdefghijklmnopqrstuvwxyz', + ); + expect(acquireEarlyBirdStreamLease).not.toHaveBeenCalled(); + }); + + it('claims eviction priority while preserving IDLE/unmetered semantics in the core', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + claimEarlyBirdStreamLease.mockResolvedValue({ + serverNow: new Date('2026-08-06T12:00:00.000Z'), + accessKind: 'free-quota', + quota: { policy: 'personal-7-day-v1', status: 'not-started' }, + leaseGeneration: 4, + presenceSequence: 0, + leaseId: '00000000-0000-4000-8000-000000000003', + leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), + evictedLeaseId: '00000000-0000-4000-8000-000000000001', + stream: { + manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003&leaseGeneration=4', + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }, + }); + const response = await POST(request('device_abcdefghijklmnopqrstuvwxyz', 'claim')); + expect(response.status).toBe(200); + expect(claimEarlyBirdStreamLease).toHaveBeenCalledWith( + 'listener-1', 'device_abcdefghijklmnopqrstuvwxyz', + ); + await expect(response.json()).resolves.toMatchObject({ + leaseGeneration: 4, + presenceSequence: 0, + evictedAnotherDevice: true, + }); + }); + + it('reports device capacity instead of evicting during preparation', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + prepareEarlyBirdStreamLease.mockRejectedValue(new EarlyBirdDeviceCapacityError()); + + const response = await POST(request('device_abcdefghijklmnopqrstuvwxyz', 'prepare')); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ reason: 'device_limit' }); + expect(acquireEarlyBirdStreamLease).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/early-birds/stream/lease/route.ts b/src/app/api/early-birds/stream/lease/route.ts new file mode 100644 index 00000000..9f505a5f --- /dev/null +++ b/src/app/api/early-birds/stream/lease/route.ts @@ -0,0 +1,81 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; +import { + acquireEarlyBirdStreamLease, + acquireFreeForAllStreamLease, + claimEarlyBirdStreamLease, + EarlyBirdAccessDeniedError, + EarlyBirdDeviceCapacityError, + EarlyBirdStreamIssuerUnavailableError, + prepareEarlyBirdStreamLease, +} from '@/lib/early-birds/stream'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + + const freeForAll = earlyBirdsFreeForAll(); + const session = freeForAll + ? null + : await currentEarlyBirdSession(request.headers).catch(() => null); + if (!freeForAll && !session) { + return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + } + + let deviceId: string; + let intent: 'play' | 'prepare' | 'claim'; + try { + const body = await request.json() as { deviceId?: unknown; intent?: unknown }; + deviceId = typeof body.deviceId === 'string' ? body.deviceId : ''; + intent = body.intent === 'prepare' + ? 'prepare' + : body.intent === 'claim' ? 'claim' : 'play'; + } catch { + return NextResponse.json({ error: 'Malformed request.' }, { status: 400 }); + } + + try { + const grant = freeForAll + ? await acquireFreeForAllStreamLease(deviceId) + : intent === 'prepare' + ? await prepareEarlyBirdStreamLease(session!.user.id, deviceId) + : intent === 'claim' + ? await claimEarlyBirdStreamLease(session!.user.id, deviceId) + : await acquireEarlyBirdStreamLease(session!.user.id, deviceId); + return NextResponse.json({ + serverNow: grant.serverNow.toISOString(), + accessKind: grant.accessKind, + quota: grant.quota, + leaseId: grant.leaseId, + leaseGeneration: grant.leaseGeneration, + presenceSequence: grant.presenceSequence, + leaseExpiresAt: grant.leaseExpiresAt.toISOString(), + evictedAnotherDevice: grant.evictedLeaseId !== null, + stream: { + manifestUrl: grant.stream.manifestUrl, + expiresAt: grant.stream.expiresAt.toISOString(), + }, + }); + } catch (error) { + if (error instanceof EarlyBirdAccessDeniedError) { + return NextResponse.json({ error: 'Listening access inactive.' }, { status: 403 }); + } + if (error instanceof EarlyBirdStreamIssuerUnavailableError) { + return NextResponse.json({ error: 'Stream temporarily unavailable.' }, { status: 503 }); + } + if (error instanceof EarlyBirdDeviceCapacityError) { + return NextResponse.json({ error: 'Two devices are already active.', reason: 'device_limit' }, { status: 409 }); + } + if (error instanceof Error && error.message === 'invalid device id') { + return NextResponse.json({ error: 'Invalid device.' }, { status: 400 }); + } + return NextResponse.json({ error: 'Stream temporarily unavailable.' }, { status: 503 }); + } +} diff --git a/src/app/api/early-birds/test-login/__tests__/route.test.ts b/src/app/api/early-birds/test-login/__tests__/route.test.ts new file mode 100644 index 00000000..efd67d06 --- /dev/null +++ b/src/app/api/early-birds/test-login/__tests__/route.test.ts @@ -0,0 +1,87 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + user: vi.fn(), subject: vi.fn(), session: vi.fn(), findSession: vi.fn(), membership: vi.fn(), +})); +vi.mock('@/lib/db', () => ({ prisma: { + $transaction: async (apply: (transaction: unknown) => unknown) => apply({ + earlyBirdUser: { upsert: mocks.user }, + listenerAccountSubject: { upsert: mocks.subject }, + listenerAccountSession: { create: mocks.session }, + }), + listenerAccountSession: { findUnique: mocks.findSession }, +} })); +vi.mock('@/lib/early-birds/membership', () => ({ issueSyntheticMembership: mocks.membership })); + +import { + currentListenerAccountSession, + LISTENER_ACCOUNT_COOKIE, +} from '@/lib/listener/account-rp'; +import { POST } from '../route'; + +function request(secret = 's'.repeat(32), authOnly = false) { + return new NextRequest('https://earlybirds-staging.harmonicbeacon.com/api/early-birds/test-login', { + method: 'POST', + headers: { + host: 'earlybirds-staging.harmonicbeacon.com', 'x-forwarded-proto': 'https', + authorization: `Bearer ${secret}`, 'content-type': 'application/json', + }, + body: JSON.stringify({ email: 'listener@e2e.invalid', name: 'Synthetic Listener', authOnly }), + }); +} + +describe('supervised Listener synthetic Account RP session', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_TEST_ACCESS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_TEST_LOGIN_SECRET', 's'.repeat(32)); + vi.stubEnv('EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS', 'earlybirds-staging.harmonicbeacon.com'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING', 'c'.repeat(32)); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING', 'b'.repeat(32)); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENVIRONMENT', 'staging'); + }); + afterEach(() => vi.unstubAllEnvs()); + + it('mints only the canonical host-only RP cookie and issuer-bound local session', async () => { + const response = await POST(request()); + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toContain(`${LISTENER_ACCOUNT_COOKIE}=`); + expect(response.headers.get('set-cookie')).toContain('Path=/; HttpOnly; Secure; SameSite=Lax'); + expect(response.headers.get('set-cookie')).not.toContain('hb_earlybird'); + expect(mocks.session).toHaveBeenCalledWith({ data: expect.objectContaining({ + issuer: 'https://account-staging.harmonicbeacon.com', + subject: expect.stringMatching(/^test_/), synthetic: true, + }) }); + expect(mocks.membership).toHaveBeenCalledOnce(); + + const data = mocks.session.mock.calls[0]?.[0].data; + mocks.findSession.mockResolvedValue({ + id: 'local-session', ...data, + account: { + id: data.accountId, name: 'Synthetic Listener', email: 'listener@e2e.invalid', + image: null, beaconProfile: { displayName: 'Synthetic Listener' }, + }, + }); + const cookie = response.headers.get('set-cookie')!.split(';', 1)[0]; + const resolved = await currentListenerAccountSession(new Headers({ + host: 'earlybirds-staging.harmonicbeacon.com', cookie, + })); + expect(resolved?.user.id).toBe(data.accountId); + }); + + it('can mint identity-only state for invitation redemption', async () => { + expect((await POST(request('s'.repeat(32), true))).status).toBe(200); + expect(mocks.membership).not.toHaveBeenCalled(); + }); + + it('remains hidden without the supervised bearer or on an unsafe host', async () => { + expect((await POST(request('x'.repeat(32)))).status).toBe(404); + const unsafe = request(); unsafe.headers.set('host', 'other.example.test'); + expect((await POST(unsafe)).status).toBe(404); + }); +}); diff --git a/src/app/api/early-birds/test-login/route.ts b/src/app/api/early-birds/test-login/route.ts new file mode 100644 index 00000000..1bb8a876 --- /dev/null +++ b/src/app/api/early-birds/test-login/route.ts @@ -0,0 +1,68 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +import { NextResponse, type NextRequest } from 'next/server'; + +import { prisma } from '@/lib/db'; +import { earlyBirdTestAuthEnabled, earlyBirdTestLoginSecret } from '@/lib/early-birds/auth'; +import { issueSyntheticMembership } from '@/lib/early-birds/membership'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; +import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entry'; +import { listenerAccountCookie, localListenerAccountId } from '@/lib/listener/account-rp'; +import { digestSessionToken } from '@/lib/session-auth'; + +export const dynamic = 'force-dynamic'; +const STAGING_ISSUER = 'https://account-staging.harmonicbeacon.com'; + +function notFound(): NextResponse { + return NextResponse.json({ error: 'Not found.' }, { status: 404 }); +} +function digest(value: string): Buffer { + return createHash('sha256').update(value, 'utf8').digest(); +} +function authorizedSyntheticLogin(request: NextRequest): boolean { + const expected = earlyBirdTestLoginSecret(); + if (!expected || !earlyBirdTestAuthEnabled()) return false; + const authorization = request.headers.get('authorization'); + const presented = authorization?.startsWith('Bearer ') ? authorization.slice(7) : ''; + return timingSafeEqual(digest(presented), digest(expected)); +} + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled() || !syntheticTeamEntryAllowed({ + headers: request.headers, requestProtocol: request.nextUrl.protocol, + }) || !authorizedSyntheticLogin(request)) return notFound(); + const body = await request.json().catch(() => null) as { + email?: unknown; name?: unknown; authOnly?: unknown; + } | null; + const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : ''; + const name = typeof body?.name === 'string' ? body.name.trim() : ''; + if (!/^[a-z0-9._-]{1,80}@e2e\.invalid$/.test(email) || name.length < 1 || name.length > 80) { + return NextResponse.json({ error: 'An e2e.invalid identity and name are required.' }, { status: 400 }); + } + const subject = `test_${createHash('sha256').update(email).digest('base64url')}`; + const accountId = localListenerAccountId(STAGING_ISSUER, subject); + const token = randomBytes(32).toString('base64url'); + const expiresAt = new Date(Date.now() + 8 * 60 * 60_000); + await prisma.$transaction(async (transaction) => { + await transaction.earlyBirdUser.upsert({ + where: { id: accountId }, create: { id: accountId, name, email, emailVerified: true }, + update: { name }, + }); + await transaction.listenerAccountSubject.upsert({ + where: { issuer_subject: { issuer: STAGING_ISSUER, subject } }, + create: { accountId, issuer: STAGING_ISSUER, subject }, update: {}, + }); + await transaction.listenerAccountSession.create({ data: { + tokenDigest: digestSessionToken(token), accountId, issuer: STAGING_ISSUER, + subject, sid: `synthetic_${randomBytes(16).toString('base64url')}`, + synthetic: true, expiresAt, lastCheckedAt: new Date(), + } }); + }); + if (body?.authOnly !== true) await issueSyntheticMembership(accountId); + return NextResponse.json({ ok: true, landing: '/early-birds' }, { headers: { + 'Cache-Control': 'private, no-store', + 'Set-Cookie': listenerAccountCookie(token, 8 * 60 * 60), + } }); +} + +export async function GET(): Promise { return notFound(); } diff --git a/src/app/api/health/__tests__/ready-route.test.ts b/src/app/api/health/__tests__/ready-route.test.ts index 94655f2a..3174b6a5 100644 --- a/src/app/api/health/__tests__/ready-route.test.ts +++ b/src/app/api/health/__tests__/ready-route.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; import { parseResponse } from '@/__tests__/helpers'; describe('GET /api/health/ready', () => { beforeEach(() => { vi.resetModules(); }); + afterEach(() => vi.unstubAllEnvs()); it('returns 200 when the database query succeeds', async () => { const mockPrisma = { $queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1 }]) }; @@ -19,6 +20,131 @@ describe('GET /api/health/ready', () => { expect(response.headers.get('cache-control')).toBe('no-store'); }); + it('fails readiness without leaking values when Listener aliases conflict', async () => { + const canonicalSecret = 'canonical-secret-that-must-not-leak'; + const legacySecret = 'legacy-secret-that-must-not-leak'; + vi.stubEnv('BEACON_LISTENER_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_AUTH_BASE_URL', 'https://listen.example.test'); + vi.stubEnv('EARLY_BIRDS_AUTH_BASE_URL', 'https://listen.example.test'); + vi.stubEnv('BEACON_LISTENER_AUTH_SECRET', canonicalSecret); + vi.stubEnv('EARLY_BIRDS_AUTH_SECRET', legacySecret); + const mockPrisma = { $queryRaw: vi.fn() }; + vi.doMock('@/lib/db', () => ({ prisma: mockPrisma, default: mockPrisma })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const { GET } = await import('../ready/route'); + const response = await GET(); + const { status, body } = await parseResponse(response); + expect(status).toBe(503); + expect(body).toEqual({ + status: 'error', + checks: { database: 'unknown', listenerRuntime: 'invalid' }, + }); + expect(mockPrisma.$queryRaw).not.toHaveBeenCalled(); + const logged = errorSpy.mock.calls.flat().map(String).join(' '); + expect(logged).toContain('BEACON_LISTENER_AUTH_SECRET'); + expect(logged).not.toContain(canonicalSecret); + expect(logged).not.toContain(legacySecret); + } finally { + errorSpy.mockRestore(); + } + }); + + it('requires the private media-grant control origin when Listener is enabled', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_AUTH_BASE_URL', 'https://listen.example.test'); + vi.stubEnv('EARLY_BIRDS_AUTH_SECRET', 'a'.repeat(32)); + vi.stubEnv('EARLY_BIRDS_STREAM_ORIGIN', 'https://stream.example.test'); + vi.stubEnv('EARLY_BIRDS_STREAM_ARTIFACT_ID', 'approved-v1'); + vi.stubEnv('EARLY_BIRDS_STREAM_SIGNING_SECRET', 's'.repeat(32)); + vi.stubEnv('EARLY_BIRDS_STREAM_CONTROL_ORIGIN', ''); + const mockPrisma = { $queryRaw: vi.fn() }; + vi.doMock('@/lib/db', () => ({ prisma: mockPrisma, default: mockPrisma })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const { GET } = await import('../ready/route'); + expect((await GET()).status).toBe(503); + expect(mockPrisma.$queryRaw).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + + it('fails readiness before the database on a partial private Live workbench', async () => { + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED', '1'); + const mockPrisma = { $queryRaw: vi.fn() }; + vi.doMock('@/lib/db', () => ({ prisma: mockPrisma, default: mockPrisma })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const { GET } = await import('../ready/route'); + const response = await GET(); + const { status, body } = await parseResponse(response); + expect(status).toBe(503); + expect(body).toEqual({ + status: 'error', + checks: { database: 'unknown', listenerRuntime: 'invalid' }, + }); + expect(mockPrisma.$queryRaw).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.flat().map(String).join(' ')).not.toContain('undefined'); + } finally { + errorSpy.mockRestore(); + } + }); + + it('fails readiness before the database when withdrawal is enabled without its secret', async () => { + vi.stubEnv('LISTENER_WITHDRAWAL_ENABLED', '1'); + vi.stubEnv('LISTENER_WITHDRAWAL_SECRET', ''); + const mockPrisma = { $queryRaw: vi.fn() }; + vi.doMock('@/lib/db', () => ({ prisma: mockPrisma, default: mockPrisma })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const { GET } = await import('../ready/route'); + const { status, body } = await parseResponse(await GET()); + expect(status).toBe(503); + expect(body).toEqual({ + status: 'error', + checks: { database: 'unknown', listenerRuntime: 'invalid' }, + }); + expect(mockPrisma.$queryRaw).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + + it('fails readiness when enabled withdrawal tables have not been migrated', async () => { + vi.stubEnv('LISTENER_WITHDRAWAL_ENABLED', '1'); + vi.stubEnv('LISTENER_WITHDRAWAL_SECRET', 's'.repeat(32)); + const mockPrisma = { $queryRaw: vi.fn().mockResolvedValue([{ requests: null, throttles: null }]) }; + vi.doMock('@/lib/db', () => ({ prisma: mockPrisma, default: mockPrisma })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const { GET } = await import('../ready/route'); + const { status, body } = await parseResponse(await GET()); + expect(status).toBe(503); + expect(body).toEqual({ status: 'error', checks: { database: 'unreachable' } }); + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it('reports enabled withdrawal readiness only after both tables are readable', async () => { + vi.stubEnv('LISTENER_WITHDRAWAL_ENABLED', '1'); + vi.stubEnv('LISTENER_WITHDRAWAL_SECRET', 's'.repeat(32)); + const mockPrisma = { $queryRaw: vi.fn() + .mockResolvedValueOnce([{ requests: 'listener_withdrawal_requests', throttles: 'listener_withdrawal_throttles' }]) + .mockResolvedValueOnce([{ '?column?': 1 }]) }; + vi.doMock('@/lib/db', () => ({ prisma: mockPrisma, default: mockPrisma })); + const { GET } = await import('../ready/route'); + const { status, body } = await parseResponse(await GET()); + expect(status).toBe(200); + expect(body).toEqual({ + status: 'ok', + checks: { database: 'ok', listenerWithdrawal: 'ok' }, + }); + }); + it('returns 503 when the database query rejects', async () => { const mockPrisma = { $queryRaw: vi.fn().mockRejectedValue( diff --git a/src/app/api/health/ready/route.ts b/src/app/api/health/ready/route.ts index 153b7e9e..55137549 100644 --- a/src/app/api/health/ready/route.ts +++ b/src/app/api/health/ready/route.ts @@ -1,7 +1,26 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { redactError } from '@/lib/redact'; +import { + ListenerRuntimeEnvironmentError, + listenerRuntimeFlag, + validateListenerRuntimeEnvironment, +} from '@/lib/listener/runtime-env'; +import { + ListenerLiveWorkbenchConfigurationError, + validateListenerLiveWorkbenchEnvironment, +} from '@/lib/early-birds/live-workbench'; import { OperationTimeoutError, withTimeout } from '@/lib/with-timeout'; +import { + ListenerWithdrawalConfigurationError, + listenerWithdrawalConfiguration, +} from '@/lib/listener/consumer-withdrawal'; +import { + earlyBirdOriginConfig, + earlyBirdStreamControlOrigin, + EarlyBirdStreamIssuerUnavailableError, +} from '@/lib/early-birds/stream'; +import { validateListenerAccountRPEnvironment } from '@/lib/listener/account-rp'; export const dynamic = 'force-dynamic'; @@ -17,10 +36,60 @@ const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' }; * body distinguishes only 'timeout' from 'unreachable', nothing more. */ export async function GET() { + let listenerRuntimeConfigured = false; + let listenerWithdrawalConfigured = false; + let listenerAccountConfigured = false; try { + listenerRuntimeConfigured = validateListenerRuntimeEnvironment(); + if (listenerRuntimeFlag('ENABLED')) { + earlyBirdOriginConfig(); + earlyBirdStreamControlOrigin(); + } + validateListenerLiveWorkbenchEnvironment(); + listenerWithdrawalConfigured = listenerWithdrawalConfiguration().enabled; + listenerAccountConfigured = validateListenerAccountRPEnvironment(); + } catch (error) { + const diagnostic = error instanceof ListenerRuntimeEnvironmentError || + error instanceof ListenerLiveWorkbenchConfigurationError || + error instanceof ListenerWithdrawalConfigurationError + || error instanceof EarlyBirdStreamIssuerUnavailableError + ? error.message + : 'unexpected validation failure'; + console.error('Listener runtime configuration invalid:', diagnostic); + return NextResponse.json( + { + status: 'error', + checks: { database: 'unknown', listenerRuntime: 'invalid' }, + }, + { status: 503, headers: NO_STORE_HEADERS }, + ); + } + try { + if (listenerWithdrawalConfigured) { + const tables = await withTimeout( + prisma.$queryRaw>` + SELECT + to_regclass('public.listener_withdrawal_requests')::text AS requests, + to_regclass('public.listener_withdrawal_throttles')::text AS throttles + `, + DB_CHECK_TIMEOUT_MS, + 'Listener withdrawal schema check', + ); + if (!tables[0]?.requests || !tables[0]?.throttles) { + throw new Error('Listener withdrawal schema unavailable'); + } + } await withTimeout(prisma.$queryRaw`SELECT 1`, DB_CHECK_TIMEOUT_MS, 'Database check'); return NextResponse.json( - { status: 'ok', checks: { database: 'ok' } }, + { + status: 'ok', + checks: { + database: 'ok', + ...(listenerRuntimeConfigured ? { listenerRuntime: 'ok' } : {}), + ...(listenerWithdrawalConfigured ? { listenerWithdrawal: 'ok' } : {}), + ...(listenerAccountConfigured ? { listenerAccount: 'ok' } : {}), + }, + }, { headers: NO_STORE_HEADERS }, ); } catch (error) { diff --git a/src/app/api/internal/v1/listener/session-cookie-observations/__tests__/route.test.ts b/src/app/api/internal/v1/listener/session-cookie-observations/__tests__/route.test.ts new file mode 100644 index 00000000..af28b8b4 --- /dev/null +++ b/src/app/api/internal/v1/listener/session-cookie-observations/__tests__/route.test.ts @@ -0,0 +1,99 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ render: vi.fn() })); +vi.mock('@/lib/listener/session-cookie-observability', async (importOriginal) => { + const original = await importOriginal(); + mocks.render.mockImplementation(original.renderListenerSessionCookieObservations); + return { + ...original, + renderListenerSessionCookieObservations: mocks.render, + }; +}); + +import { GET } from '../route'; +import { + LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC, + LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC, + LISTENER_SESSION_COOKIE_STATES, + recordListenerSessionCookieObservation, +} from '@/lib/listener/session-cookie-observability'; + +const PATH = '/api/internal/v1/listener/session-cookie-observations'; + +function request(host: string | null, headers: Record = {}): NextRequest { + return new NextRequest(`http://beacon-app:3000${PATH}`, { + headers: { ...(host === null ? {} : { host }), ...headers }, + }); +} + +describe('Listener session-cookie observations route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('serves the fixed Prometheus exposition on the canonical Listener host', async () => { + recordListenerSessionCookieObservation('dual_identical'); + const response = GET(request('listen.harmonicbeacon.com')); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/plain; version=0.0.4; charset=utf-8'); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + + const body = await response.text(); + for (const state of LISTENER_SESSION_COOKIE_STATES) { + expect(body).toContain(`${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC}{state="${state}"}`); + } + const labelSets = [...body.matchAll(/\{([^}]*)\}/g)].map((match) => match[1]); + expect(labelSets).toHaveLength(LISTENER_SESSION_COOKIE_STATES.length); + for (const labelSet of labelSets) expect(labelSet).toMatch(/^state="[a-z_]+"$/); + expect(body).toMatch(new RegExp(`^${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} \\d+$`, 'm')); + }); + + it('accepts the canonical host with an optional port', async () => { + const response = GET(request('listen.harmonicbeacon.com:443')); + expect(response.status).toBe(200); + }); + + it('answers 404 on any other host and never trusts a forwarded host', async () => { + for (const host of [ + 'live.harmonicbeacon.com', + 'earlybirds-staging.harmonicbeacon.com', + 'beacon-app:3000', + 'listen.harmonicbeacon.com.attacker.invalid', + ]) { + const response = GET(request(host)); + expect(response.status, host).toBe(404); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + await expect(response.json()).resolves.toEqual({ error: 'Resource not found.' }); + } + // A forwarded header never substitutes for the request Host. + const spoofed = GET(request('live.harmonicbeacon.com', { + 'x-forwarded-host': 'listen.harmonicbeacon.com', + })); + expect(spoofed.status).toBe(404); + const missing = GET(request(null, { 'x-forwarded-host': 'listen.harmonicbeacon.com' })); + expect(missing.status).toBe(404); + }); + + it('answers a generic 503 when its own observer fails', async () => { + mocks.render.mockImplementationOnce(() => { + throw new Error('observer down'); + }); + const response = GET(request('listen.harmonicbeacon.com')); + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + const body = await response.text(); + expect(body).not.toContain('observer down'); + expect(JSON.parse(body)).toEqual({ error: 'Session-cookie observations unavailable.' }); + }); + + it('is GET-only and touches no database, auth or request metadata', async () => { + const routeModule = await import('../route'); + expect('POST' in routeModule).toBe(false); + // No authorization, cookie, body or query material is read: the Host + // header alone decides, and the exposition is fixed aggregate state. + const source = await import('node:fs/promises') + .then((fs) => fs.readFile(new URL('../route.ts', import.meta.url), 'utf8')); + expect(source).not.toMatch(/@\/lib\/db|prisma|service-auth|authorization|cookies\(\)/); + }); +}); diff --git a/src/app/api/internal/v1/listener/session-cookie-observations/route.ts b/src/app/api/internal/v1/listener/session-cookie-observations/route.ts new file mode 100644 index 00000000..1483b989 --- /dev/null +++ b/src/app/api/internal/v1/listener/session-cookie-observations/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { isCanonicalListenerHost } from '@/lib/listener/public-discovery'; +import { renderListenerSessionCookieObservations } from '@/lib/listener/session-cookie-observability'; + +export const dynamic = 'force-dynamic'; + +const NO_STORE = { 'Cache-Control': 'private, no-store' }; + +/** + * Aggregate Listener session-cookie compatibility observations for the + * rollback-support window. GET-only, loopback-operated: the public nginx + * templates do not expose this path, and the route additionally answers 404 + * on any Host other than the canonical Listener host (the request Host + * header, never a forwarded one). No database, no authentication, no request + * metadata and no dynamic labels: the output is the fixed nine-state counter + * exposition plus the process-start gauge, and it carries no cookie, header, + * user, session, account, IP or user-agent material. + */ +export function GET(request: NextRequest): Response { + if (!isCanonicalListenerHost(request.headers)) { + return NextResponse.json({ error: 'Resource not found.' }, { status: 404, headers: NO_STORE }); + } + try { + return new Response(renderListenerSessionCookieObservations(), { + status: 200, + headers: { + 'content-type': 'text/plain; version=0.0.4; charset=utf-8', + 'cache-control': 'private, no-store', + }, + }); + } catch { + return NextResponse.json( + { error: 'Session-cookie observations unavailable.' }, + { status: 503, headers: NO_STORE }, + ); + } +} diff --git a/src/app/api/internal/v2/early-bird-memberships/[accountId]/__tests__/route.test.ts b/src/app/api/internal/v2/early-bird-memberships/[accountId]/__tests__/route.test.ts new file mode 100644 index 00000000..a5fc276d --- /dev/null +++ b/src/app/api/internal/v2/early-bird-memberships/[accountId]/__tests__/route.test.ts @@ -0,0 +1,162 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + apply: vi.fn(), + findUnique: vi.fn(), +})); + +vi.mock('@/lib/early-birds/service-auth', () => ({ + authorizeEarlyBirdMembershipService: mocks.authorize, +})); +vi.mock('@/lib/early-birds/membership', async (importOriginal) => ({ + ...await importOriginal(), + applyMembershipProjection: mocks.apply, +})); +vi.mock('@/lib/db', () => ({ + prisma: { earlyBirdMembershipProjection: { findUnique: mocks.findUnique } }, +})); + +import { GET, PUT } from '../route'; +import { EarlyBirdProjectionAccountMissingError } from '@/lib/early-birds/membership'; + +const ACCOUNT = 'listener-1'; +const continuity = { + episode_id: '00000000-0000-4000-8000-000000000101', + revision: 2, + state: 'ACTIVE', + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + canonical_price: { currency: 'USD', amount_minor: 500 }, + billing_period: 'MONTHLY', + activated_at: '2026-08-06T12:00:00Z', + service_through: '2026-09-06T12:00:00Z', + ended_at: null, + terminal_reason: null, +}; +const command = { + schema_version: 'early-bird-membership.command.v2', + account_id: ACCOUNT, + membership_revision: 3, + state: 'ACTIVE', + source: 'PAYPAL', + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + effective_at: '2026-08-06T12:00:00Z', + paid_through: '2026-09-06T12:00:00Z', + grace_until: null, + provider: 'paypal', + current_price: { currency: 'USD', amount_minor: 500 }, + reason_code: 'PAYMENT_SUCCEEDED', + founder_continuity: continuity, +}; +const projection = { + id: 'eb100000-0000-4000-8000-000000000001', + accountId: ACCOUNT, + revision: 3, + commandHash: 'a'.repeat(64), + state: 'ACTIVE', + source: 'PAYPAL', + offerCode: 'EARLY_BIRDS_FOUNDERS_V1', + offerRevision: 1, + effectiveAt: new Date('2026-08-06T12:00:00Z'), + paidThrough: new Date('2026-09-06T12:00:00Z'), + graceUntil: null, + provider: 'paypal', + amountMinor: 500, + currency: 'USD', + reasonCode: 'PAYMENT_SUCCEEDED', + synthetic: false, + founderContinuityEpisodeId: continuity.episode_id, + founderContinuityRevision: continuity.revision, + founderContinuityState: 'ACTIVE', + founderContinuityOfferCode: 'EARLY_BIRDS_FOUNDERS_V1', + founderContinuityOfferRevision: 1, + founderContinuityCurrency: 'USD', + founderContinuityAmountMinor: 500, + founderContinuityBillingPeriod: 'MONTHLY', + founderContinuityActivatedAt: new Date(continuity.activated_at), + founderContinuityServiceThrough: new Date(continuity.service_through), + founderContinuityEndedAt: null, + founderContinuityTerminalReason: null, + createdAt: new Date('2026-08-06T12:00:00Z'), + updatedAt: new Date('2026-08-06T12:00:00Z'), +}; + +function put(body: unknown = command, headers: Record = {}) { + return new NextRequest(`http://beacon-app:3000/api/internal/v2/early-bird-memberships/${ACCOUNT}`, { + method: 'PUT', + headers: { + authorization: 'Bearer secret-not-logged', + 'x-hb-service-key-id': 'current', + 'content-type': 'application/json', + 'idempotency-key': `early-bird-membership:${ACCOUNT}:3`, + ...headers, + }, + body: JSON.stringify(body), + }); +} + +const params = { params: Promise.resolve({ accountId: ACCOUNT }) }; + +describe('private EarlyBird membership projection v2 route', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authorize.mockReturnValue(true); + mocks.apply.mockResolvedValue({ projection, outcome: 'APPLIED' }); + mocks.findUnique.mockResolvedValue(projection); + }); + + it.each(['APPLIED', 'REPLAYED', 'STALE'] as const)('returns the canonical %s outcome', async (outcome) => { + mocks.apply.mockResolvedValue({ projection, outcome }); + const response = await PUT(put(), params); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + await expect(response.json()).resolves.toEqual({ + schema_version: 'early-bird-membership.result.v1', + membership_id: projection.id, + account_id: ACCOUNT, + outcome, + applied_revision: 3, + effective_state: 'ACTIVE', + access_allowed: true, + reconciliation_required: false, + }); + }); + + it('authenticates before parsing and rejects v1, extra fields and wrong idempotency', async () => { + mocks.authorize.mockReturnValue(false); + const unauthorized = await PUT(put({ secret_material: 'not-read' }), params); + expect(unauthorized.status).toBe(401); + expect(mocks.apply).not.toHaveBeenCalled(); + + mocks.authorize.mockReturnValue(true); + expect((await PUT(put({ ...command, schema_version: 'early-bird-membership.command.v1' }), params)).status) + .toBe(422); + expect((await PUT(put({ ...command, unexpected: true }), params)).status).toBe(422); + expect((await PUT(put(command, { 'idempotency-key': 'wrong' }), params)).status).toBe(422); + }); + + it('returns the non-secret current projection acknowledgement', async () => { + const request = new NextRequest( + `http://beacon-app:3000/api/internal/v2/early-bird-memberships/${ACCOUNT}`, + { headers: { authorization: 'Bearer hidden', 'x-hb-service-key-id': 'current' } }, + ); + const response = await GET(request, params); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + schema_version: 'early-bird-membership.result.v1', + account_id: ACCOUNT, + outcome: 'REPLAYED', + }); + }); + + it('fails a canonical projection permanently when its Listener account does not exist', async () => { + mocks.apply.mockRejectedValue(new EarlyBirdProjectionAccountMissingError()); + + const response = await PUT(put(), params); + + expect(response.status).toBe(404); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + await expect(response.json()).resolves.toEqual({ error: 'Resource not found.' }); + }); +}); diff --git a/src/app/api/internal/v2/early-bird-memberships/[accountId]/route.ts b/src/app/api/internal/v2/early-bird-memberships/[accountId]/route.ts new file mode 100644 index 00000000..66df886f --- /dev/null +++ b/src/app/api/internal/v2/early-bird-memberships/[accountId]/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { prisma } from '@/lib/db'; +import { + EarlyBirdMembershipContractError, + parseMembershipProjectionCommand, +} from '@/lib/early-birds/membership-contract'; +import { + applyMembershipProjection, + EarlyBirdProjectionAccountMissingError, + EarlyBirdProjectionConflictError, + membershipAccessDecision, + type EarlyBirdProjectionOutcome, +} from '@/lib/early-birds/membership'; +import { authorizeEarlyBirdMembershipService } from '@/lib/early-birds/service-auth'; + +export const dynamic = 'force-dynamic'; + +const MAX_BODY_BYTES = 16 * 1024; +const NO_STORE = { 'Cache-Control': 'private, no-store' }; + +function response(body: unknown, status = 200): NextResponse { + return NextResponse.json(body, { status, headers: NO_STORE }); +} + +function authorized(request: NextRequest): boolean { + return authorizeEarlyBirdMembershipService( + request.headers.get('authorization'), + request.headers.get('x-hb-service-key-id'), + ); +} + +// The acknowledgement shape did not change with command v2. It deliberately +// exposes only the applied revision and effective access decision. +function result( + projection: NonNullable['projection']>, + outcome: EarlyBirdProjectionOutcome, +) { + return { + schema_version: 'early-bird-membership.result.v1', + membership_id: projection.id, + account_id: projection.accountId, + outcome, + applied_revision: projection.revision, + effective_state: projection.state, + access_allowed: membershipAccessDecision(projection).allowed, + reconciliation_required: false, + }; +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ accountId: string }> }, +): Promise { + if (!authorized(request)) return response({ error: 'Service authentication failed.' }, 401); + const { accountId } = await params; + if (!accountId || accountId.length > 255) return response({ error: 'Resource not found.' }, 404); + if (request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + return response({ error: 'Content-Type must be application/json.' }, 400); + } + const contentLength = Number(request.headers.get('content-length') || '0'); + if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) { + return response({ error: 'Request body exceeds 16 KiB.' }, 413); + } + + let raw: string; + try { + raw = await request.text(); + } catch { + return response({ error: 'Malformed request.' }, 400); + } + if (Buffer.byteLength(raw, 'utf8') > MAX_BODY_BYTES) { + return response({ error: 'Request body exceeds 16 KiB.' }, 413); + } + + try { + const command = parseMembershipProjectionCommand(JSON.parse(raw) as unknown); + if (command.account_id !== accountId) return response({ error: 'Account mismatch.' }, 422); + const expectedKey = `early-bird-membership:${accountId}:${command.membership_revision}`; + if (request.headers.get('idempotency-key') !== expectedKey) { + return response({ error: 'Idempotency-Key mismatch.' }, 422); + } + const applied = await applyMembershipProjection(command); + return response(result(applied.projection, applied.outcome)); + } catch (error) { + if (error instanceof SyntaxError) return response({ error: 'Malformed request.' }, 400); + if (error instanceof EarlyBirdMembershipContractError) return response({ error: error.message }, 422); + if (error instanceof EarlyBirdProjectionConflictError) { + return response({ error: 'Revision conflicts with the existing command.' }, 409); + } + if (error instanceof EarlyBirdProjectionAccountMissingError) { + return response({ error: 'Resource not found.' }, 404); + } + console.error('[early-bird-membership] apply failed without request material'); + return response({ error: 'Membership projection unavailable.' }, 500); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ accountId: string }> }, +): Promise { + if (!authorized(request)) return response({ error: 'Service authentication failed.' }, 401); + const { accountId } = await params; + if (!accountId || accountId.length > 255) return response({ error: 'Resource not found.' }, 404); + try { + const projection = await prisma.earlyBirdMembershipProjection.findUnique({ where: { accountId } }); + return projection ? response(result(projection, 'REPLAYED')) : response({ error: 'Resource not found.' }, 404); + } catch { + console.error('[early-bird-membership] reconciliation read failed'); + return response({ error: 'Membership projection unavailable.' }, 500); + } +} diff --git a/src/app/api/listener/access-state/route.ts b/src/app/api/listener/access-state/route.ts new file mode 100644 index 00000000..6b740756 --- /dev/null +++ b/src/app/api/listener/access-state/route.ts @@ -0,0 +1,3 @@ +export const dynamic = 'force-dynamic'; + +export { GET } from '../../early-birds/access-state/route'; diff --git a/src/app/api/listener/analysis/frame/__tests__/route.test.ts b/src/app/api/listener/analysis/frame/__tests__/route.test.ts new file mode 100644 index 00000000..b536f15a --- /dev/null +++ b/src/app/api/listener/analysis/frame/__tests__/route.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const analyzer = vi.hoisted(() => ({ frameAt: vi.fn() })); +const routeState = vi.hoisted(() => ({ + enabled: true, + freeForAll: false, + session: { user: { id: 'account-1' } } as { user: { id: string } } | null, + authorize: vi.fn(), + authorizeFreeForAll: vi.fn(), +})); + +vi.mock('@/lib/listener/analysis/server-harmonic-analyzer', () => ({ + listenerServerHarmonicAnalyzer: () => analyzer, + serializeServerHarmonicFrame: (frame: unknown) => frame, +})); +vi.mock('@/lib/early-birds/auth', () => ({ + currentEarlyBirdSession: () => Promise.resolve(routeState.session), +})); +vi.mock('@/lib/early-birds/enabled', () => ({ + earlyBirdsEnabled: () => routeState.enabled, + earlyBirdsFreeForAll: () => routeState.freeForAll, +})); +vi.mock('@/lib/early-birds/stream', () => ({ + authorizeEarlyBirdStreamLease: (...args: unknown[]) => routeState.authorize(...args), + authorizeFreeForAllStreamLease: (...args: unknown[]) => routeState.authorizeFreeForAll(...args), +})); + +import { GET } from '../route'; + +function request(host: string, at = Date.now()) { + return new Request( + `https://${host}/api/listener/analysis/frame?at=${at}` + + '&leaseId=00000000-0000-4000-8000-000000000003&leaseGeneration=7', { + headers: { host }, + }, + ); +} + +describe('GET /api/listener/analysis/frame', () => { + beforeEach(() => { + routeState.enabled = true; + routeState.freeForAll = false; + routeState.session = { user: { id: 'account-1' } }; + routeState.authorize.mockReset().mockResolvedValue({}); + routeState.authorizeFreeForAll.mockReset().mockResolvedValue({}); + analyzer.frameAt.mockReset().mockResolvedValue({ + schemaVersion: 1, + capturedAtMs: Date.now(), + sourceTimeSeconds: 1, + overallDb: -18, + harmonicAbsoluteDb: [-10], + harmonicDeltaDb: [0], + spectralEnvelopeDb: [-10], + stereoBalance: 0, + stereoWidth: 0, + confidence: 1, + sourceKind: 'beacon', + }); + }); + + it('serves no-store frames only on the exact Listener hosts', async () => { + const response = await GET(request('earlybirds-staging.harmonicbeacon.com')); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); + expect(response.headers.get('X-Listener-Analysis-Source')).toBe('server'); + expect(routeState.authorize).toHaveBeenCalledWith( + 'account-1', + '00000000-0000-4000-8000-000000000003', + 7, + ); + expect(analyzer.frameAt).toHaveBeenCalledOnce(); + + expect((await GET(request('listen.harmonicbeacon.com'))).status).toBe(200); + + for (const host of [ + 'live.harmonicbeacon.com', + 'earlybirds-staging.harmonicbeacon.com.evil.test', + ]) { + const rejected = await GET(request(host)); + expect(rejected.status).toBe(404); + } + }); + + it('rejects stale program times before decoding', async () => { + expect((await GET(request( + 'earlybirds-staging.harmonicbeacon.com', + Date.now() - 3 * 60_000, + ))).status).toBe(200); + analyzer.frameAt.mockClear(); + const response = await GET(request( + 'earlybirds-staging.harmonicbeacon.com', + Date.now() - 10 * 60_000, + )); + expect(response.status).toBe(400); + expect(analyzer.frameAt).not.toHaveBeenCalled(); + }); + + it('requires a valid listening lease and supports the existing FFA authority', async () => { + routeState.session = null; + expect((await GET(request('earlybirds-staging.harmonicbeacon.com'))).status).toBe(401); + expect(analyzer.frameAt).not.toHaveBeenCalled(); + + routeState.freeForAll = true; + const response = await GET(request('earlybirds-staging.harmonicbeacon.com')); + expect(response.status).toBe(200); + expect(routeState.authorizeFreeForAll).toHaveBeenCalledWith( + '00000000-0000-4000-8000-000000000003', + 7, + ); + }); + + it('fails the visual endpoint softly without leaking decoder details', async () => { + analyzer.frameAt.mockRejectedValueOnce(new Error('/media/private/segment.m4s')); + const response = await GET(request('earlybirds-staging.harmonicbeacon.com')); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: 'analysis_unavailable' }); + }); + + it('rejects an inactive lease without invoking the decoder', async () => { + routeState.authorize.mockRejectedValueOnce(new Error('expired lease internals')); + const response = await GET(request('earlybirds-staging.harmonicbeacon.com')); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'listening_lease_inactive' }); + expect(analyzer.frameAt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/listener/analysis/frame/route.ts b/src/app/api/listener/analysis/frame/route.ts new file mode 100644 index 00000000..a0c51277 --- /dev/null +++ b/src/app/api/listener/analysis/frame/route.ts @@ -0,0 +1,90 @@ +import { + isCanonicalListenerHost, + isListenerStagingHost, +} from '@/lib/listener/public-discovery'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled, earlyBirdsFreeForAll } from '@/lib/early-birds/enabled'; +import { + authorizeEarlyBirdStreamLease, + authorizeFreeForAllStreamLease, +} from '@/lib/early-birds/stream'; +import { + listenerServerHarmonicAnalyzer, + serializeServerHarmonicFrame, +} from '@/lib/listener/analysis/server-harmonic-analyzer'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const NO_STORE_HEADERS = { + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', +}; +const LEASE_ID = /^[0-9a-f-]{36}$/i; +// The stability-first player deliberately listens two minutes behind the live +// edge. Keep the optional visualization valid throughout the five-minute HLS +// recovery window without accepting arbitrary historical decode requests. +const MAX_AUDIBLE_LATENCY_MS = 5.5 * 60_000; +const MAX_FUTURE_SKEW_MS = 5_000; + +export async function GET(request: Request) { + if (!isCanonicalListenerHost(request.headers) && !isListenerStagingHost(request.headers)) { + return new Response('not found\n', { status: 404, headers: NO_STORE_HEADERS }); + } + if (!earlyBirdsEnabled()) { + return Response.json({ error: 'analysis_unavailable' }, { + status: 503, + headers: NO_STORE_HEADERS, + }); + } + const parameters = new URL(request.url).searchParams; + const programTimeMs = Number(parameters.get('at')); + const leaseId = parameters.get('leaseId') ?? ''; + const leaseGeneration = Number(parameters.get('leaseGeneration')); + const serverNowMs = Date.now(); + if (!Number.isFinite(programTimeMs) + || programTimeMs < serverNowMs - MAX_AUDIBLE_LATENCY_MS + || programTimeMs > serverNowMs + MAX_FUTURE_SKEW_MS + || !LEASE_ID.test(leaseId) + || !Number.isSafeInteger(leaseGeneration) + || leaseGeneration < 1) { + return Response.json({ error: 'invalid_program_time' }, { + status: 400, + headers: NO_STORE_HEADERS, + }); + } + const freeForAll = earlyBirdsFreeForAll(); + try { + if (freeForAll) { + await authorizeFreeForAllStreamLease(leaseId, leaseGeneration); + } else { + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) { + return Response.json({ error: 'sign_in_required' }, { + status: 401, + headers: NO_STORE_HEADERS, + }); + } + await authorizeEarlyBirdStreamLease(session.user.id, leaseId, leaseGeneration); + } + } catch { + return Response.json({ error: 'listening_lease_inactive' }, { + status: 403, + headers: NO_STORE_HEADERS, + }); + } + try { + const frame = await listenerServerHarmonicAnalyzer().frameAt(programTimeMs); + return Response.json(serializeServerHarmonicFrame(frame), { + headers: { + ...NO_STORE_HEADERS, + 'X-Listener-Analysis-Source': 'server', + }, + }); + } catch { + return Response.json({ error: 'analysis_unavailable' }, { + status: 503, + headers: NO_STORE_HEADERS, + }); + } +} diff --git a/src/app/api/listener/auth/recover/__tests__/route.test.ts b/src/app/api/listener/auth/recover/__tests__/route.test.ts new file mode 100644 index 00000000..f22324e5 --- /dev/null +++ b/src/app/api/listener/auth/recover/__tests__/route.test.ts @@ -0,0 +1,105 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = vi.hoisted(() => ({ findUnique: vi.fn(), deleteMany: vi.fn() })); +vi.mock('@/lib/db', () => ({ prisma: { listenerAccountSession: db } })); + +import { GET, POST } from '../route'; + +function request(input: { + origin?: string; + url?: string; + host?: string; + forwardedHost?: string; +} = {}) { + const origin = input.origin ?? 'https://earlybirds-staging.harmonicbeacon.com'; + return new NextRequest(input.url ?? 'https://earlybirds-staging.harmonicbeacon.com/api/listener/auth/recover', { + method: 'POST', + headers: { + host: input.host ?? 'earlybirds-staging.harmonicbeacon.com', origin, + 'x-forwarded-host': input.forwardedHost ?? 'earlybirds-staging.harmonicbeacon.com', + 'sec-fetch-site': 'same-origin', + 'content-type': 'application/json', + cookie: '__Host-hb_listener_account=local-cookie', + }, + body: JSON.stringify({ mode: 'current', locale: 'es' }), + }); +} + +describe('Listener same-origin central logout initiation', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET_STAGING', 's'.repeat(32)); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_STATE_SECRET_STAGING', 'b'.repeat(32)); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENVIRONMENT', 'staging'); + db.deleteMany.mockResolvedValue({ count: 1 }); + }); + afterEach(() => vi.unstubAllEnvs()); + + it('deletes the local RP session before returning a signed Account initiation', async () => { + db.findUnique.mockResolvedValue({ + id: 'local-session', issuer: 'https://account-staging.harmonicbeacon.com', sid: 'central-sid', + }); + const response = await POST(request()); + const result = await response.json() as { url: string }; + const target = new URL(result.url); + expect(response.status).toBe(200); + expect(target.origin).toBe('https://account-staging.harmonicbeacon.com'); + expect(target.pathname).toBe('/account/logout'); + expect(target.searchParams.get('initiation')).toBeTruthy(); + expect(target.searchParams.get('lang')).toBe('es'); + expect(db.deleteMany).toHaveBeenCalledWith({ + where: { id: 'local-session', sid: 'central-sid' }, + }); + expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); + expect(response.headers.get('set-cookie')) + .toContain('__Host-hb_listener_account_auto_handoff=1'); + }); + + it('offers human confirmation when state_mismatch left no local RP session', async () => { + db.findUnique.mockResolvedValue(null); + const response = await POST(request()); + const result = await response.json() as { url: string; confirmation: boolean }; + const target = new URL(result.url); + expect(response.status).toBe(200); + expect(result.confirmation).toBe(true); + expect(response.headers.get('set-cookie')) + .toContain('__Host-hb_listener_account_auto_handoff=1'); + expect(target.searchParams.has('initiation')).toBe(false); + expect(target.searchParams.get('return_to')) + .toBe('https://earlybirds-staging.harmonicbeacon.com/'); + expect(db.deleteMany).not.toHaveBeenCalled(); + }); + + it('accepts the canonical browser origin when Next sees the loopback nginx upstream', async () => { + db.findUnique.mockResolvedValue(null); + const response = await POST(request({ + url: 'http://127.0.0.1:3000/api/listener/auth/recover', + forwardedHost: 'attacker.example', + })); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ confirmation: true }); + }); + + it('treats malformed or duplicate RP cookies as absent without throwing or querying by token', async () => { + for (const cookie of [ + '__Host-hb_listener_account=%', + '__Host-hb_listener_account=one; __Host-hb_listener_account=two', + ]) { + const malformed = request(); + malformed.headers.set('cookie', cookie); + const response = await POST(malformed); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ confirmation: true }); + } + expect(db.findUnique).not.toHaveBeenCalled(); + }); + + it('rejects sibling-origin POSTs and every GET', async () => { + expect((await POST(request({ origin: 'https://listen.harmonicbeacon.com' }))).status).toBe(403); + expect((await POST(request({ host: '127.0.0.1:3000' }))).status).toBe(403); + expect(GET().status).toBe(405); + expect(db.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/listener/auth/recover/route.ts b/src/app/api/listener/auth/recover/route.ts new file mode 100644 index 00000000..51c2be27 --- /dev/null +++ b/src/app/api/listener/auth/recover/route.ts @@ -0,0 +1,68 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { prisma } from '@/lib/db'; +import { signAccountLogoutInitiation } from '@/lib/account/frontchannel-token'; +import { + listenerAccountCookie, + listenerAutomaticHandoffCookie, + listenerAccountRPConfig, + readListenerAccountCookie, +} from '@/lib/listener/account-rp'; +import { + isListenerStagingHost, + trustedListenerRequestOrigin, +} from '@/lib/listener/public-discovery'; +import { digestSessionToken } from '@/lib/session-auth'; + +export async function POST(request: NextRequest): Promise { + const headers = new Headers(request.headers); + const listenerOrigin = trustedListenerRequestOrigin(headers); + if (!listenerOrigin || request.headers.get('origin') !== listenerOrigin || + request.headers.get('sec-fetch-site') !== 'same-origin' || + request.headers.get('content-type') !== 'application/json') { + return new Response(null, { status: 403, headers: { 'Cache-Control': 'private, no-store' } }); + } + const body = await request.json().catch(() => null) as { mode?: unknown; locale?: unknown } | null; + const mode = body?.mode === 'all' ? 'all' as const : 'current' as const; + const locale = body?.locale === 'es' ? 'es' : 'en'; + const raw = readListenerAccountCookie(request.headers); + const config = listenerAccountRPConfig(headers); + const local = raw ? await prisma.listenerAccountSession.findUnique({ + where: { tokenDigest: digestSessionToken(raw) }, + select: { id: true, issuer: true, sid: true }, + }) : null; + const responseHeaders = new Headers({ + 'Cache-Control': 'private, no-store', + 'Set-Cookie': listenerAccountCookie('', 0), + }); + responseHeaders.append('Set-Cookie', listenerAutomaticHandoffCookie('1')); + const returnTo = isListenerStagingHost(headers) + ? 'https://earlybirds-staging.harmonicbeacon.com/' + : 'https://listen.harmonicbeacon.com/'; + if (!local || local.issuer !== config.issuer) { + // Without a local sid Account must ask for an explicit confirmation; + // a cross-site navigation can never auto-revoke the central session. + const confirmation = new URL('/account/logout', config.issuer); + confirmation.searchParams.set('mode', mode); + confirmation.searchParams.set('return_to', returnTo); + confirmation.searchParams.set('lang', locale); + return NextResponse.json({ url: confirmation.toString(), confirmation: true }, { + headers: responseHeaders, + }); + } + await prisma.listenerAccountSession.deleteMany({ where: { id: local.id, sid: local.sid } }); + const initiation = signAccountLogoutInitiation({ + issuer: config.issuer, clientId: config.clientId, clientSecret: config.clientSecret, + sid: local.sid, mode, returnTo, + }); + const target = new URL('/account/logout', config.issuer); + target.searchParams.set('mode', mode); + target.searchParams.set('return_to', returnTo); + target.searchParams.set('lang', locale); + target.searchParams.set('initiation', initiation); + return NextResponse.json({ url: target.toString() }, { headers: responseHeaders }); +} + +export function GET(): Response { + return new Response(null, { status: 405, headers: { Allow: 'POST', 'Cache-Control': 'private, no-store' } }); +} diff --git a/src/app/api/listener/checkout/__tests__/route.test.ts b/src/app/api/listener/checkout/__tests__/route.test.ts new file mode 100644 index 00000000..64252a1e --- /dev/null +++ b/src/app/api/listener/checkout/__tests__/route.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); +const createCheckout = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/checkout', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HttpListenerCheckoutGateway: class { + create = createCheckout; + }, + }; +}); + +import { POST } from '../route'; + +const HOST = 'earlybirds-staging.harmonicbeacon.com'; +const ATTEMPT = '123e4567-e89b-42d3-a456-426614174000'; + +function request( + body: unknown = { provider: 'paypal', attemptId: ATTEMPT }, + origin = `https://${HOST}`, + host = HOST, +) { + const serialized = JSON.stringify(body); + return new NextRequest(`https://${host}/api/listener/checkout`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': String(new TextEncoder().encode(serialized).byteLength), + host, + origin, + 'x-forwarded-proto': 'https', + }, + body: serialized, + }); +} + +beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED', '0'); + vi.stubEnv('BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED', '0'); + currentEarlyBirdSession.mockResolvedValue({ + user: { id: 'opaqueBetterAuthId', email: 'listener@example.com', name: 'Listener' }, + }); + createCheckout.mockResolvedValue({ + provider: 'paypal', + approvalUrl: 'https://www.sandbox.paypal.com/checkoutnow?token=test', + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('Listener sandbox checkout route', () => { + it('derives account and callbacks from the session without sending its email to PayPal', async () => { + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + await expect(response.json()).resolves.toEqual({ + provider: 'paypal', + approvalUrl: 'https://www.sandbox.paypal.com/checkoutnow?token=test', + }); + expect(createCheckout).toHaveBeenCalledWith({ + accountId: 'opaqueBetterAuthId', + payerEmail: undefined, + provider: 'paypal', + attemptId: ATTEMPT, + returnUrl: `https://${HOST}/?checkout=returned`, + cancelUrl: `https://${HOST}/?checkout=cancelled`, + environment: 'staging', + }); + }); + + it('accepts a distinct normalized Mercado Pago payer email without changing Listener identity', async () => { + createCheckout.mockResolvedValue({ + provider: 'mercado_pago', + approvalUrl: 'https://www.mercadopago.com.ar/subscriptions/checkout?preapproval_id=test', + }); + const response = await POST(request({ + provider: 'mercado_pago', + attemptId: ATTEMPT, + payerEmail: 'Ani.Billing@Example.com ', + })); + expect(response.status).toBe(200); + expect(createCheckout).toHaveBeenCalledWith(expect.objectContaining({ + accountId: 'opaqueBetterAuthId', + provider: 'mercado_pago', + payerEmail: 'ani.billing@example.com', + })); + }); + + it('allows an explicitly enabled Live checkout only on the canonical Listener origin', async () => { + vi.stubEnv('BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED', '1'); + createCheckout.mockResolvedValue({ + provider: 'paypal', + approvalUrl: 'https://www.paypal.com/checkoutnow?token=live', + }); + const host = 'listen.harmonicbeacon.com'; + const response = await POST(request(undefined, `https://${host}`, host)); + expect(response.status).toBe(200); + expect(createCheckout).toHaveBeenCalledWith(expect.objectContaining({ + accountId: 'opaqueBetterAuthId', + provider: 'paypal', + environment: 'live', + returnUrl: `https://${host}/?checkout=returned`, + cancelUrl: `https://${host}/?checkout=cancelled`, + })); + }); + + it('keeps canonical Live checkout closed when only sandbox providers are enabled', async () => { + const host = 'listen.harmonicbeacon.com'; + const response = await POST(request(undefined, `https://${host}`, host)); + expect(response.status).toBe(404); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(createCheckout).not.toHaveBeenCalled(); + }); + + it.each([ + ['event host', 'https://live.harmonicbeacon.com', 'live.harmonicbeacon.com'], + ['cross origin', 'https://attacker.invalid', HOST], + ])('rejects %s before auth', async (_label, origin, host) => { + const response = await POST(request(undefined, origin, host)); + expect(response.status).toBe(403); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(createCheckout).not.toHaveBeenCalled(); + }); + + it('rejects unauthenticated requests without contacting the authority', async () => { + currentEarlyBirdSession.mockResolvedValue(null); + const response = await POST(request()); + expect(response.status).toBe(401); + expect(createCheckout).not.toHaveBeenCalled(); + }); + + it.each([ + ['paypal', 'BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED'], + ['mercado_pago', 'BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED'], + ] as const)('fails closed when %s is disabled', async (provider, variable) => { + vi.stubEnv(variable, '0'); + const response = await POST(request(provider === 'mercado_pago' + ? { provider, attemptId: ATTEMPT, payerEmail: 'buyer@example.com' } + : { provider, attemptId: ATTEMPT })); + expect(response.status).toBe(404); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(createCheckout).not.toHaveBeenCalled(); + }); + + it.each([ + [{ provider: 'paypal', attemptId: 'not-a-uuid' }, 400], + [{ provider: 'stripe', attemptId: ATTEMPT }, 400], + [{ provider: 'paypal', attemptId: ATTEMPT, email: 'attacker@example.com' }, 400], + [{ provider: 'paypal', attemptId: ATTEMPT, payerEmail: 'attacker@example.com' }, 400], + [{ provider: 'mercado_pago', attemptId: ATTEMPT }, 400], + [{ provider: 'mercado_pago', attemptId: ATTEMPT, payerEmail: 'not-an-email' }, 400], + ])('rejects malformed or client-supplied identity input', async (body, status) => { + const response = await POST(request(body)); + expect(response.status).toBe(status); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(createCheckout).not.toHaveBeenCalled(); + }); + + it('returns a generic error without exposing authority details', async () => { + createCheckout.mockRejectedValue(new Error('provider payload contained PII')); + const response = await POST(request()); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ error: 'Checkout unavailable.' }); + }); +}); diff --git a/src/app/api/listener/checkout/live-workbench/__tests__/route.test.ts b/src/app/api/listener/checkout/live-workbench/__tests__/route.test.ts new file mode 100644 index 00000000..2def132f --- /dev/null +++ b/src/app/api/listener/checkout/live-workbench/__tests__/route.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); +const createCheckout = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/checkout', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HttpListenerCheckoutGateway: class { + create = createCheckout; + }, + }; +}); + +import { + LISTENER_LIVE_WORKBENCH_CSRF_HEADER, + createListenerLiveWorkbenchCsrfToken, + listenerLiveWorkbenchConfig, +} from '@/lib/early-birds/live-workbench'; +import { POST } from '../route'; + +const HOST = 'earlybirds-staging.harmonicbeacon.com'; +const ORIGIN = `https://${HOST}`; +const ATTEMPT = '123e4567-e89b-42d3-a456-426614174000'; +const ACCOUNT_ID = 'opaque-account_1'; +const SESSION_ID = 'session-1'; + +function csrfToken(): string { + return createListenerLiveWorkbenchCsrfToken({ + config: listenerLiveWorkbenchConfig()!, + accountId: ACCOUNT_ID, + sessionId: SESSION_ID, + })!; +} + +function request(overrides: { + body?: unknown; + host?: string; + origin?: string; + protocol?: string; + fetchSite?: string; + fetchMode?: string; + fetchDest?: string; + csrf?: string | null; +} = {}) { + const body = JSON.stringify(overrides.body ?? { attemptId: ATTEMPT }); + const host = overrides.host ?? HOST; + const headers = new Headers({ + 'content-type': 'application/json', + 'content-length': String(new TextEncoder().encode(body).byteLength), + host, + origin: overrides.origin ?? ORIGIN, + 'x-forwarded-proto': overrides.protocol ?? 'https', + 'sec-fetch-site': overrides.fetchSite ?? 'same-origin', + 'sec-fetch-mode': overrides.fetchMode ?? 'cors', + 'sec-fetch-dest': overrides.fetchDest ?? 'empty', + }); + const csrf = overrides.csrf === undefined ? csrfToken() : overrides.csrf; + if (csrf !== null) headers.set(LISTENER_LIVE_WORKBENCH_CSRF_HEADER, csrf); + return new NextRequest(`https://${host}/api/listener/checkout/live-workbench`, { + method: 'POST', + headers, + body, + }); +} + +beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID', ACCOUNT_ID); + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER', 'paypal'); + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET', 's'.repeat(43)); + vi.stubEnv('BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED', '0'); + vi.stubEnv('BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED', '0'); + currentEarlyBirdSession.mockResolvedValue({ + user: { id: ACCOUNT_ID, email: 'listener@example.com', name: 'Listener' }, + session: { id: SESSION_ID, expiresAt: new Date('2026-09-01T00:00:00Z') }, + }); + createCheckout.mockResolvedValue({ + provider: 'paypal', + approvalUrl: 'https://www.paypal.com/checkoutnow?token=live', + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('private staging-only Listener Live workbench', () => { + it('derives account and the single PayPal provider without coupling the session email', async () => { + const response = await POST(request()); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + provider: 'paypal', + approvalUrl: 'https://www.paypal.com/checkoutnow?token=live', + }); + expect(createCheckout).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + payerEmail: undefined, + provider: 'paypal', + attemptId: ATTEMPT, + returnUrl: `${ORIGIN}/?checkout=returned`, + cancelUrl: `${ORIGIN}/?checkout=cancelled`, + environment: 'live', + }); + }); + + it('accepts an explicit Mercado Pago payer email for the allowlisted Listener account', async () => { + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER', 'mercado_pago'); + createCheckout.mockResolvedValue({ + provider: 'mercado_pago', + approvalUrl: 'https://www.mercadopago.com.ar/subscriptions/checkout?preapproval_id=live', + }); + const response = await POST(request({ body: { + attemptId: ATTEMPT, + payerEmail: 'Ani.Billing@Example.com ', + } })); + expect(response.status).toBe(200); + expect(createCheckout).toHaveBeenCalledWith(expect.objectContaining({ + accountId: ACCOUNT_ID, + provider: 'mercado_pago', + payerEmail: 'ani.billing@example.com', + })); + }); + + it('is absent by default and whenever public Live checkout is enabled', async () => { + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED', '0'); + expect((await POST(request({ csrf: null }))).status).toBe(404); + + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED', '1'); + expect((await POST(request({ csrf: null }))).status).toBe(404); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(createCheckout).not.toHaveBeenCalled(); + }); + + it.each([ + ['public Listener', { host: 'listen.harmonicbeacon.com', origin: 'https://listen.harmonicbeacon.com', csrf: null }, 404], + ['event host', { host: 'live.harmonicbeacon.com', origin: 'https://live.harmonicbeacon.com', csrf: null }, 404], + ['cross origin', { origin: 'https://attacker.invalid', csrf: null }, 403], + ['plain HTTP', { protocol: 'http', csrf: null }, 403], + ['cross-site fetch', { fetchSite: 'cross-site', csrf: null }, 403], + ['navigation fetch', { fetchMode: 'navigate', csrf: null }, 403], + ['wrong fetch destination', { fetchDest: 'document', csrf: null }, 403], + ['missing CSRF proof', { csrf: null }, 403], + ['invalid CSRF proof', { csrf: 'invalid' }, 403], + ] as const)('rejects %s before checkout', async (_label, overrides, expected) => { + const response = await POST(request(overrides)); + expect(response.status).toBe(expected); + expect(createCheckout).not.toHaveBeenCalled(); + }); + + it('hides the route from every account except the one server allowlist entry', async () => { + currentEarlyBirdSession.mockResolvedValue({ + user: { id: 'another-account', email: 'other@example.com', name: 'Other' }, + session: { id: SESSION_ID }, + }); + const response = await POST(request()); + expect(response.status).toBe(404); + expect(createCheckout).not.toHaveBeenCalled(); + }); + + it.each([ + { attemptId: ATTEMPT, provider: 'mercado_pago' }, + { attemptId: ATTEMPT, accountId: ACCOUNT_ID }, + { attemptId: 'not-a-uuid' }, + { attemptId: ATTEMPT, payerEmail: 'unexpected@example.com' }, + ])('rejects client attempts to choose authority fields', async (body) => { + const response = await POST(request({ body })); + expect(response.status).toBe(400); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(createCheckout).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/listener/checkout/live-workbench/route.ts b/src/app/api/listener/checkout/live-workbench/route.ts new file mode 100644 index 00000000..6d859349 --- /dev/null +++ b/src/app/api/listener/checkout/live-workbench/route.ts @@ -0,0 +1,117 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + HttpListenerCheckoutGateway, + ListenerCheckoutUnavailableError, +} from '@/lib/early-birds/checkout'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; +import { normalizeMercadoPagoPayerEmail } from '@/lib/early-birds/payer-email'; +import { + LISTENER_LIVE_WORKBENCH_CSRF_HEADER, + listenerLiveWorkbenchConfig, + verifyListenerLiveWorkbenchCsrfToken, +} from '@/lib/early-birds/live-workbench'; +import { + LISTENER_STAGING_HOST, + isListenerStagingHost, +} from '@/lib/listener/public-discovery'; + +export const dynamic = 'force-dynamic'; + +const STAGING_ORIGIN = `https://${LISTENER_STAGING_HOST}`; +const MAX_REQUEST_BYTES = 512; +const ATTEMPT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function json(body: Record, status: number): NextResponse { + const response = NextResponse.json(body, { status }); + response.headers.set('Cache-Control', 'private, no-store'); + response.headers.set('Referrer-Policy', 'no-referrer'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + return response; +} + +function exactStagingRequest(request: NextRequest): boolean { + return isListenerStagingHost(request.headers) && + request.headers.get('host') === LISTENER_STAGING_HOST && + request.headers.get('x-forwarded-proto') === 'https' && + request.headers.get('origin') === STAGING_ORIGIN && + request.headers.get('sec-fetch-site') === 'same-origin' && + request.headers.get('sec-fetch-mode') === 'cors' && + request.headers.get('sec-fetch-dest') === 'empty' && + request.headers.get('content-type')?.split(';', 1)[0] === 'application/json'; +} + +export async function POST(request: NextRequest): Promise { + // Hide the route completely on the public Listener, event vhosts, direct + // container access and any deployment without the private gate. + if (!earlyBirdsEnabled() || !isListenerStagingHost(request.headers)) { + return json({ error: 'Not found.' }, 404); + } + const config = listenerLiveWorkbenchConfig(); + if (!config) return json({ error: 'Not found.' }, 404); + if (!exactStagingRequest(request)) return json({ error: 'Invalid request.' }, 403); + + const declared = request.headers.get('content-length'); + if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_REQUEST_BYTES)) { + return json({ error: 'Invalid request.' }, 413); + } + const raw = await request.text().catch(() => ''); + if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BYTES) { + return json({ error: 'Invalid request.' }, 413); + } + let input: unknown; + try { + input = JSON.parse(raw) as unknown; + } catch { + return json({ error: 'Invalid request.' }, 400); + } + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return json({ error: 'Invalid request.' }, 400); + } + const body = input as Record; + const expectedKeys = config.provider === 'mercado_pago' + ? ['attemptId', 'payerEmail'] + : ['attemptId']; + if (Object.keys(body).sort().join('\0') !== expectedKeys.join('\0')) { + return json({ error: 'Invalid request.' }, 400); + } + const attemptId = body.attemptId; + const payerEmail = config.provider === 'mercado_pago' + ? normalizeMercadoPagoPayerEmail(body.payerEmail) + : null; + if (typeof attemptId !== 'string' || !ATTEMPT_ID.test(attemptId)) { + return json({ error: 'Invalid request.' }, 400); + } + if (config.provider === 'mercado_pago' && !payerEmail) { + return json({ error: 'Invalid request.' }, 400); + } + + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) return json({ error: 'Sign in required.' }, 401); + if (session.user.id !== config.accountId) return json({ error: 'Not found.' }, 404); + if (!verifyListenerLiveWorkbenchCsrfToken({ + config, + token: request.headers.get(LISTENER_LIVE_WORKBENCH_CSRF_HEADER), + accountId: session.user.id, + sessionId: session.session.id, + })) return json({ error: 'Invalid request.' }, 403); + + try { + const result = await new HttpListenerCheckoutGateway().create({ + accountId: session.user.id, + payerEmail: payerEmail ?? undefined, + provider: config.provider, + attemptId, + returnUrl: `${STAGING_ORIGIN}/?checkout=returned`, + cancelUrl: `${STAGING_ORIGIN}/?checkout=cancelled`, + environment: 'live', + }); + return json({ provider: result.provider, approvalUrl: result.approvalUrl }, 200); + } catch (error) { + if (error instanceof ListenerCheckoutUnavailableError) { + return json({ error: 'Checkout unavailable.' }, 503); + } + return json({ error: 'Checkout unavailable.' }, 503); + } +} diff --git a/src/app/api/listener/checkout/route.ts b/src/app/api/listener/checkout/route.ts new file mode 100644 index 00000000..304caa1f --- /dev/null +++ b/src/app/api/listener/checkout/route.ts @@ -0,0 +1,120 @@ +import { createHash } from 'node:crypto'; +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + HttpListenerCheckoutGateway, + listenerCheckoutAvailability, + ListenerCheckoutUnavailableError, + type ListenerCheckoutProvider, + type ListenerCheckoutEnvironment, +} from '@/lib/early-birds/checkout'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; +import { normalizeMercadoPagoPayerEmail } from '@/lib/early-birds/payer-email'; +import { isCanonicalListenerHost, isListenerStagingHost } from '@/lib/listener/public-discovery'; +import { emitAnalyticsEvent } from '@/lib/analytics-server'; + +export const dynamic = 'force-dynamic'; + +const MAX_REQUEST_BYTES = 512; + +function json(body: Record, status: number): NextResponse { + const response = NextResponse.json(body, { status }); + response.headers.set('Cache-Control', 'private, no-store'); + response.headers.set('Referrer-Policy', 'no-referrer'); + return response; +} + +function requestContext(request: NextRequest): { + origin: string; + environment: ListenerCheckoutEnvironment; +} | null { + const host = request.headers.get('host')?.trim().toLowerCase(); + const protocol = request.headers.get('x-forwarded-proto')?.trim().toLowerCase(); + const environment = isCanonicalListenerHost(request.headers) + ? 'live' + : isListenerStagingHost(request.headers) ? 'staging' : null; + if (!host || protocol !== 'https' || !environment) return null; + const expected = `https://${host}`; + return request.headers.get('origin') === expected ? { origin: expected, environment } : null; +} + +function providerFrom(value: unknown): ListenerCheckoutProvider | null { + return value === 'paypal' || value === 'mercado_pago' ? value : null; +} + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return json({ error: 'Checkout unavailable.' }, 404); + const context = requestContext(request); + if (!context) return json({ error: 'Invalid request.' }, 403); + + const declared = request.headers.get('content-length'); + if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_REQUEST_BYTES)) { + return json({ error: 'Invalid request.' }, 413); + } + const raw = await request.text().catch(() => ''); + if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BYTES) { + return json({ error: 'Invalid request.' }, 413); + } + let input: unknown; + try { + input = JSON.parse(raw) as unknown; + } catch { + return json({ error: 'Invalid request.' }, 400); + } + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return json({ error: 'Invalid request.' }, 400); + } + const body = input as Record; + const provider = providerFrom(body.provider); + const expectedKeys = provider === 'mercado_pago' + ? ['attemptId', 'payerEmail', 'provider'] + : ['attemptId', 'provider']; + if (Object.keys(body).sort().join('\0') !== expectedKeys.join('\0')) { + return json({ error: 'Invalid request.' }, 400); + } + const attemptId = typeof body.attemptId === 'string' ? body.attemptId : ''; + const payerEmail = provider === 'mercado_pago' + ? normalizeMercadoPagoPayerEmail(body.payerEmail) + : null; + if (!provider || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(attemptId)) { + return json({ error: 'Invalid request.' }, 400); + } + if (provider === 'mercado_pago' && !payerEmail) { + return json({ error: 'Invalid request.' }, 400); + } + + const available = listenerCheckoutAvailability(process.env, context.environment); + if ((provider === 'paypal' && !available.paypal) || + (provider === 'mercado_pago' && !available.mercadoPago)) { + return json({ error: 'Checkout unavailable.' }, 404); + } + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) return json({ error: 'Sign in required.' }, 401); + + try { + const result = await new HttpListenerCheckoutGateway().create({ + accountId: session.user.id, + payerEmail: payerEmail ?? undefined, + provider, + attemptId, + returnUrl: `${context.origin}/?checkout=returned`, + cancelUrl: `${context.origin}/?checkout=cancelled`, + environment: context.environment, + }); + await emitAnalyticsEvent({ + eventName: 'membership.checkout_opened', source: 'membership', surface: 'commerce', accountId: session.user.id, + environment: context.environment === 'staging' ? 'staging' : 'production', + trafficClass: context.environment === 'staging' ? 'test' : 'unknown', + properties: { + provider, source_key_digest: createHash('sha256').update(attemptId).digest('hex'), + }, + }); + return json({ provider: result.provider, approvalUrl: result.approvalUrl }, 200); + } catch (error) { + if (error instanceof ListenerCheckoutUnavailableError) { + return json({ error: 'Checkout unavailable.' }, 503); + } + return json({ error: 'Checkout unavailable.' }, 503); + } +} diff --git a/src/app/api/listener/free/redeem/route.ts b/src/app/api/listener/free/redeem/route.ts new file mode 100644 index 00000000..7b81663c --- /dev/null +++ b/src/app/api/listener/free/redeem/route.ts @@ -0,0 +1,3 @@ +export const dynamic = 'force-dynamic'; + +export { POST } from '../../../early-birds/free/redeem/route'; diff --git a/src/app/api/listener/membership/action/__tests__/route.test.ts b/src/app/api/listener/membership/action/__tests__/route.test.ts new file mode 100644 index 00000000..d1854f91 --- /dev/null +++ b/src/app/api/listener/membership/action/__tests__/route.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); +const requestAction = vi.hoisted(() => vi.fn()); +const getEarlyBirdListeningAccess = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/access', () => ({ getEarlyBirdListeningAccess })); +vi.mock('@/lib/early-birds/membership-actions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + HttpListenerMembershipActionsGateway: class { + requestAction = requestAction; + }, + }; +}); + +import { POST } from '../route'; + +const HOST = 'listen.harmonicbeacon.com'; +const ATTEMPT = '123e4567-e89b-42d3-a456-426614174000'; + +function request( + body: unknown = { action: 'cancel', attemptId: ATTEMPT }, + host = HOST, + origin = `https://${HOST}`, +) { + const serialized = JSON.stringify(body); + return new NextRequest(`https://${host}/api/listener/membership/action`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': String(new TextEncoder().encode(serialized).byteLength), + host, + origin, + 'x-forwarded-proto': 'https', + }, + body: serialized, + }); +} + +beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + currentEarlyBirdSession.mockResolvedValue({ + user: { id: 'opaqueBetterAuthId', email: 'listener@example.com', name: 'Listener' }, + }); + requestAction.mockResolvedValue(undefined); + getEarlyBirdListeningAccess.mockResolvedValue({ + membership: { projection: { source: 'PAYPAL' } }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('Listener membership action route', () => { + it.each(['cancel', 'reactivate'] as const)( + 'derives the account for %s and returns no provider identifiers', + async (action) => { + const response = await POST(request({ action, attemptId: ATTEMPT })); + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ status: 'queued' }); + expect(requestAction).toHaveBeenCalledWith({ + accountId: 'opaqueBetterAuthId', + attemptId: ATTEMPT, + action, + environment: 'live', + provider: null, + }); + }, + ); + + it('uses only the canonical staging projection to select a sandbox provider', async () => { + const host = 'earlybirds-staging.harmonicbeacon.com'; + const response = await POST(request( + { action: 'reactivate', attemptId: ATTEMPT }, + host, + `https://${host}`, + )); + expect(response.status).toBe(202); + expect(requestAction).toHaveBeenCalledWith({ + accountId: 'opaqueBetterAuthId', + attemptId: ATTEMPT, + action: 'reactivate', + environment: 'staging', + provider: 'paypal', + }); + }); + + it.each([ + ['event host', 'live.harmonicbeacon.com', 'https://live.harmonicbeacon.com'], + ['cross origin', HOST, 'https://attacker.invalid'], + ])('rejects %s before session lookup', async (_label, host, origin) => { + const response = await POST(request(undefined, host, origin)); + expect(response.status).toBe(403); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(requestAction).not.toHaveBeenCalled(); + }); + + it('rejects unauthenticated, unknown actions and client-supplied fields', async () => { + currentEarlyBirdSession.mockResolvedValue(null); + expect((await POST(request())).status).toBe(401); + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'opaqueBetterAuthId' } }); + expect((await POST(request({ action: 'resume', attemptId: ATTEMPT }))).status).toBe(400); + expect((await POST(request({ action: 'cancel', attemptId: ATTEMPT, provider: 'paypal' }))).status).toBe(400); + expect(requestAction).not.toHaveBeenCalled(); + }); + + it('returns a generic failure without changing browser authority', async () => { + requestAction.mockRejectedValue(new Error('provider leaked a subscription id')); + const response = await POST(request()); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ error: 'Membership unavailable.' }); + }); +}); diff --git a/src/app/api/listener/membership/action/route.ts b/src/app/api/listener/membership/action/route.ts new file mode 100644 index 00000000..f5a26e0b --- /dev/null +++ b/src/app/api/listener/membership/action/route.ts @@ -0,0 +1,94 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; +import { getEarlyBirdListeningAccess } from '@/lib/early-birds/access'; +import { + HttpListenerMembershipActionsGateway, + ListenerMembershipActionUnavailableError, + type ListenerMembershipAction, +} from '@/lib/early-birds/membership-actions'; +import { isCanonicalListenerHost, isListenerStagingHost } from '@/lib/listener/public-discovery'; + +export const dynamic = 'force-dynamic'; + +const MAX_REQUEST_BYTES = 256; + +function json(body: Record, status: number): NextResponse { + const response = NextResponse.json(body, { status }); + response.headers.set('Cache-Control', 'private, no-store'); + response.headers.set('Referrer-Policy', 'no-referrer'); + return response; +} + +function requestEnvironment(request: NextRequest): 'live' | 'staging' | null { + if (request.headers.get('x-forwarded-proto')?.trim().toLowerCase() !== 'https') return null; + if (isCanonicalListenerHost(request.headers) && + request.headers.get('origin') === 'https://listen.harmonicbeacon.com') return 'live'; + if (isListenerStagingHost(request.headers) && + request.headers.get('origin') === 'https://earlybirds-staging.harmonicbeacon.com') return 'staging'; + return null; +} + +function membershipAction(input: unknown): ListenerMembershipAction | null { + return input === 'cancel' || input === 'reactivate' ? input : null; +} + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return json({ error: 'Membership unavailable.' }, 404); + const environment = requestEnvironment(request); + if (!environment) return json({ error: 'Invalid request.' }, 403); + const declared = request.headers.get('content-length'); + if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_REQUEST_BYTES)) { + return json({ error: 'Invalid request.' }, 413); + } + const raw = await request.text().catch(() => ''); + if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BYTES) { + return json({ error: 'Invalid request.' }, 413); + } + let input: unknown; + try { + input = JSON.parse(raw) as unknown; + } catch { + return json({ error: 'Invalid request.' }, 400); + } + if (!input || typeof input !== 'object' || Array.isArray(input) || + Object.keys(input).sort().join('\0') !== ['action', 'attemptId'].join('\0')) { + return json({ error: 'Invalid request.' }, 400); + } + const attemptId = (input as Record).attemptId; + const action = membershipAction((input as Record).action); + if (typeof attemptId !== 'string' || !action || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(attemptId)) { + return json({ error: 'Invalid request.' }, 400); + } + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) return json({ error: 'Sign in required.' }, 401); + + let provider: 'paypal' | 'mercado_pago' | null = null; + if (environment === 'staging') { + const access = await getEarlyBirdListeningAccess(session.user.id).catch(() => null); + provider = access?.membership.projection?.source === 'PAYPAL' + ? 'paypal' + : access?.membership.projection?.source === 'MERCADO_PAGO' + ? 'mercado_pago' + : null; + if (!provider) return json({ error: 'Membership unavailable.' }, 422); + } + + try { + await new HttpListenerMembershipActionsGateway().requestAction({ + accountId: session.user.id, + attemptId, + action, + environment, + provider, + }); + return json({ status: 'queued' }, 202); + } catch (error) { + if (error instanceof ListenerMembershipActionUnavailableError) { + return json({ error: 'Membership unavailable.' }, 503); + } + return json({ error: 'Membership unavailable.' }, 503); + } +} diff --git a/src/app/api/listener/presence/__tests__/route.test.ts b/src/app/api/listener/presence/__tests__/route.test.ts new file mode 100644 index 00000000..3682fd1a --- /dev/null +++ b/src/app/api/listener/presence/__tests__/route.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const currentRegionalPresence = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/listener/presence', () => ({ currentRegionalPresence })); + +import { resetListenerPresenceRouteCacheForTests } from '@/lib/listener/presence-route-cache'; +import { GET } from '../route'; + +const snapshot = { + schema: 'listener-presence.v1' as const, + observedAt: '2026-08-07T20:00:00.000Z', + attribution: { + label: 'IP Geolocation by DB-IP' as const, + href: 'https://db-ip.com' as const, + license: 'CC BY 4.0' as const, + }, + regions: [{ region: 'EUROPE' as const, level: 'cluster' as const }], +}; + +beforeEach(() => { + resetListenerPresenceRouteCacheForTests(); + currentRegionalPresence.mockResolvedValue(snapshot); +}); + +afterEach(() => vi.clearAllMocks()); + +describe('public Listener presence route', () => { + it('returns only the coarse cacheable public snapshot', async () => { + const response = await GET(); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('max-age=5'); + await expect(response.json()).resolves.toEqual(snapshot); + }); + + it('serves the last known public bands when storage is temporarily unavailable', async () => { + await GET(); + currentRegionalPresence.mockRejectedValue(new Error('db down')); + const response = await GET(); + expect(response.status).toBe(200); + expect(response.headers.get('warning')).toContain('stale'); + await expect(response.json()).resolves.toEqual(snapshot); + }); + + it('fails explicitly instead of inventing an empty crowd without evidence', async () => { + resetListenerPresenceRouteCacheForTests(); + currentRegionalPresence.mockRejectedValue(new Error('db down')); + const response = await GET(); + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); +}); diff --git a/src/app/api/listener/presence/route.ts b/src/app/api/listener/presence/route.ts new file mode 100644 index 00000000..2ff45987 --- /dev/null +++ b/src/app/api/listener/presence/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; + +import { + currentRegionalPresence, +} from '@/lib/listener/presence'; +import { + cachedListenerPresence, + rememberListenerPresence, +} from '@/lib/listener/presence-route-cache'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + try { + const snapshot = await currentRegionalPresence(); + rememberListenerPresence(snapshot); + return NextResponse.json(snapshot, { + headers: { + 'cache-control': 'public, max-age=5, stale-while-revalidate=20', + }, + }); + } catch { + const lastGood = cachedListenerPresence(); + if (lastGood) { + return NextResponse.json(lastGood, { + headers: { + 'cache-control': 'public, max-age=0, stale-while-revalidate=20', + warning: '110 - "Response is stale"', + }, + }); + } + return NextResponse.json( + { error: 'Presence temporarily unavailable.' }, + { status: 503, headers: { 'cache-control': 'no-store' } }, + ); + } +} diff --git a/src/app/api/listener/public-discovery/__tests__/route.test.ts b/src/app/api/listener/public-discovery/__tests__/route.test.ts new file mode 100644 index 00000000..a124d185 --- /dev/null +++ b/src/app/api/listener/public-discovery/__tests__/route.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { GET as getRobots } from '../robots.txt/route'; +import { GET as getSitemap } from '../sitemap.xml/route'; + +function request(path: string, host: string): Request { + return new Request(`https://${host}${path}`, { headers: { host } }); +} + +describe('Listener discovery routes', () => { + it('serves robots and sitemap on the canonical public host', async () => { + const robots = getRobots(request('/robots.txt', 'listen.harmonicbeacon.com')); + const sitemap = getSitemap(request('/sitemap.xml', 'listen.harmonicbeacon.com')); + + expect(robots.status).toBe(200); + expect(robots.headers.get('content-type')).toBe('text/plain; charset=utf-8'); + expect(await robots.text()).toContain('https://listen.harmonicbeacon.com/sitemap.xml'); + expect(sitemap.status).toBe(200); + expect(sitemap.headers.get('content-type')).toBe('application/xml; charset=utf-8'); + expect(await sitemap.text()).toContain('https://listen.harmonicbeacon.com/'); + }); + + it.each(['live.harmonicbeacon.com', 'earlybirds-staging.harmonicbeacon.com']) ( + 'fails closed on %s', + async (host) => { + const robots = getRobots(request('/robots.txt', host)); + const sitemap = getSitemap(request('/sitemap.xml', host)); + + expect(robots.status).toBe(404); + expect(sitemap.status).toBe(404); + expect(await robots.text()).toBe(''); + expect(await sitemap.text()).toBe(''); + }, + ); +}); diff --git a/src/app/api/listener/public-discovery/robots.txt/route.ts b/src/app/api/listener/public-discovery/robots.txt/route.ts new file mode 100644 index 00000000..5bcf21d0 --- /dev/null +++ b/src/app/api/listener/public-discovery/robots.txt/route.ts @@ -0,0 +1,19 @@ +import { + isCanonicalListenerHost, + listenerRobotsText, +} from '@/lib/listener/public-discovery'; + +const PUBLIC_CACHE = 'public, max-age=300, stale-while-revalidate=3600'; + +export function GET(request: Request): Response { + if (!isCanonicalListenerHost(request.headers)) { + return new Response(null, { status: 404 }); + } + + return new Response(listenerRobotsText(), { + headers: { + 'Cache-Control': PUBLIC_CACHE, + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); +} diff --git a/src/app/api/listener/public-discovery/sitemap.xml/route.ts b/src/app/api/listener/public-discovery/sitemap.xml/route.ts new file mode 100644 index 00000000..27f75ad7 --- /dev/null +++ b/src/app/api/listener/public-discovery/sitemap.xml/route.ts @@ -0,0 +1,19 @@ +import { + isCanonicalListenerHost, + listenerSitemapXml, +} from '@/lib/listener/public-discovery'; + +const PUBLIC_CACHE = 'public, max-age=300, stale-while-revalidate=3600'; + +export function GET(request: Request): Response { + if (!isCanonicalListenerHost(request.headers)) { + return new Response(null, { status: 404 }); + } + + return new Response(listenerSitemapXml(), { + headers: { + 'Cache-Control': PUBLIC_CACHE, + 'Content-Type': 'application/xml; charset=utf-8', + }, + }); +} diff --git a/src/app/api/listener/withdrawal/__tests__/route.test.ts b/src/app/api/listener/withdrawal/__tests__/route.test.ts new file mode 100644 index 00000000..9b68d3aa --- /dev/null +++ b/src/app/api/listener/withdrawal/__tests__/route.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const submitListenerWithdrawal = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/listener/consumer-withdrawal', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, submitListenerWithdrawal }; +}); + +import { + ListenerWithdrawalRateLimitError, +} from '@/lib/listener/consumer-withdrawal'; +import { POST } from '../route'; + +const HOST = 'listen.harmonicbeacon.com'; +const BODY = { + email: 'listener@example.com', + idempotencyKey: '123e4567-e89b-42d3-a456-426614174000', + locale: 'en', + provider: 'PAYPAL', + purchaseDate: '', + requestKind: 'WITHDRAWAL', +}; + +function request(overrides: { + body?: unknown; + host?: string; + origin?: string; + intent?: string | null; + contentType?: string; + length?: string; +} = {}) { + const raw = JSON.stringify(overrides.body ?? BODY); + const host = overrides.host ?? HOST; + const headers: Record = { + host, + origin: overrides.origin ?? `https://${host}`, + 'x-forwarded-proto': 'https', + 'content-type': overrides.contentType ?? 'application/json', + 'content-length': overrides.length ?? String(new TextEncoder().encode(raw).byteLength), + 'x-real-ip': '192.0.2.10', + }; + if (overrides.intent !== null) headers['x-listener-withdrawal-intent'] = overrides.intent ?? '1'; + return new NextRequest(`https://${host}/api/listener/withdrawal`, { method: 'POST', headers, body: raw }); +} + +describe('public Listener withdrawal API', () => { + beforeEach(() => { + process.env.LISTENER_WITHDRAWAL_SECRET = 'w'.repeat(32); + process.env.LISTENER_WITHDRAWAL_ENABLED = '1'; + submitListenerWithdrawal.mockReset(); + submitListenerWithdrawal.mockResolvedValue({ + receiptCode: 'HBW-1234567890ABCDEF1234567890ABCD', + receivedAt: new Date('2026-08-13T19:00:00.000Z'), + replayed: false, + }); + }); + + it('accepts without a session and returns only an opaque receipt', async () => { + const response = await POST(request()); + expect(response.status).toBe(201); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ + receiptCode: 'HBW-1234567890ABCDEF1234567890ABCD', + receivedAt: '2026-08-13T19:00:00.000Z', + }); + expect(submitListenerWithdrawal).toHaveBeenCalledWith(expect.objectContaining({ + networkIdentity: '192.0.2.10', + secret: 'w'.repeat(32), + })); + }); + + it.each([ + { host: 'live.harmonicbeacon.com' }, + { origin: 'https://attacker.example' }, + { intent: null }, + { contentType: 'text/plain' }, + ])('rejects an untrusted CSRF/host boundary %#', async (overrides) => { + expect((await POST(request(overrides))).status).toBe(403); + expect(submitListenerWithdrawal).not.toHaveBeenCalled(); + }); + + it('bounds payload size before parsing', async () => { + expect((await POST(request({ length: '2049' }))).status).toBe(413); + expect(submitListenerWithdrawal).not.toHaveBeenCalled(); + }); + + it('is indistinguishable from an absent route while disabled or missing its secret', async () => { + process.env.LISTENER_WITHDRAWAL_ENABLED = '0'; + expect((await POST(request())).status).toBe(404); + process.env.LISTENER_WITHDRAWAL_ENABLED = '1'; + delete process.env.LISTENER_WITHDRAWAL_SECRET; + expect((await POST(request())).status).toBe(404); + expect(submitListenerWithdrawal).not.toHaveBeenCalled(); + }); + + it('returns one generic rate response without exposing an account or provider fact', async () => { + submitListenerWithdrawal.mockRejectedValue(new ListenerWithdrawalRateLimitError()); + const response = await POST(request()); + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('3600'); + expect(JSON.stringify(await response.json())).not.toMatch(/paypal|account|email/i); + }); +}); diff --git a/src/app/api/listener/withdrawal/route.ts b/src/app/api/listener/withdrawal/route.ts new file mode 100644 index 00000000..6512162e --- /dev/null +++ b/src/app/api/listener/withdrawal/route.ts @@ -0,0 +1,76 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { + ListenerWithdrawalConflictError, + ListenerWithdrawalInputError, + ListenerWithdrawalRateLimitError, + listenerWithdrawalNetworkIdentity, + listenerWithdrawalPublicConfiguration, + parseListenerWithdrawalInput, + submitListenerWithdrawal, +} from '@/lib/listener/consumer-withdrawal'; +import { LISTENER_WITHDRAWAL_MAX_REQUEST_BYTES } from '@/lib/listener/consumer-withdrawal-contract'; +import { + isCanonicalListenerHost, + isListenerStagingHost, +} from '@/lib/listener/public-discovery'; + +export const dynamic = 'force-dynamic'; + +function json(body: Record, status: number, retryAfter?: number): NextResponse { + const response = NextResponse.json(body, { status }); + response.headers.set('Cache-Control', 'no-store'); + response.headers.set('Referrer-Policy', 'no-referrer'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + if (retryAfter) response.headers.set('Retry-After', String(retryAfter)); + return response; +} + +function trustedRequest(request: NextRequest): boolean { + if (!isCanonicalListenerHost(request.headers) && !isListenerStagingHost(request.headers)) return false; + const host = request.headers.get('host')?.trim().toLowerCase(); + const protocol = request.headers.get('x-forwarded-proto')?.trim().toLowerCase(); + if (!host || protocol !== 'https' || request.headers.get('origin') !== `https://${host}`) return false; + return request.headers.get('x-listener-withdrawal-intent') === '1' + && request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() === 'application/json'; +} + +export async function POST(request: NextRequest): Promise { + const configuration = listenerWithdrawalPublicConfiguration(); + if (!configuration) return json({ error: 'Not found.' }, 404); + if (!trustedRequest(request)) return json({ error: 'Invalid request.' }, 403); + + const declared = request.headers.get('content-length'); + if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > LISTENER_WITHDRAWAL_MAX_REQUEST_BYTES)) { + return json({ error: 'Invalid request.' }, 413); + } + const raw = await request.text().catch(() => ''); + if (new TextEncoder().encode(raw).byteLength > LISTENER_WITHDRAWAL_MAX_REQUEST_BYTES) { + return json({ error: 'Invalid request.' }, 413); + } + + try { + const body = JSON.parse(raw) as unknown; + const parsed = parseListenerWithdrawalInput(body); + const result = await submitListenerWithdrawal({ + request: parsed, + networkIdentity: listenerWithdrawalNetworkIdentity(request), + secret: configuration.secret!, + }); + return json({ + receiptCode: result.receiptCode, + receivedAt: result.receivedAt.toISOString(), + }, 201); + } catch (error) { + if (error instanceof ListenerWithdrawalInputError) { + return json({ error: 'Invalid request.' }, 400); + } + if (error instanceof ListenerWithdrawalConflictError) { + return json({ error: 'Invalid request.' }, 409); + } + if (error instanceof ListenerWithdrawalRateLimitError) { + return json({ error: 'Please try again later.' }, 429, 3_600); + } + return json({ error: 'Request service unavailable.' }, 503); + } +} diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx new file mode 100644 index 00000000..bcc3d454 --- /dev/null +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -0,0 +1,412 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + earlyBirdOAuthAvailability: vi.fn(), + earlyBirdMagicLinkAvailable: vi.fn(), + getEarlyBirdListeningAccess: vi.fn(), + headers: vi.fn(), + redirect: vi.fn(), +})); + +vi.mock('next/headers', () => ({ + headers: mocks.headers, +})); +vi.mock('next/navigation', () => ({ + redirect: mocks.redirect, +})); +vi.mock('@/lib/early-birds/auth', () => ({ + currentEarlyBirdSession: mocks.currentEarlyBirdSession, + earlyBirdOAuthAvailability: mocks.earlyBirdOAuthAvailability, +})); +vi.mock('@/lib/early-birds/magic-link', () => ({ + earlyBirdMagicLinkAvailable: mocks.earlyBirdMagicLinkAvailable, +})); +vi.mock('@/lib/early-birds/access', () => ({ + getEarlyBirdListeningAccess: mocks.getEarlyBirdListeningAccess, +})); + +import EarlyBirdHome from '@/components/early-birds/EarlyBirdHome'; +import { + EARLY_BIRD_INVITATION_COOKIE, + LISTENER_INVITATION_COOKIE, +} from '@/lib/early-birds/invitation-cookie'; +import EarlyBirdsPage from '../page'; + +const INVITATION = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)}`; + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +const availableQuota = { + policy: 'personal-7-day-v1' as const, + status: 'not-started' as const, + cycleStartedAt: null, + cycleEndsAt: null, + baseAllowanceMs: 10_800_000, + bonusAllowanceMs: 0, + consumedMs: 0, + remainingMs: 10_800_000, + activelyConsuming: false, + exhaustsAt: null, + nextCycleAt: null, +}; + +describe('EarlyBird Listener page', () => { + function enableProductionAccount() { + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_ENVIRONMENT', 'production'); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_CLIENT_SECRET', 'p'.repeat(32)); + vi.stubEnv('BEACON_LISTENER_ACCOUNT_STATE_SECRET', 's'.repeat(32)); + } + + it('starts one bounded Account handoff instead of rendering a redundant signed-out CTA', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + enableProductionAccount(); + mocks.headers.mockResolvedValue(new Headers({ host: 'listen.harmonicbeacon.com' })); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + const redirected = new Error('redirected'); + mocks.redirect.mockImplementation(() => { throw redirected; }); + + await expect(EarlyBirdsPage({ searchParams: Promise.resolve({}) })) + .rejects.toBe(redirected); + expect(mocks.redirect).toHaveBeenCalledWith('/api/account/login?auto=1'); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); + }); + + it.each([ + ['logout suppression', { cookie: '__Host-hb_listener_account_auto_handoff=1' }, {}], + ['callback failure', {}, { authError: '1' }], + ])('keeps a visible retry path after %s instead of redirecting in a loop', async (_label, headerValues, params) => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + enableProductionAccount(); + mocks.headers.mockResolvedValue(new Headers({ + host: 'listen.harmonicbeacon.com', + ...headerValues, + })); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve(params) }); + expect(result.type).toBeDefined(); + expect(mocks.redirect).not.toHaveBeenCalled(); + }); + + it('turns an unavailable automatic handoff into a truthful retryable landing', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + enableProductionAccount(); + mocks.headers.mockResolvedValue(new Headers({ host: 'listen.harmonicbeacon.com' })); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + + const result = await EarlyBirdsPage({ + searchParams: Promise.resolve({ accountUnavailable: '1' }), + }); + expect(result.props).toMatchObject({ + signedIn: false, + serviceUnavailable: 'identity', + }); + expect(mocks.redirect).not.toHaveBeenCalled(); + }); + + it('cleans provider return parameters without treating them as payment authority', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + mocks.headers.mockResolvedValue(new Headers({ + host: 'earlybirds-staging.harmonicbeacon.com', + })); + const redirected = new Error('redirected'); + mocks.redirect.mockImplementation(() => { throw redirected; }); + + await expect(EarlyBirdsPage({ + searchParams: Promise.resolve({ + paypal: 'success', + subscription_id: 'opaque-provider-value', + ba_token: 'opaque-provider-value', + token: 'opaque-provider-value', + }), + })).rejects.toBe(redirected); + + expect(mocks.redirect).toHaveBeenCalledWith('/'); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); + }); + + it('renders the Listener directly without auth or membership in Free for All mode', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + vi.stubEnv('EARLY_BIRDS_DROPIN_EN_PATH', '/media/drop-ins/amara.m4a'); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.type).toBe(EarlyBirdHome); + expect(result.props).toMatchObject({ + publicAccess: true, + dropIns: { es: null, en: '/api/early-birds/drop-ins/en' }, + }); + expect(result.props).not.toHaveProperty('membership'); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); + }); + + it('keeps remote visualization unavailable on public and unflagged staging hosts', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + mocks.headers.mockResolvedValue(new Headers({ + host: 'earlybirds-staging.harmonicbeacon.com', + })); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.type).toBe(EarlyBirdHome); + expect(result.props).toMatchObject({ + reactiveVisualizationAvailable: false, + reactiveFieldLabAvailable: false, + }); + + mocks.headers.mockResolvedValue(new Headers({ host: 'listen.harmonicbeacon.com' })); + const publicResult = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + expect(publicResult.props).toMatchObject({ + reactiveVisualizationAvailable: false, + reactiveFieldLabAvailable: false, + }); + }); + + it('enables the lab only by explicit flag on the exact staging hostname', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + vi.stubEnv('BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED', '1'); + mocks.headers.mockResolvedValue(new Headers({ + host: 'earlybirds-staging.harmonicbeacon.com', + })); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + expect(result.props).toMatchObject({ + reactiveVisualizationAvailable: true, + reactiveFieldLabAvailable: true, + }); + }); + + it('renders an authenticated Listener immediately with the server-authoritative Free quota', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ + allowed: true, + kind: 'free-quota', + membership: { allowed: false, projection: null }, + quota: availableQuota, + allowedUntil: null, + serverNow: new Date('2026-08-08T15:00:00.000Z'), + }); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.type).toBe(EarlyBirdHome); + expect(result.props).toMatchObject({ + accessKind: 'free-quota', + quota: expect.objectContaining({ remainingMs: 10_800_000 }), + serverNow: '2026-08-08T15:00:00.000Z', + }); + expect(result.props).not.toHaveProperty('membership'); + expect(result.props).not.toHaveProperty('checkoutAvailability'); + }); + + it('keeps checkout and private workbench material out of a denied landing', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED', '1'); + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID', 'listener-1'); + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER', 'mercado_pago'); + vi.stubEnv('BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET', 's'.repeat(43)); + vi.stubEnv('BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED', '0'); + vi.stubEnv('BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED', '0'); + mocks.headers.mockResolvedValue(new Headers({ + host: 'earlybirds-staging.harmonicbeacon.com', + })); + mocks.currentEarlyBirdSession.mockResolvedValue({ + user: { id: 'listener-1', name: 'Nico', email: 'nico@example.com' }, + session: { id: 'session-1', expiresAt: new Date('2026-09-01T00:00:00Z') }, + }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ + allowed: false, + kind: 'denied', + membership: { allowed: false, projection: null }, + quota: { ...availableQuota, status: 'exhausted', remainingMs: 0 }, + allowedUntil: null, + serverNow: new Date('2026-08-13T15:00:00.000Z'), + }); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + expect(result.props).not.toHaveProperty('liveWorkbench'); + expect(result.props).not.toHaveProperty('checkoutEnvironment'); + expect(result.props).not.toHaveProperty('checkoutAvailability'); + }); + + it('keeps canonical Founder and provider details out of the player props', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ + allowed: true, + kind: 'membership', + membership: { + allowed: true, + projection: { + state: 'CANCELLED_PENDING_END', + source: 'MERCADO_PAGO', + offerCode: 'EARLY_BIRDS_FOUNDERS_V1', + offerRevision: 1, + effectiveAt: new Date('2026-08-01T00:00:00.000Z'), + paidThrough: new Date('2026-08-31T00:00:00.000Z'), + graceUntil: null, + synthetic: false, + founderContinuityEpisodeId: '00000000-0000-4000-8000-000000000101', + founderContinuityState: 'CANCELLED_PENDING_END', + founderContinuityOfferCode: 'EARLY_BIRDS_FOUNDERS_V1', + founderContinuityOfferRevision: 1, + founderContinuityCurrency: 'USD', + founderContinuityAmountMinor: 500, + founderContinuityBillingPeriod: 'MONTHLY', + founderContinuityActivatedAt: new Date('2026-08-01T00:00:00.000Z'), + founderContinuityServiceThrough: new Date('2026-08-31T00:00:00.000Z'), + provider: 'internal-provider-value', + reasonCode: 'PRIVATE_REASON', + }, + }, + quota: null, + allowedUntil: new Date('2026-08-31T00:00:00.000Z'), + serverNow: new Date('2026-08-08T15:00:00.000Z'), + }); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.type).toBe(EarlyBirdHome); + expect(result.props).not.toHaveProperty('membership'); + expect(result.props).not.toHaveProperty('checkoutAvailability'); + expect(result.props).not.toHaveProperty('liveWorkbench'); + expect(JSON.stringify(result.props)).not.toMatch(/PRIVATE_REASON|internal-provider-value|MERCADO_PAGO/); + }); + + it('shows exhausted quota rather than fabricating membership', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); + mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: true, apple: false }); + mocks.earlyBirdMagicLinkAvailable.mockReturnValue(true); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ + allowed: false, + kind: 'denied', + membership: { allowed: false, projection: null }, + quota: { + ...availableQuota, + status: 'exhausted', + cycleStartedAt: new Date('2026-08-01T15:00:00.000Z'), + cycleEndsAt: new Date('2026-08-08T15:00:00.000Z'), + consumedMs: 10_800_000, + remainingMs: 0, + nextCycleAt: new Date('2026-08-08T15:00:00.000Z'), + }, + serverNow: new Date('2026-08-08T14:00:00.000Z'), + }); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.props).toMatchObject({ + signedIn: true, + entitled: false, + quota: expect.objectContaining({ + status: 'exhausted', + remainingMs: 0, + }), + serverNow: '2026-08-08T14:00:00.000Z', + }); + }); + + it('does not fabricate Free or welcome state when identity resolution fails', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockRejectedValue(new Error('identity unavailable')); + mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: true, apple: false }); + mocks.earlyBirdMagicLinkAvailable.mockReturnValue(false); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.props).toMatchObject({ + signedIn: false, + serviceUnavailable: 'identity', + quota: null, + }); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); + }); + + it('does not fabricate Free or welcome state when access resolution fails', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); + mocks.getEarlyBirdListeningAccess.mockRejectedValue(new Error('database unavailable')); + mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: true, apple: false }); + mocks.earlyBirdMagicLinkAvailable.mockReturnValue(false); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.props).toMatchObject({ + signedIn: true, + serviceUnavailable: 'access', + quota: null, + }); + }); + + it('shows identity unavailable when no public sign-in method is configured', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: false, apple: false }); + mocks.earlyBirdMagicLinkAvailable.mockReturnValue(false); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.props.serviceUnavailable).toBe('identity'); + }); + + it.each([ + [LISTENER_INVITATION_COOKIE], + [EARLY_BIRD_INVITATION_COOKIE], + ])('recognizes a valid %s invitation cookie without exposing its value', async (name) => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.headers.mockResolvedValue(new Headers({ cookie: `${name}=${INVITATION}` })); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: true, apple: false }); + mocks.earlyBirdMagicLinkAvailable.mockReturnValue(false); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.props.invitationAvailable).toBe(true); + expect(JSON.stringify(result.props)).not.toContain(INVITATION); + }); + + it('fails closed when canonical and legacy invitation cookies conflict', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + const other = `ebi_v1.${'d'.repeat(32)}.${'e'.repeat(32)}.${'f'.repeat(32)}`; + mocks.headers.mockResolvedValue(new Headers({ + cookie: `${LISTENER_INVITATION_COOKIE}=${other}; ${EARLY_BIRD_INVITATION_COOKIE}=${INVITATION}`, + })); + mocks.currentEarlyBirdSession.mockResolvedValue(null); + mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: true, apple: false }); + mocks.earlyBirdMagicLinkAvailable.mockReturnValue(false); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.props.invitationAvailable).toBe(false); + }); +}); diff --git a/src/app/early-birds/home/page.tsx b/src/app/early-birds/home/page.tsx new file mode 100644 index 00000000..5002b728 --- /dev/null +++ b/src/app/early-birds/home/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from 'next/navigation'; + +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; + +export const dynamic = 'force-dynamic'; + +export default async function EarlyBirdHomePage() { + redirect(LISTENER_NAMESPACE.canonical.home); +} diff --git a/src/app/early-birds/layout.tsx b/src/app/early-birds/layout.tsx new file mode 100644 index 00000000..668664a4 --- /dev/null +++ b/src/app/early-birds/layout.tsx @@ -0,0 +1,11 @@ +import { headers } from 'next/headers'; + +import { LocaleProvider } from '@/context/LocaleContext'; +import { requestBrowserLocale } from '@/lib/i18n-server'; + +export default async function EarlyBirdLayout({ children }: { children: React.ReactNode }) { + const requestHeaders = await headers(); + const locale = await requestBrowserLocale(requestHeaders); + + return {children}; +} diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx new file mode 100644 index 00000000..c60f241d --- /dev/null +++ b/src/app/early-birds/page.tsx @@ -0,0 +1,139 @@ +import { headers as requestHeaders } from 'next/headers'; +import { redirect } from 'next/navigation'; + +import EarlyBirdLanding from '@/components/early-birds/EarlyBirdLanding'; +import EarlyBirdHome from '@/components/early-birds/EarlyBirdHome'; +import EarlyBirdUnavailable from '@/components/early-birds/EarlyBirdUnavailable'; +import { + currentEarlyBirdSession, +} from '@/lib/early-birds/auth'; +import { getEarlyBirdListeningAccess } from '@/lib/early-birds/access'; +import { earlyBirdsEnabled, earlyBirdsFreeForAll } from '@/lib/early-birds/enabled'; +import { + listenerInvitationFromCookieHeader, +} from '@/lib/early-birds/invitation-cookie'; +import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entry'; +import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; +import { + listenerAccountRPConfig, + listenerAutomaticHandoffSuppressed, +} from '@/lib/listener/account-rp'; +import { serializeEarlyBirdQuotaSnapshot } from '@/lib/early-birds/quota'; +import { + isCanonicalListenerHost, + isListenerStagingHost, + listenerLocaleForHeaders, + listenerPreviewMetadata, + listenerPublicMetadata, +} from '@/lib/listener/public-discovery'; + +export const dynamic = 'force-dynamic'; + +export async function generateMetadata() { + const incomingHeaders = await requestHeaders(); + if (!isCanonicalListenerHost(incomingHeaders)) return listenerPreviewMetadata(); + + return listenerPublicMetadata( + listenerLocaleForHeaders(incomingHeaders), + ); +} + +export default async function EarlyBirdsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + if (!earlyBirdsEnabled()) return ; + const incomingHeaders = new Headers(await requestHeaders()); + const listenerStagingHost = isListenerStagingHost(incomingHeaders); + const canonicalListenerHost = isCanonicalListenerHost(incomingHeaders); + const reactiveFieldLabAvailable = listenerStagingHost + && process.env.BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED === '1'; + // Public playback uses the inert CSS field. Remote analysis remains + // available only inside the explicitly enabled staging laboratory. + const reactiveVisualizationAvailable = reactiveFieldLabAvailable; + const params = await searchParams; + const paypalReturn = params.paypal; + const checkoutReturn = params.checkout; + if ((listenerStagingHost || canonicalListenerHost) && ( + paypalReturn === 'success' + || paypalReturn === 'cancel' + || checkoutReturn === 'returned' + || checkoutReturn === 'cancelled' + )) { + // Provider redirects are never membership authority. Remove their opaque + // browser parameters before rendering; the clean request will read the + // canonical server-side membership projection instead. + redirect('/'); + } + + if (earlyBirdsFreeForAll()) { + return ( + + ); + } + + const sessionResolution = await currentEarlyBirdSession() + .then((session) => ({ session, unavailable: false as const })) + .catch(() => ({ session: null, unavailable: true as const })); + const session = sessionResolution.session; + const accessResolution = session + ? await getEarlyBirdListeningAccess(session.user.id) + .then((access) => ({ access, unavailable: false as const })) + .catch(() => ({ access: null, unavailable: true as const })) + : { access: null, unavailable: false as const }; + const access = accessResolution.access; + const invitationAvailable = listenerInvitationFromCookieHeader( + incomingHeaders.get('cookie'), + ) !== null; + + if (session && access?.allowed === true) { + return ( + + ); + } + + let accountIdentityAvailable = true; + try { listenerAccountRPConfig(incomingHeaders); } catch { accountIdentityAvailable = false; } + const syntheticTeamEntryAvailable = syntheticTeamEntryAllowed({ headers: incomingHeaders }); + const accountUnavailable = params.accountUnavailable === '1'; + if (!session && accountIdentityAvailable && !accountUnavailable && params.authError !== '1' && + !listenerAutomaticHandoffSuppressed(incomingHeaders)) { + redirect('/api/account/login?auto=1'); + } + const identityUnavailable = sessionResolution.unavailable || ( + !session + && !accountIdentityAvailable + && !syntheticTeamEntryAvailable + ) || accountUnavailable; + return ( + + ); +} diff --git a/src/app/early-birds/redeem/__tests__/page.test.tsx b/src/app/early-birds/redeem/__tests__/page.test.tsx new file mode 100644 index 00000000..a582fdb9 --- /dev/null +++ b/src/app/early-birds/redeem/__tests__/page.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + headers: vi.fn(), + currentEarlyBirdSession: vi.fn(), + redirect: vi.fn((target: string) => { + throw new Error(`REDIRECT:${target}`); + }), +})); + +vi.mock('next/headers', () => ({ headers: mocks.headers })); +vi.mock('next/navigation', () => ({ redirect: mocks.redirect })); +vi.mock('@/lib/early-birds/auth', () => ({ + currentEarlyBirdSession: mocks.currentEarlyBirdSession, +})); + +import FreeInvitationRedeemer from '@/components/early-birds/FreeInvitationRedeemer'; +import { + EARLY_BIRD_INVITATION_COOKIE, + LISTENER_INVITATION_COOKIE, +} from '@/lib/early-birds/invitation-cookie'; +import EarlyBirdRedeemPage from '../page'; + +const TOKEN = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)}`; + +function cookieHeaders(entries: Array<[string, string]>) { + return new Headers({ + cookie: entries.map(([name, value]) => `${name}=${value}`).join('; '), + }); +} + +beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('Listener invitation redeem page compatibility', () => { + it.each([ + [LISTENER_INVITATION_COOKIE], + [EARLY_BIRD_INVITATION_COOKIE], + ])('accepts an authenticated %s-only handoff', async (name) => { + mocks.headers.mockResolvedValue(cookieHeaders([[name, TOKEN]])); + + const result = await EarlyBirdRedeemPage(); + + expect(result.type).toBe(FreeInvitationRedeemer); + expect(mocks.redirect).not.toHaveBeenCalled(); + }); + + it('accepts equal dual cookies', async () => { + mocks.headers.mockResolvedValue(cookieHeaders([ + [LISTENER_INVITATION_COOKIE, TOKEN], + [EARLY_BIRD_INVITATION_COOKIE, TOKEN], + ])); + + const result = await EarlyBirdRedeemPage(); + + expect(result.type).toBe(FreeInvitationRedeemer); + }); + + it('fails closed before auth when generations conflict or a name is duplicated', async () => { + const other = `ebi_v1.${'d'.repeat(32)}.${'e'.repeat(32)}.${'f'.repeat(32)}`; + for (const entries of [ + [ + [LISTENER_INVITATION_COOKIE, other], + [EARLY_BIRD_INVITATION_COOKIE, TOKEN], + ], + [ + [LISTENER_INVITATION_COOKIE, TOKEN], + [LISTENER_INVITATION_COOKIE, TOKEN], + ], + ] as Array>) { + mocks.headers.mockResolvedValueOnce(cookieHeaders(entries)); + await expect(EarlyBirdRedeemPage()).rejects.toThrow('REDIRECT:/listener'); + } + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/early-birds/redeem/page.tsx b/src/app/early-birds/redeem/page.tsx new file mode 100644 index 00000000..b28191aa --- /dev/null +++ b/src/app/early-birds/redeem/page.tsx @@ -0,0 +1,25 @@ +import { redirect } from 'next/navigation'; +import { headers } from 'next/headers'; + +import FreeInvitationRedeemer from '@/components/early-birds/FreeInvitationRedeemer'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; +import { + listenerInvitationFromCookieHeader, +} from '@/lib/early-birds/invitation-cookie'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; + +export const dynamic = 'force-dynamic'; + +export default async function EarlyBirdRedeemPage() { + if (!earlyBirdsEnabled()) redirect(LISTENER_NAMESPACE.canonical.home); + + const incomingHeaders = await headers(); + const token = listenerInvitationFromCookieHeader(incomingHeaders.get('cookie')); + if (!token) redirect(LISTENER_NAMESPACE.canonical.home); + + const session = await currentEarlyBirdSession().catch(() => null); + if (!session) redirect(LISTENER_NAMESPACE.canonical.home); + + return ; +} diff --git a/src/app/fonts/README.md b/src/app/fonts/README.md new file mode 100644 index 00000000..bd6ff058 --- /dev/null +++ b/src/app/fonts/README.md @@ -0,0 +1,22 @@ +# Self-hosted application fonts + +These files remove the release-time dependency on `fonts.gstatic.com` while +preserving the existing typography: + +| Family | Source file | Local SHA-256 | +| --- | --- | --- | +| Cormorant Garamond variable, normal 400–600 | `cormorant-garamond/CormorantGaramond-wght.woff2` | `e4c3c3eb566c07afee0b54301b984dc3e5e7e1dd1218a528e61133ed84a7647d` | +| Cormorant Garamond variable, italic 400–600 | `cormorant-garamond/CormorantGaramond-Italic-wght.woff2` | `14d1519ed9320432e1782e0b90435647827937a41222e99531f449c981090303` | +| Syne variable, normal 400–700 | `syne/Syne-wght.woff2` | `3426a96623df5fba636f48774ae899f5b9136b67a8418f49c04d110cf30a585b` | +| Space Mono regular 400 | `space-mono/SpaceMono-Regular.woff2` | `76ba939dbd8fe9d6cb0519633d0e92878e21e6c8cb6cd635f67fc344c242a4c9` | +| Space Mono bold 700 | `space-mono/SpaceMono-Bold.woff2` | `2ef5a6968e7045c138da05c95e583025c967b698a3c2bd3d9ea177ba7209934b` | + +Upstream is the Google Fonts repository at commit +`038b637da7b3fd956a4ed93ffc607c3d5e4ce172`. The original TTF files were +subset locally with fontTools 4.57.0 to Latin/Latin Extended plus punctuation +and emitted as WOFF2. Each family is licensed under the SIL Open Font License +1.1; the upstream `OFL.txt` text is retained next to each family (with trailing +whitespace normalized for the repository gate). + +Do not replace these binaries implicitly during dependency upgrades. Update +the provenance, hashes, licenses and visual/browser acceptance together. diff --git a/src/app/fonts/cormorant-garamond/CormorantGaramond-Italic-wght.woff2 b/src/app/fonts/cormorant-garamond/CormorantGaramond-Italic-wght.woff2 new file mode 100644 index 00000000..984e3c5a Binary files /dev/null and b/src/app/fonts/cormorant-garamond/CormorantGaramond-Italic-wght.woff2 differ diff --git a/src/app/fonts/cormorant-garamond/CormorantGaramond-wght.woff2 b/src/app/fonts/cormorant-garamond/CormorantGaramond-wght.woff2 new file mode 100644 index 00000000..db32ef25 Binary files /dev/null and b/src/app/fonts/cormorant-garamond/CormorantGaramond-wght.woff2 differ diff --git a/src/app/fonts/cormorant-garamond/OFL.txt b/src/app/fonts/cormorant-garamond/OFL.txt new file mode 100644 index 00000000..10e3a35d --- /dev/null +++ b/src/app/fonts/cormorant-garamond/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/app/fonts/inter/Inter-latin-wght.woff2 b/src/app/fonts/inter/Inter-latin-wght.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/src/app/fonts/inter/Inter-latin-wght.woff2 differ diff --git a/src/app/fonts/inter/OFL.txt b/src/app/fonts/inter/OFL.txt new file mode 100644 index 00000000..909a6bf9 --- /dev/null +++ b/src/app/fonts/inter/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/app/fonts/space-mono/OFL.txt b/src/app/fonts/space-mono/OFL.txt new file mode 100644 index 00000000..389d65c1 --- /dev/null +++ b/src/app/fonts/space-mono/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/app/fonts/space-mono/SpaceMono-Bold.woff2 b/src/app/fonts/space-mono/SpaceMono-Bold.woff2 new file mode 100644 index 00000000..5b8eda90 Binary files /dev/null and b/src/app/fonts/space-mono/SpaceMono-Bold.woff2 differ diff --git a/src/app/fonts/space-mono/SpaceMono-Regular.woff2 b/src/app/fonts/space-mono/SpaceMono-Regular.woff2 new file mode 100644 index 00000000..2162fd67 Binary files /dev/null and b/src/app/fonts/space-mono/SpaceMono-Regular.woff2 differ diff --git a/src/app/fonts/syne/OFL.txt b/src/app/fonts/syne/OFL.txt new file mode 100644 index 00000000..7baa003b --- /dev/null +++ b/src/app/fonts/syne/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Syne Project Authors (https://gitlab.com/bonjour-monde/fonderie/syne-typeface) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/app/fonts/syne/Syne-wght.woff2 b/src/app/fonts/syne/Syne-wght.woff2 new file mode 100644 index 00000000..5c2dde16 Binary files /dev/null and b/src/app/fonts/syne/Syne-wght.woff2 differ diff --git a/src/app/globals.css b/src/app/globals.css index c8aebb41..fe4663dd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,5 +1,327 @@ @import "tailwindcss"; +/* Accessible light-DOM fallback while the canonical cross-product web + component loads from harmonicbeacon.com. Once defined, its shadow DOM owns + the exact desktop/mobile rendering shared by all three products. */ +hb-global-nav:not(:defined) { + display: block; + min-height: 72px; + background: rgba(22, 18, 13, 0.98); + border-bottom: 1px solid rgba(244, 238, 226, 0.1); + color: #f4eee2; + font-family: var(--font-hb-inter), Inter, system-ui, sans-serif; +} + +hb-global-nav:not(:defined) > [slot="account-menu"] { display: none; } + +.hb-global-navigation-local-account-slot { min-width: 15rem; } +.hb-listener-account-menu { display: grid; min-width: 15rem; } +.hb-listener-account-menu__identity { + margin: 0; + padding: 0.65rem 0.75rem 0.55rem; + border-bottom: 1px solid rgba(244, 238, 226, 0.1); + color: #f4eee2; + font-size: 0.82rem; + font-weight: 600; + overflow-wrap: anywhere; +} +.hb-listener-account-menu a, +.hb-listener-account-menu button { + display: flex; + width: 100%; + min-height: 44px; + align-items: center; + padding: 10px 12px; + border: 0; + border-radius: 8px; + color: #e9e0d0; + background: transparent; + cursor: pointer; + font: 600 11px/1.2 var(--font-hb-inter), Inter, system-ui, sans-serif; + letter-spacing: .12em; + text-align: left; + text-transform: uppercase; +} +.hb-listener-account-menu a:hover, +.hb-listener-account-menu button:hover { color: #f4eee2; background: rgba(201, 162, 78, .1); } +.hb-listener-account-menu a:focus-visible, +.hb-listener-account-menu button:focus-visible { outline: 2px solid #c9a24e; outline-offset: -2px; } +.hb-listener-account-menu small { + padding: 0.55rem 0.75rem; + color: #e4b8ae; + font-size: 0.72rem; + line-height: 1.4; +} + +/* Central Account authority. Bound to its own route/host; event and Listener + product styling remain untouched. */ +.account-shell { + min-height: 100svh; + padding: clamp(5rem, 10vw, 8rem) clamp(1rem, 5vw, 4rem) 3rem; + color: var(--hb-bone, #f4eee2); + background: + radial-gradient(circle at 50% 0%, rgba(201, 162, 78, 0.14), transparent 42rem), + #16120d; + font-family: var(--font-hb-inter), Inter, system-ui, sans-serif; +} +.account-shell--center { display: grid; place-items: center; } +.account-hero, .account-grid { width: min(64rem, 100%); margin-inline: auto; } +.account-hero { margin-bottom: 2rem; } +.account-brand, .account-link { color: #c9a24e; text-decoration: none; } +.account-eyebrow { color: #c9a24e; text-transform: uppercase; letter-spacing: .16em; font-size: .72rem; } +.account-hero h1, .account-card h1, .account-card h2 { + margin: .4rem 0; + font-family: var(--font-cormorant), Georgia, serif; + font-weight: 500; + font-size: clamp(2rem, 6vw, 3.7rem); +} +.account-card h2 { font-size: 2rem; } +.account-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); } +.account-card { + width: min(34rem, 100%); + padding: clamp(1.1rem, 4vw, 2rem); + border: 1px solid rgba(201, 162, 78, .24); + border-radius: 18px; + background: rgba(30, 24, 18, .92); + box-shadow: 0 1.5rem 5rem rgba(0, 0, 0, .28); +} +.account-shell--logout { display: grid; place-items: center; padding-block: 5rem 2rem; } +.account-card--logout { + width: min(24rem, 100%); + padding: 0.9rem 1rem; + border-radius: 12px; + box-shadow: 0 0.8rem 2.5rem rgba(0, 0, 0, .2); +} +.account-card--logout h1 { + margin: 0; + font-family: var(--font-hb-inter), Inter, system-ui, sans-serif; + font-size: 0.85rem; + font-weight: 500; + letter-spacing: .04em; +} +.account-grid .account-card { width: 100%; } +.account-form, .account-providers, .account-actions { display: grid; gap: .75rem; margin-top: 1rem; } +.account-form label { display: grid; gap: .35rem; font-size: .82rem; color: rgba(244, 238, 226, .78); } +.account-form input { + min-height: 44px; + padding: .7rem .8rem; + color: #f4eee2; + background: #16120d; + border: 1px solid rgba(244, 238, 226, .22); + border-radius: 10px; +} +.account-password-field { position: relative; display: block; } +.account-password-field input { width: 100%; min-width: 0; padding-right: 3.35rem; } +.account-password-toggle { + position: absolute; + inset: 0 .1rem 0 auto; + width: 44px; + min-width: 44px; + min-height: 44px !important; + margin: auto 0; + padding: 0 !important; + border: 0 !important; + border-radius: 8px !important; + color: rgba(244, 238, 226, .72) !important; + background: transparent !important; +} +.account-password-toggle:hover { color: #e0bd69 !important; background: rgba(201, 162, 78, .08) !important; } +.account-password-toggle svg { + width: 21px; + height: 21px; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} +.account-form input:focus-visible, .account-card button:focus-visible, .account-card a:focus-visible { + outline: 2px solid #e0bd69; + outline-offset: 3px; +} +.account-card button, .account-primary { + min-height: 44px; + padding: .7rem 1rem; + border: 1px solid rgba(201, 162, 78, .45); + border-radius: 999px; + color: #f4eee2; + background: transparent; + cursor: pointer; +} +.account-card button:hover { background: rgba(201, 162, 78, .1); } +.account-card button:disabled { opacity: .55; cursor: wait; } +.account-card .account-primary { + display: inline-flex; align-items: center; justify-content: center; + color: #16120d; background: #c9a24e; font-weight: 600; text-decoration: none; +} +.account-inline { margin-top: 1rem; } +.account-return { width: fit-content; margin-top: 1rem; } +.account-mode { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; } +.account-mode button[aria-pressed="true"] { color: #16120d; background: #c9a24e; } +.account-muted, .account-message { color: rgba(244, 238, 226, .7); line-height: 1.55; } +.account-signup-confirmation { + display: grid; + justify-items: center; + gap: .75rem; + margin-bottom: 1.25rem; + padding: 1.25rem; + border: 1px solid rgba(201, 162, 78, .45); + border-radius: 18px; + color: #f4eee2; + text-align: center; + background: rgba(201, 162, 78, .08); +} +.account-signup-confirmation:focus-visible { outline: 2px solid #e0bd69; outline-offset: 3px; } +.account-signup-confirmation__mark { + display: grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + border: 1px solid rgba(201, 162, 78, .65); + border-radius: 999px; + color: #e0bd69; + font-size: 1.25rem; +} +.account-signup-confirmation h2, .account-signup-confirmation p { margin: 0; } +.account-signup-confirmation p { line-height: 1.55; } +.account-link { margin-top: 1rem; border: 0 !important; padding-inline: 0 !important; } +@media (prefers-reduced-motion: reduce) { + .account-shell *, .account-shell *::before, .account-shell *::after { scroll-behavior: auto !important; transition: none !important; } +} +.hb-global-navigation-fallback { + min-height: 72px; + max-width: 1180px; + margin-inline: auto; + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.hb-global-navigation-fallback__brand { + color: #f4eee2; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + white-space: nowrap; +} + +.hb-global-navigation-fallback ul { + display: flex; + align-items: center; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +.hb-global-navigation-fallback__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.hb-global-navigation-fallback__account-control { + position: relative; + width: 44px; + height: 44px; + flex: 0 0 44px; +} + +.hb-global-navigation-fallback__account-control summary { + width: 44px; + height: 44px; + display: grid; + place-items: center; + list-style: none; + border: 1px solid rgba(201, 162, 78, .55); + border-radius: 999px; + color: #e9e0d0; + background: rgba(22, 18, 13, .82); + cursor: pointer; +} +.hb-global-navigation-fallback__account-control--signed-in summary { + border-color: rgba(201, 162, 78, .72); + background: rgba(201, 162, 78, .1); +} +.hb-global-navigation-fallback__account-control--signed-in summary::after { + content: ""; + position: absolute; + right: 3px; + bottom: 3px; + width: 7px; + height: 7px; + border: 2px solid #16120d; + border-radius: 999px; + background: #c9a24e; +} + +.hb-global-navigation-fallback__account-control summary::-webkit-details-marker { display: none; } + +.hb-global-navigation-fallback__account-control summary:focus-visible { + outline: 2px solid #c9a24e; + outline-offset: 3px; +} + +.hb-global-navigation-fallback__account-control svg { + width: 24px; + height: 24px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; +} + +.hb-global-navigation-fallback__account-menu { + position: absolute; + z-index: 4; + top: calc(100% + 8px); + right: 0; + min-width: 164px; + padding: 6px; + border: 1px solid rgba(201, 162, 78, .34); + border-radius: 12px; + background: #16120d; + box-shadow: 0 18px 48px rgba(0, 0, 0, .34); +} + +.hb-global-navigation-fallback__account-menu a { + min-height: 44px; + display: flex; + align-items: center; + padding: 10px 12px; + border-radius: 8px; + color: #e9e0d0; + font-size: 11px; + font-weight: 600; + letter-spacing: .12em; + text-transform: uppercase; + white-space: nowrap; +} + +.hb-global-navigation-fallback__account-menu a[aria-current="page"] { color: #c9a24e; } + +.hb-global-navigation-fallback li a { + padding: 9px 8px; + color: #ada089; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + white-space: nowrap; +} + +.hb-global-navigation-fallback li a[aria-current="page"] { + color: #c9a24e; +} + +@media (max-width: 1120px) { + hb-global-nav:not(:defined) { min-height: 68px; } + .hb-global-navigation-fallback { min-height: 68px; padding-inline: 16px; } + .hb-global-navigation-fallback ul { display: none; } +} + /* ============================================ HARMONIC PROJECTION — Design System Phase 5: Aligned with confirmation palette @@ -98,6 +420,19 @@ body { -moz-osx-font-smoothing: grayscale; } +/* The canonical Listener host owns the browser overscroll canvas. Keep this + exact-host marker in RootLayout so the event and operator document retain + their established green/Syne theme. */ +html[data-hb-surface='listener'], +html[data-hb-surface='listener'] body { + background: var(--hb-bg-0); + color: var(--hb-ink-800); +} + +html[data-hb-surface='listener'] body { + font-family: var(--hb-font-sans); +} + :where(a, button, summary, input, select, textarea):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; @@ -422,6 +757,24 @@ body { border-color: var(--border-strong); } +.listener-transport__button[aria-pressed="true"] { + border-color: rgba(124, 234, 255, 0.78); + box-shadow: + 0 0 0 1px rgba(124, 234, 255, 0.28), + 0 0 30px rgba(124, 234, 255, 0.32), + inset 0 0 18px rgba(255, 255, 255, 0.12); +} + +.listener-transport__button[aria-pressed="true"]::after { + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 10px currentColor; + content: ''; +} + .event-button--danger { color: #fca5a5; background: rgba(239, 68, 68, 0.08); @@ -1098,3 +1451,1493 @@ body { scroll-behavior: auto; } } + +/* ============================================ + HARMONIC BEACON LISTENER + Isolated from event and operator surfaces. + ============================================ */ + +@keyframes listener-orbit { + from { transform: translate(-50%, -50%) rotate(0deg); } + to { transform: translate(-50%, -50%) rotate(360deg); } +} + +@keyframes listener-breathe { + 0%, 100% { opacity: 0.58; transform: scale(0.96); } + 50% { opacity: 1; transform: scale(1.035); } +} + +@keyframes listener-core-breathe { + 0%, 100% { opacity: 0.58; transform: translate(-50%, -50%) scale(0.96); } + 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.035); } +} + +@keyframes listener-point { + 0%, 100% { opacity: 0.18; transform: scale(0.8); } + 50% { opacity: 0.82; transform: scale(1.18); } +} + +.listener-shell, +.listener-page-shell { + --night: var(--hb-bg-0); + --forest: var(--hb-bg-1); + --forest-2: var(--hb-bg-2); + --cream: var(--hb-bone); + --paper: var(--hb-bone); + --text: var(--hb-ink-800); + --muted: var(--hb-ink-600); + --gold: var(--hb-gold); + --cyan: var(--hb-gold-2); + --lime: var(--hb-gold-2); + --violet: var(--hb-gold-deep); + --bg-page: var(--hb-bg-0); + --bg-elevated: rgba(30, 24, 18, 0.94); + --bg-elevated-hover: rgba(36, 29, 21, 0.96); + --bg-card: rgba(27, 21, 15, 0.86); + --surface-alt: rgba(36, 29, 21, 0.72); + --text-primary: var(--hb-bone); + --text-secondary: var(--hb-ink-800); + --text-muted: var(--hb-ink-600); + --text-inverse: var(--hb-bg-0); + --border-subtle: var(--hb-hair-soft); + --border-active: var(--hb-hair); + --border-gold: var(--hb-hair); + --border-strong: rgba(201, 162, 78, 0.42); + --focus-ring: var(--hb-gold-2); + --shadow-card: var(--hb-shadow-card); + --shadow-glow: 0 0 40px rgba(201, 162, 78, 0.1); + --shadow-deep: 0 35px 110px rgba(0, 0, 0, 0.52), 0 0 80px rgba(201, 162, 78, 0.06); + color: var(--hb-ink-800); + font-family: var(--hb-font-sans); +} + +/* A host-local Account document is hidden before a possible bfcache snapshot. + A persisted restoration reloads authoritatively before removing this flag. */ +html[data-hb-listener-identity-stale='1'] body { + visibility: hidden; +} + +.listener-shell :where(button, input, select, textarea), +.listener-page-shell :where(button, input, select, textarea) { + font: inherit; +} + +.listener-shell { + --listener-accent: var(--hb-gold); + min-height: 100vh; + position: relative; + overflow-x: clip; + background: + radial-gradient(120% 80% at 12% -8%, rgba(201, 162, 78, 0.1), transparent 60%), + radial-gradient(120% 90% at 92% 108%, rgba(110, 94, 68, 0.16), transparent 60%), + linear-gradient(158deg, var(--hb-bg-0), var(--hb-bg-1)); +} + +.listener-shell::before { + position: fixed; + inset: 0; + pointer-events: none; + background-image: radial-gradient(rgba(244, 238, 226, 0.13) 0.55px, transparent 0.7px); + background-size: 31px 31px; + mask-image: linear-gradient(to bottom, black, transparent 84%); + content: ''; + opacity: 0.34; +} + +.listener-reactive-field { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + contain: strict; + /* The field is atmospheric rather than UI: keep it softly out of focus + everywhere, while the control panel's backdrop blur remains deeper. */ + filter: blur(3px); + transform: scale(1.012); +} + +.listener-reactive-field::after { + position: absolute; + inset: 0; + z-index: 1; + background: + linear-gradient(to bottom, rgba(22, 18, 13, 0.18), transparent 28%, transparent 68%, rgba(22, 18, 13, 0.5)), + radial-gradient(ellipse at 50% 52%, transparent 0%, transparent 30%, rgba(22, 18, 13, 0.22) 76%); + content: ''; +} + +.listener-static-field { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + transition: opacity 240ms ease; +} + +.listener-shell__frame--home:has(.listener-reactive-field) > .listener-static-field { + opacity: 0; +} + +.listener-static-field::after { + position: absolute; + inset: 0; + z-index: 1; + background: + linear-gradient(to bottom, rgba(22, 18, 13, 0.14), transparent 30%, transparent 68%, rgba(22, 18, 13, 0.44)), + radial-gradient(circle at 50% 48%, transparent 12%, rgba(22, 18, 13, 0.18) 68%, rgba(22, 18, 13, 0.48) 100%); + content: ''; +} + +.listener-shell__frame--home > .listener-static-field .listener-field { + width: min(125vw, 90rem); + aspect-ratio: 1.35; + margin: 0; + position: absolute; + top: 50%; + left: 50%; + opacity: 0.58; + transform: translate(-50%, -50%); + -webkit-mask-image: radial-gradient(ellipse, #000 12%, rgba(0, 0, 0, 0.82) 62%, transparent 92%); + mask-image: radial-gradient(ellipse, #000 12%, rgba(0, 0, 0, 0.82) 62%, transparent 92%); +} + +.listener-shell__frame { + width: min(100%, 1180px); + min-height: 100vh; + margin: 0 auto; + padding: max(1.25rem, var(--safe-top)) clamp(1.1rem, 4vw, 3.5rem) max(2rem, var(--safe-bottom)); + position: relative; + z-index: 1; +} + +.listener-shell__frame--home { + display: grid; + width: min(100%, 90rem); + grid-template-rows: auto minmax(0, 1fr); + gap: clamp(0.5rem, 1.5vh, 1rem); +} + +.listener-altar { + width: min(100%, 46rem); + min-height: min(43rem, calc(100dvh - 8rem)); + align-self: center; + justify-self: center; + position: relative; + isolation: isolate; + overflow: hidden; + padding: clamp(1.1rem, 3vw, 2rem); + border: 1px solid rgba(201, 162, 78, 0.24); + border-radius: 2.15rem 2.15rem 1.15rem 1.15rem; + background: linear-gradient(160deg, rgba(36, 29, 21, 0.24), rgba(22, 18, 13, 0.44)); + box-shadow: 0 2.25rem 7rem rgba(0, 0, 0, 0.48), inset 0 1px rgba(244, 238, 226, 0.035); + -webkit-backdrop-filter: blur(3px) saturate(105%); + backdrop-filter: blur(3px) saturate(105%); +} + +.listener-altar::before, +.listener-public-altar::before { + position: absolute; + top: 0; + left: 50%; + z-index: 3; + width: 54%; + height: 1px; + content: ''; + background: linear-gradient(90deg, transparent, var(--hb-gold-2), transparent); + transform: translateX(-50%); +} + +.listener-altar > * { + position: relative; + z-index: 2; +} + +.listener-altar__heading { + display: grid; + gap: 0.35rem; + text-align: center; +} + +.listener-altar__heading p { + color: var(--hb-gold); + font-size: 0.64rem; + font-weight: 500; + letter-spacing: var(--hb-track-eyebrow); + text-transform: uppercase; +} + +.listener-altar__heading strong { + color: var(--hb-bone); + font-family: var(--hb-font-serif); + font-size: clamp(2rem, 5vw, 2.55rem); + font-weight: 500; + line-height: 1; +} + +.listener-rail { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 3.25rem; + position: relative; + z-index: 2; +} + +.listener-rail__actions { + display: flex; + align-items: center; + gap: 0.65rem; + margin-left: auto; +} + +.listener-membership-entry { + min-height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.65rem 1rem; + border: 1px solid rgba(201, 162, 78, 0.42); + border-radius: 999px; + color: var(--hb-bone); + background: linear-gradient(135deg, rgba(201, 162, 78, 0.14), rgba(201, 162, 78, 0.04)); + box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.2); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.02em; + text-decoration: none; + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; +} + +.listener-membership-entry:hover { + border-color: var(--hb-gold-2); + background: linear-gradient(135deg, rgba(201, 162, 78, 0.2), rgba(201, 162, 78, 0.07)); + transform: translateY(-1px); +} + +.listener-membership-entry:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 3px; +} + +.listener-experience { + width: 100%; + align-self: center; + margin: clamp(0.8rem, 2vh, 1.35rem) auto 0; + padding-bottom: 0; + --listener-phase: var(--hb-gold); +} + +.listener-experience[data-phase='intro'] { --listener-phase: var(--hb-gold); } +.listener-experience[data-phase='beacon'] { --listener-phase: var(--hb-gold-2); } +.listener-experience[data-phase='reconnecting'], +.listener-experience[data-phase='preparing'] { --listener-phase: var(--warning); } +.listener-experience[data-phase='unavailable'], +.listener-experience[data-phase='displaced'] { --listener-phase: var(--danger); } +.listener-experience[data-phase='paused'], +.listener-experience[data-phase='stopped'] { --listener-phase: var(--muted); } + +.listener-stage { + position: relative; + z-index: 2; + display: grid; + align-content: end; + min-height: clamp(23rem, 52vh, 31rem); + text-align: center; +} + +.listener-control-panel { + width: min(100%, 34rem); + margin: 0.9rem auto 0; + padding: clamp(0.85rem, 2.2vw, 1.2rem) 0 0; + border-top: 1px solid var(--hb-hair-soft); + background: transparent; +} + +.listener-stage__copy { + width: min(100%, 38rem); + margin: 0 auto; +} + +.listener-stage__eyebrow, +.listener-public-hero__copy > p:first-child, +.listener-access__intro > p { + color: var(--hb-gold); + font-family: var(--hb-font-sans); + font-weight: 500; + font-size: 0.68rem; + letter-spacing: var(--hb-track-eyebrow); + text-transform: uppercase; +} + +.listener-field { + width: min(100%, 35rem); + aspect-ratio: 1.4; + margin: clamp(1rem, 3vh, 2rem) auto 0.5rem; + position: relative; + isolation: isolate; + contain: layout paint; + pointer-events: none; + user-select: none; + --field-color: var(--listener-phase, var(--gold)); +} + +.listener-experience .listener-field { + width: min(100%, 32rem); + aspect-ratio: 1.55; + margin: clamp(0.35rem, 1.2vh, 0.8rem) auto 0.1rem; +} + +.listener-field__aurora { + position: absolute; + inset: 12% 17%; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--field-color) 24%, transparent), transparent 66%); + filter: blur(16px); + animation: listener-breathe 7s ease-in-out infinite; +} + +.listener-field__orbit { + position: absolute; + top: 50%; + left: 50%; + border: 1px solid color-mix(in srgb, var(--field-color) 34%, transparent); + border-radius: 50%; + transform: translate(-50%, -50%); +} + +.listener-field__orbit::before, +.listener-field__orbit::after { + position: absolute; + width: 0.42rem; + height: 0.42rem; + border-radius: 50%; + background: var(--field-color); + box-shadow: 0 0 1.2rem var(--field-color); + content: ''; +} + +.listener-field__orbit--outer { + width: 66%; + aspect-ratio: 1; + animation: listener-orbit 48s linear infinite; +} + +.listener-field__orbit--outer::before { top: 8%; left: 20%; } +.listener-field__orbit--outer::after { right: 4%; bottom: 32%; } + +.listener-field__orbit--inner { + width: 43%; + aspect-ratio: 1; + border-style: dashed; + animation: listener-orbit 32s linear infinite reverse; +} + +.listener-field__orbit--inner::before { left: -0.2rem; top: 48%; } +.listener-field__orbit--inner::after { top: 2%; right: 25%; } + +.listener-field__core { + position: absolute; + top: 50%; + left: 50%; + width: 18%; + aspect-ratio: 1; + display: grid; + place-items: center; + border: 1px solid color-mix(in srgb, var(--field-color) 52%, transparent); + border-radius: 50%; + color: var(--field-color); + background: radial-gradient(circle, rgba(244, 238, 226, 0.07), transparent 70%); + box-shadow: 0 0 3rem color-mix(in srgb, var(--field-color) 25%, transparent); + transform: translate(-50%, -50%); + animation: listener-core-breathe 5.5s ease-in-out infinite; +} + +.listener-field__spark { + font-size: clamp(1.2rem, 4vw, 2rem); + line-height: 1; + text-shadow: 0 0 1.2rem currentColor; +} + +.listener-field__horizon { + position: absolute; + left: 8%; + right: 8%; + bottom: 4%; + height: 35%; + border-top: 1px solid var(--hb-hair); + border-radius: 50% 50% 0 0; + background: radial-gradient(ellipse at 50% 0%, rgba(201, 162, 78, 0.08), transparent 66%); + transform: perspective(18rem) rotateX(58deg); + transform-origin: top; +} + +.listener-field__point { + position: absolute; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--hb-ink-800); + box-shadow: 0 0 0.65rem rgba(227, 199, 126, 0.55); + animation: listener-point 4.8s ease-in-out var(--point-delay) infinite; +} + +.listener-field[data-phase='paused'] .listener-field__orbit, +.listener-field[data-phase='stopped'] .listener-field__orbit, +.listener-field[data-phase='unavailable'] .listener-field__orbit, +.listener-field[data-phase='displaced'] .listener-field__orbit { + animation-play-state: paused; + opacity: 0.46; +} + +.listener-stage__status { + min-height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + color: var(--hb-bone); + font-family: var(--hb-font-serif); + font-size: clamp(1rem, 2.6vw, 1.2rem); +} + +.listener-stage__status-dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--listener-phase); + box-shadow: 0 0 0.9rem var(--listener-phase); +} + +.listener-intro-option { + width: min(100%, 28rem); + max-width: 100%; + display: flex; + align-items: center; + gap: 0.7rem; + margin: 0 auto; + padding: 0.8rem 1rem; + border: 1px solid var(--border-subtle); + border-radius: 0.85rem; + background: rgba(27, 21, 15, 0.62); + color: var(--hb-bone); + font-family: var(--hb-font-sans); + font-size: 0.72rem; + letter-spacing: 0.06em; + cursor: pointer; + transition: border-color 180ms ease, background-color 180ms ease; +} + +.listener-reactive-option { + width: fit-content; + max-width: 100%; + display: flex; + align-items: center; + gap: 0.65rem; + margin: 0 auto 0.65rem; + color: var(--text-muted); + font-family: var(--hb-font-sans); + font-size: 0.64rem; + letter-spacing: 0.08em; + cursor: pointer; +} + +.listener-reactive-option input { + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--hb-gold); +} + +.listener-reactive-option:focus-within { + outline: 2px solid var(--hb-gold-2); + outline-offset: 4px; + border-radius: 0.35rem; +} + +.listener-intro-option:has(input:checked) { + border-color: color-mix(in srgb, var(--listener-phase) 48%, transparent); + background: color-mix(in srgb, var(--listener-phase) 10%, transparent); +} + +.listener-intro-option:focus-within { + outline: 2px solid var(--hb-gold-2); + outline-offset: 3px; +} + +.listener-intro-option input { + width: 1.15rem; + height: 1.15rem; + margin: 0; + flex: 0 0 auto; + accent-color: var(--hb-gold); +} + +.listener-intro-option:has(input:disabled) { cursor: wait; opacity: 0.62; } + +.listener-transport { + width: min(100%, 28rem); + display: flex; + gap: 0.65rem; + justify-content: center; + margin: 0.9rem auto 0; +} + +.listener-transport__primary, +.listener-transport__secondary { + min-height: 3.25rem; + border-radius: 999px; + font-family: var(--hb-font-sans); + font-weight: 500; + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; +} + +.listener-transport__primary { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.65rem; + border: 1px solid rgba(244, 238, 226, 0.2); + color: var(--hb-bg-0); + background: linear-gradient(135deg, var(--hb-gold-2), var(--hb-gold) 52%, var(--hb-gold-deep)); + box-shadow: 0 10px 30px rgba(201, 162, 78, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.35); +} + +.listener-experience:not([data-phase='ready']):not([data-phase='stopped']) .listener-transport__primary { + color: var(--hb-bg-0); + background: linear-gradient(135deg, var(--hb-gold-2), var(--hb-gold) 52%, var(--hb-gold-deep)); + border-color: rgba(244, 238, 226, 0.2); +} + +.listener-transport__secondary { + min-width: 4.5rem; + padding: 0 1rem; + border: 1px solid rgba(248, 113, 113, 0.34); + color: #fecaca; + background: rgba(69, 24, 20, 0.24); +} + +.listener-transport button:disabled { opacity: 0.5; cursor: wait; } +.listener-transport--stop-only { justify-content: flex-end; } +.listener-stage__hint { margin: 0.8rem auto 0; color: var(--text-muted); font-size: 0.75rem; } + +.listener-details { + width: min(100%, 32rem); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); + gap: 0.9rem; + margin: 0.9rem auto 0; + padding: 0.9rem 1rem; + border: 1px solid var(--hb-hair-soft); + border-radius: var(--hb-radius-card); + background: rgba(27, 21, 15, 0.52); + text-align: left; +} + +.listener-control-panel .listener-details { + padding: 0.85rem; + border: 1px solid var(--hb-hair-soft); + background: rgba(27, 21, 15, 0.52); +} + +.listener-details__control > span, +.listener-details__seek label > span:first-child, +.listener-details__selection > span { + display: block; + color: var(--hb-bone); + font-family: var(--hb-font-sans); + font-weight: 500; + font-size: 0.64rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.listener-details__selection select { + width: 100%; + margin-top: 0.55rem; + min-height: 2.75rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--hb-hair); + border-radius: 0.7rem; + color: var(--hb-bone); + background-color: var(--hb-bg-1); + color-scheme: dark; + font-size: 0.78rem; + cursor: pointer; +} + +.listener-details__selection select:focus-visible { + outline: 2px solid var(--hb-gold-2); + outline-offset: 2px; + border-color: transparent; +} + +.listener-details__selection select option { + color: var(--hb-bone); + background: var(--hb-bg-1); +} + +.listener-details input[type='range'] { + width: 100%; + min-height: 2.75rem; + margin-top: 0.35rem; + appearance: none; + background: transparent; + cursor: pointer; +} + +.listener-details input[type='range']::-webkit-slider-runnable-track { + height: 0.3rem; + border-radius: 999px; + background: linear-gradient(90deg, var(--hb-gold-deep), var(--hb-gold), var(--hb-gold-2)); + box-shadow: 0 0 1rem rgba(201, 162, 78, 0.12); +} + +.listener-details input[type='range']::-webkit-slider-thumb { + width: 1.15rem; + height: 1.15rem; + margin-top: -0.43rem; + appearance: none; + border: 2px solid var(--hb-bg-0); + border-radius: 50%; + background: var(--hb-bone); + box-shadow: 0 0 0 3px rgba(201, 162, 78, 0.2), 0 0 1rem rgba(201, 162, 78, 0.4); +} + +.listener-details input[type='range']::-moz-range-track { + height: 0.3rem; + border-radius: 999px; + background: linear-gradient(90deg, var(--hb-gold-deep), var(--hb-gold), var(--hb-gold-2)); +} + +.listener-details input[type='range']::-moz-range-thumb { + width: 1rem; + height: 1rem; + border: 2px solid var(--hb-bg-0); + border-radius: 50%; + background: var(--hb-bone); + box-shadow: 0 0 0 3px rgba(201, 162, 78, 0.2), 0 0 1rem rgba(201, 162, 78, 0.4); +} + +.listener-details__time { + display: flex; + justify-content: space-between; + color: var(--text-muted); + font-family: var(--font-space-mono), monospace; + font-size: 0.62rem; +} + +.listener-details__seek button { + min-height: 2.75rem; + margin-top: 0.15rem; + color: var(--hb-gold-2); + font-size: 0.7rem; + cursor: pointer; +} + +.listener-public-hero { + min-height: calc(100dvh - 5rem); + display: grid; + place-items: center; + padding: clamp(1rem, 3vh, 2rem) 0 clamp(2rem, 5vh, 3.5rem); + position: relative; +} + +.listener-public-altar { + width: min(100%, 42rem); + position: relative; + isolation: isolate; + overflow: hidden; + padding: clamp(1.35rem, 4vw, 2.5rem); + border: 1px solid rgba(201, 162, 78, 0.24); + border-radius: 2.15rem 2.15rem 1.15rem 1.15rem; + background: linear-gradient(160deg, rgba(36, 29, 21, 0.24), rgba(22, 18, 13, 0.44)); + box-shadow: 0 2.25rem 7rem rgba(0, 0, 0, 0.48), inset 0 1px rgba(244, 238, 226, 0.035); + -webkit-backdrop-filter: blur(3px) saturate(105%); + backdrop-filter: blur(3px) saturate(105%); +} + +.listener-public-altar > * { + position: relative; + z-index: 2; +} + +.listener-public-hero > .listener-field { + width: min(125vw, 88rem); + aspect-ratio: 1.35; + margin: 0; + position: absolute; + z-index: 0; + top: 50%; + left: 50%; + opacity: 0.52; + transform: translate(-50%, -50%); + -webkit-mask-image: radial-gradient(ellipse, #000 12%, rgba(0, 0, 0, 0.8) 62%, transparent 92%); + mask-image: radial-gradient(ellipse, #000 12%, rgba(0, 0, 0, 0.8) 62%, transparent 92%); +} + +.listener-public-hero__copy { + text-align: center; +} + +.listener-public-hero__copy h1 { + max-width: 15ch; + margin: 0.7rem auto 0; + color: var(--hb-bone); + font-family: var(--hb-font-serif); + font-size: clamp(2.35rem, 6.2vw, 4.5rem); + font-weight: 400; + line-height: 0.9; +} + +.listener-public-hero__copy > p:nth-child(3) { + max-width: 30rem; + margin: 1rem auto 0; + color: var(--text-secondary); + font-size: clamp(1rem, 2vw, 1.2rem); + line-height: 1.7; +} + +.listener-access { + width: min(100%, 31rem); + margin: clamp(6rem, 16vh, 10rem) auto 0; + padding: 0; + scroll-margin-top: 2rem; +} + +.listener-access__card { + padding: clamp(1rem, 3vw, 1.5rem) 0 0; + border-top: 1px solid var(--hb-hair-soft); + background: transparent; +} + +.listener-quota { + display: grid; + gap: 0.45rem; + padding: 1rem; + border: 1px solid var(--hb-hair); + border-radius: var(--hb-radius-card); + background: rgba(201, 162, 78, 0.055); +} + +.listener-quota strong { + color: var(--hb-bone); + font-size: 0.95rem; + font-weight: 500; +} + +.listener-quota p, +.listener-quota small { + color: var(--text-secondary); + font-size: 0.78rem; + line-height: 1.5; +} + +.listener-quota small { + color: var(--text-muted); +} + +.listener-quota--compact { + margin-top: 0.6rem; + padding: 0.65rem 0; + border: 0; + border-top: 1px solid var(--border-subtle); + border-radius: 0; + background: transparent; +} + +.listener-quota--compact strong { font-size: 0.75rem; font-weight: 400; } + +.listener-quota a { + width: fit-content; + min-height: 2.75rem; + display: inline-flex; + align-items: center; + color: var(--hb-gold-2); + font-size: 0.75rem; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--hb-gold) 55%, transparent); + text-underline-offset: 0.24em; +} + +.listener-listening-status { + width: min(100%, 34rem); + margin: 1rem auto 0; + padding-top: 0.25rem; + align-self: end; +} + +.listener-listening-status .listener-quota--compact { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: center; + gap: 0.3rem 0.8rem; + margin: 0; + padding: 0.75rem 0 0; + text-align: center; +} + +.listener-listening-status .listener-quota--compact strong, +.listener-listening-status .listener-quota--compact small { + font-size: 0.7rem; +} + +.listener-membership-page { + width: min(100%, 42rem); + margin: auto; + padding: clamp(2rem, 8vh, 6rem) clamp(1rem, 4vw, 2rem) 3rem; + position: relative; + z-index: 2; +} + +.listener-membership-page__back { + display: inline-flex; + min-height: 44px; + align-items: center; + color: var(--hb-gold-2); + font-size: 0.78rem; + text-decoration: none; +} + +.listener-membership-page > header { + margin-top: clamp(1.5rem, 5vh, 3.5rem); +} + +.listener-membership-page > header > p:first-child { + color: var(--hb-gold); + font-size: 0.68rem; + letter-spacing: var(--hb-track-eyebrow); +} + +.listener-membership-page h1 { + margin-top: 0.6rem; + color: var(--hb-bone); + font-family: var(--hb-font-serif); + font-size: clamp(2.5rem, 8vw, 4.5rem); + font-weight: 400; + line-height: 0.95; +} + +.listener-membership-page > header > p:last-child { + max-width: 34rem; + margin-top: 1rem; + color: var(--text-secondary); + line-height: 1.65; +} + +.listener-membership-page__card { + display: grid; + gap: 1.1rem; + margin-top: 2rem; + padding: clamp(1rem, 4vw, 1.5rem); + border: 1px solid var(--hb-hair); + border-radius: var(--hb-radius-card); + background: rgba(27, 21, 15, 0.78); + box-shadow: var(--hb-shadow-card); +} + +.listener-membership-page__status { + display: grid; + gap: 0.4rem; +} + +.listener-membership-page__status strong { color: var(--hb-bone); font-weight: 500; } +.listener-membership-page__status p, +.listener-membership-page__checkout > p { color: var(--text-secondary); font-size: 0.78rem; line-height: 1.55; } +.listener-membership-page__checkout .listener-checkout { margin-top: 0; } + +.listener-checkout { + margin-top: 0; + color: var(--text-secondary); + font-size: 0.75rem; + text-align: left; +} + +.listener-checkout__options { + display: grid; + gap: 0.7rem; + margin-top: 0; + padding: clamp(1rem, 4vw, 1.35rem); + border: 1px solid var(--border-subtle); + border-radius: var(--hb-radius-card); + background: linear-gradient(145deg, rgba(201, 162, 78, 0.09), rgba(27, 21, 15, 0.94) 45%); +} + +.listener-checkout__options strong { + color: var(--hb-bone); + font-family: var(--hb-font-serif); + font-size: 1.55rem; + font-weight: 500; +} +.listener-checkout__options p { line-height: 1.45; } +.listener-checkout__options [role="alert"] { color: #fecaca; } + +.listener-checkout__payer-email { + display: grid; + gap: 0.35rem; + color: var(--text-secondary); + text-align: left; +} + +.listener-checkout__payer-email > label { + color: var(--hb-bone); + font-size: 0.72rem; + font-weight: 600; +} + +.listener-checkout__payer-email > small { + color: var(--text-muted); + font-size: 0.66rem; + line-height: 1.4; +} + +.listener-checkout__payer-email > small[role="alert"] { color: #fecaca; } + +.listener-checkout__legal { + color: var(--text-muted); + font-size: 0.68rem; +} + +.listener-checkout__legal a, +.listener-legal a { color: var(--hb-gold-2); text-decoration: underline; text-underline-offset: 0.22em; } + +.listener-legal-shell { min-height: 100dvh; padding: 2rem 1rem; overflow: auto; } + +.listener-legal { + width: min(100%, 48rem); + margin: auto; + padding: clamp(1.2rem, 4vw, 3rem); + border: 1px solid var(--border-subtle); + border-radius: var(--hb-radius-card); + background: rgba(27, 21, 15, 0.94); + box-shadow: var(--hb-shadow-card); +} + +.listener-legal > p:first-of-type { margin-top: 2rem; color: var(--hb-gold); letter-spacing: var(--hb-track-eyebrow); font-size: 0.7rem; } +.listener-legal h1 { margin: 0.6rem 0; color: var(--hb-bone); font-family: var(--hb-font-serif); font-size: clamp(1.7rem, 6vw, 3.4rem); font-weight: 500; line-height: 1.05; } +.listener-legal > p { color: var(--text-muted); font-size: 0.78rem; } +.listener-legal section { margin-top: 2rem; } +.listener-legal h2 { margin-bottom: 0.65rem; color: var(--hb-bone); font-family: var(--hb-font-serif); font-size: 1.35rem; font-weight: 500; } +.listener-legal section p { margin-top: 0.65rem; color: var(--text-secondary); line-height: 1.65; } + +.listener-membership-actions { + display: grid; + gap: 0.55rem; + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid var(--border-subtle); +} + +.listener-membership-actions small, +.listener-membership-actions p { + color: var(--text-secondary); + font-size: 0.72rem; + line-height: 1.45; +} + +.listener-membership-actions > button, +.listener-membership-actions [role="group"] button { + width: fit-content; + color: var(--text-secondary); + font-size: 0.72rem; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.listener-membership-actions [role="group"] { + display: grid; + gap: 0.5rem; + padding: 0.65rem; + border: 1px solid rgba(248, 113, 113, 0.22); + border-radius: 0.65rem; + background: rgba(248, 113, 113, 0.06); +} + +.listener-membership-actions [role="alert"] { color: #fecaca; } + +.listener-membership-status { + display: grid; + gap: 0.45rem; + margin-bottom: 1.5rem; + padding: 1rem; + border: 1px solid var(--hb-hair); + border-radius: var(--hb-radius-card); + background: rgba(201, 162, 78, 0.055); +} + +.listener-access-unavailable { + display: grid; + gap: 0.8rem; + padding: 1rem; + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: 0.9rem; + background: rgba(248, 113, 113, 0.07); +} + +.listener-access-unavailable strong { + color: var(--hb-bone); + font-size: 0.95rem; + font-weight: 500; +} + +.listener-access-unavailable p { + color: var(--text-secondary); + font-size: 0.8rem; + line-height: 1.55; +} + +.listener-membership-status strong { + color: var(--hb-bone); + font-size: 0.88rem; + font-weight: 500; +} + +.listener-membership-status p { + color: var(--text-secondary); + font-size: 0.78rem; + line-height: 1.5; +} + +.listener-account-link { + min-height: 2.75rem; + width: 100%; + color: var(--text-secondary); + font-size: 0.8rem; + text-decoration: underline; + text-underline-offset: 0.25rem; +} + +.listener-email-access { + display: grid; + gap: 0.9rem; + padding-top: 0.35rem; +} + +.listener-email-access__divider { + display: flex; + align-items: center; + gap: 0.8rem; + color: var(--text-muted); + font-family: var(--hb-font-sans); + font-size: 0.66rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.listener-email-access__divider::before, +.listener-email-access__divider::after { + height: 1px; + flex: 1; + content: ''; + background: var(--border-subtle); +} + +.listener-email-access__form { + display: grid; + gap: 0.65rem; +} + +.listener-email-access__form label { + color: var(--text-secondary); + font-size: 0.78rem; +} + +.listener-email-access__form input { + min-height: 3.25rem; + width: 100%; + padding: 0.65rem 0.9rem; + border: 1px solid var(--border-subtle); + border-radius: 0.8rem; + color: var(--hb-bone); + background: var(--hb-surface); +} + +.listener-email-access__form input:focus-visible { + outline: 2px solid var(--hb-gold-2); + outline-offset: 2px; +} + +.listener-email-access__status { + min-height: 3.25rem; + padding: 0.9rem 1rem; + border: 1px solid rgba(118, 219, 193, 0.24); + border-radius: 0.8rem; + color: var(--text-secondary); + background: rgba(118, 219, 193, 0.075); + font-size: 0.82rem; + line-height: 1.55; +} + +.listener-footer { + max-width: 45rem; + padding: 1.5rem 0; + border-top: 1px solid var(--border-subtle); + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.65; +} + +.listener-withdrawal-link { + display: grid; + gap: 0.12rem; + min-height: 48px; + max-width: min(18rem, calc(100vw - 1.6rem)); + padding: 0.7rem 0.95rem; + border: 1px solid var(--hb-hair); + border-radius: 0.8rem; + color: var(--hb-ink-800); + background: rgba(27, 21, 15, 0.94); + box-shadow: 0 0.7rem 2.2rem rgba(0, 0, 0, 0.34); + font-family: var(--hb-font-sans); + text-decoration: none; +} + +.listener-withdrawal-link strong { font-size: 0.66rem; letter-spacing: 0.055em; } +.listener-withdrawal-link span { font-size: 0.62rem; } +.listener-withdrawal-link--inline { white-space: nowrap; } + +.listener-withdrawal__intro, +.listener-withdrawal__scope { + margin-top: 1rem; + color: var(--text-secondary) !important; + font-size: 0.9rem !important; + line-height: 1.65; +} + +.listener-withdrawal__scope { + padding: 0.85rem 1rem; + border: 1px solid var(--border-subtle); + border-radius: 0.8rem; + background: rgba(255, 255, 255, 0.035); +} + +.listener-withdrawal__form { + display: grid; + gap: 1.1rem; + margin-top: 1.5rem; +} + +.listener-withdrawal__form > label { + display: grid; + gap: 0.45rem; +} + +.listener-withdrawal__form label > span { + color: var(--hb-bone); + font-size: 0.78rem; +} + +.listener-withdrawal__form input, +.listener-withdrawal__form select { + width: 100%; + min-height: 48px; + padding: 0.72rem 0.85rem; + border: 1px solid var(--border-subtle); + border-radius: 0.7rem; + color: var(--hb-bone); + background: var(--hb-bg-1); + color-scheme: dark; + font: inherit; +} + +.listener-withdrawal__error { color: #fecaca; font-size: 0.78rem; line-height: 1.5; } + +.listener-withdrawal__receipt { + padding: 1.2rem; + border: 1px solid rgba(200, 255, 122, 0.32); + border-radius: 0.9rem; + background: rgba(200, 255, 122, 0.07); +} + +.listener-withdrawal__receipt code { + display: block; + width: fit-content; + max-width: 100%; + margin: 1rem 0; + padding: 0.75rem; + overflow-wrap: anywhere; + border-radius: 0.55rem; + color: var(--hb-bg-0); + background: var(--hb-gold-2); + font-family: var(--font-space-mono), ui-monospace, monospace; + font-size: clamp(0.72rem, 2.5vw, 0.95rem); +} + +/* -------------------------------------------- + LISTENER PAGE SHELL / ACTIONS / ALERTS / FIELDS + Listener-scoped mirrors of the event visual + primitives so future event UI changes cannot + restyle Listener surfaces (issues #213, #198). + Event rules above remain untouched. + -------------------------------------------- */ + +.listener-page-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + background: + radial-gradient(120% 80% at 12% -8%, rgba(201, 162, 78, 0.1), transparent 60%), + radial-gradient(120% 90% at 92% 108%, rgba(110, 94, 68, 0.16), transparent 60%), + linear-gradient(158deg, var(--hb-bg-0), var(--hb-bg-1)); + position: relative; +} + +.listener-page-shell::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse at 15% 10%, rgba(201, 162, 78, 0.06) 0%, transparent 50%), + radial-gradient(ellipse at 85% 80%, rgba(110, 94, 68, 0.07) 0%, transparent 50%); +} + +.listener-page-shell::after { + content: ""; + position: fixed; + inset: 0; + z-index: 1; + pointer-events: none; + opacity: 0.18; + background-image: + radial-gradient(circle at 15% 27%, var(--hb-gold) 0 1px, transparent 1.5px), + radial-gradient(circle at 72% 18%, var(--hb-ink-600) 0 1px, transparent 1.5px), + radial-gradient(circle at 84% 76%, var(--hb-gold-deep) 0 1px, transparent 1.5px); + background-size: 270px 270px, 390px 390px, 330px 330px; +} + +.listener-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 48px; + padding: 0 24px; + border-radius: 999px; + font-family: var(--hb-font-sans); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; + transition: transform 0.4s var(--hb-ease), box-shadow 0.4s var(--hb-ease), background-color 0.4s var(--hb-ease), border-color 0.4s var(--hb-ease); + border: 1px solid transparent; + position: relative; + overflow: hidden; +} + +.listener-button:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 3px; +} + +.listener-button--primary { + color: var(--hb-bg-0); + background: linear-gradient(135deg, var(--hb-gold-2), var(--hb-gold) 52%, var(--hb-gold-deep)); + box-shadow: 0 10px 30px rgba(201, 162, 78, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.4); + border-color: transparent; +} + +.listener-button--primary:hover { + transform: translateY(-2px) scale(1.02); + box-shadow: 0 18px 44px rgba(201, 162, 78, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + +.listener-button--primary:active { + transform: translateY(0) scale(0.99); +} + +.listener-button--secondary { + color: var(--hb-ink-800); + background: var(--hb-surface); + border-color: var(--hb-hair); +} + +.listener-button--secondary:hover { + color: var(--hb-bone); + background: rgba(201, 162, 78, 0.06); + border-color: var(--hb-gold); +} + +.listener-button--ghost { + color: var(--text-secondary); + background: transparent; + border-color: transparent; + /* Listener controls stay at the 44px touch floor; the event ghost is 36px. */ + min-height: 44px; + padding: 0 12px; +} + +.listener-button--ghost:hover { + color: var(--hb-bone); + background: var(--hb-surface); +} + +.listener-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +.listener-alert { + padding: 14px 18px; + border-radius: 14px; + font-size: 13px; + line-height: 1.6; + border: 1px solid transparent; +} + +.listener-alert--danger, +.listener-alert--error { + color: #fca5a5; + background: rgba(239, 68, 68, 0.08); + border-color: rgba(239, 68, 68, 0.2); +} + +.listener-input { + width: 100%; + height: 48px; + padding: 0 16px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: rgba(30, 24, 18, 0.76); + color: var(--hb-bone); + font-size: 15px; + font-family: var(--hb-font-sans); + transition: border-color 0.25s ease, box-shadow 0.25s ease; +} + +.listener-input::placeholder { + color: var(--muted); +} + +.listener-input:focus { + outline: none; + border-color: var(--hb-gold); + box-shadow: 0 0 0 3px rgba(201, 162, 78, 0.1), 0 0 16px rgba(201, 162, 78, 0.08); +} + +.listener-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .listener-button { + min-height: 44px; + padding: 0 18px; + font-size: 12px; + } +} + +@media (max-width: 760px) { + .listener-shell__frame { + padding-inline: max(0.75rem, var(--safe-left)); + } + + .listener-shell__frame--home { gap: 0.4rem; } + .listener-rail .hb-brand__wordmark { font-size: 0.68rem; } + + .listener-altar { + min-height: calc(100dvh - 6.5rem); + align-self: start; + padding: 1rem 0.85rem max(1rem, var(--safe-bottom)); + border-radius: 1.65rem 1.65rem 0.9rem 0.9rem; + } + + .listener-altar__heading strong { font-size: 2rem; } + + .listener-experience { + margin-top: 0.45rem; + padding-bottom: 0; + } + + .listener-stage { min-height: clamp(21rem, 50vh, 27rem); } + .listener-field { width: min(100%, 28rem); aspect-ratio: 1.28; } + .listener-experience .listener-field { width: min(100%, 25rem); aspect-ratio: 1.55; } + .listener-intro-option { margin-top: 0.5rem; } + + .listener-transport { + width: 100%; + gap: 0.5rem; + } + + .listener-transport__primary, + .listener-transport__secondary { min-height: 48px; } + + .listener-details { + grid-template-columns: 1fr; + gap: 0.65rem; + margin-top: 0.7rem; + padding: 0.75rem; + } + + .listener-public-hero { + min-height: calc(100dvh - 4rem); + padding: 0.75rem 0 max(1.5rem, var(--safe-bottom)); + } + + .listener-public-altar { + min-height: calc(100dvh - 5.5rem); + padding: 1.1rem 0.9rem; + border-radius: 1.65rem 1.65rem 0.9rem 0.9rem; + } + + .listener-public-hero > .listener-field { + width: min(190vw, 54rem); + top: 48%; + opacity: 0.4; + } + + .listener-public-hero__copy h1 { font-size: clamp(2.25rem, 13vw, 3.5rem); } + + .listener-access { + width: min(100%, 30rem); + margin: clamp(5.25rem, 15vh, 8rem) auto 0; + } + + .listener-shell__frame--home > .listener-static-field .listener-field { + width: min(190vw, 54rem); + top: 48%; + opacity: 0.48; + } + +} + +@media (max-width: 360px) { + .listener-shell__frame { + padding-inline: max(0.55rem, var(--safe-left)); + } + + .listener-altar, + .listener-public-altar { + padding-inline: 0.65rem; + border-radius: 1.35rem 1.35rem 0.8rem 0.8rem; + } + + .listener-stage { min-height: clamp(19rem, 47vh, 24rem); } + .listener-intro-option { padding-inline: 0.75rem; } + .listener-control-panel { padding-top: 0.75rem; } + .listener-details { padding-inline: 0.65rem; } + +} + +@media (prefers-reduced-motion: reduce) { + .listener-field__aurora, + .listener-field__orbit, + .listener-field__core, + .listener-field__point { animation: none; } + + .listener-button, + .listener-input { transition: none; } + + .listener-button:hover { transform: none; } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e23d7520..146a0fbc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,33 +1,78 @@ import type { Metadata, Viewport } from "next"; -import { Cormorant_Garamond, Syne, Space_Mono } from "next/font/google"; +import localFont from "next/font/local"; +import { headers } from "next/headers"; import { LocaleProvider } from "@/context/LocaleContext"; -import { requestLocale } from "@/lib/i18n-server"; +import { GlobalNavigation } from "@/components/brand/GlobalNavigation"; +import { ListenerNavigationAccountMenu } from "@/components/brand/ListenerNavigationAccountMenu"; +import { ListenerIdentityCacheBoundary } from "@/components/brand/ListenerIdentityCacheBoundary"; +import { + globalNavigationAccountHref, + globalNavigationSurface, +} from "@/lib/brand/global-navigation"; +import { requestBrowserLocale, requestLocale } from "@/lib/i18n-server"; +import { analyticsBrowserConfig } from "@/lib/analytics-browser"; +import { isCanonicalListenerHost, isListenerStagingHost } from "@/lib/listener/public-discovery"; +import { isAccountHost, isCurrentAccountHost } from "@/lib/account/config"; +import { locallyKnownAccountSession } from "@/lib/account/auth"; +import { + locallyKnownListenerNavigationIdentity, + validateListenerAccountRPEnvironment, +} from "@/lib/listener/account-rp"; +import "@/styles/hb-brand.css"; import "./globals.css"; import { Toaster } from "sonner"; -const cormorant = Cormorant_Garamond({ - subsets: ["latin"], - weight: ["400", "500", "600"], - style: ["normal", "italic"], +const cormorant = localFont({ + src: [ + { + path: "./fonts/cormorant-garamond/CormorantGaramond-wght.woff2", + weight: "400 600", + style: "normal", + }, + { + path: "./fonts/cormorant-garamond/CormorantGaramond-Italic-wght.woff2", + weight: "400 600", + style: "italic", + }, + ], variable: "--font-cormorant", display: "swap", }); -const syne = Syne({ - subsets: ["latin"], - weight: ["400", "500", "600", "700"], +const inter = localFont({ + src: "./fonts/inter/Inter-latin-wght.woff2", + weight: "300 600", + style: "normal", + variable: "--font-hb-inter", + display: "swap", +}); + +const syne = localFont({ + src: "./fonts/syne/Syne-wght.woff2", + weight: "400 700", + style: "normal", variable: "--font-syne", display: "swap", }); -const spaceMono = Space_Mono({ - subsets: ["latin"], - weight: ["400", "700"], +const spaceMono = localFont({ + src: [ + { + path: "./fonts/space-mono/SpaceMono-Regular.woff2", + weight: "400", + style: "normal", + }, + { + path: "./fonts/space-mono/SpaceMono-Bold.woff2", + weight: "700", + style: "normal", + }, + ], variable: "--font-space-mono", display: "swap", }); -export const metadata: Metadata = { +const eventMetadata: Metadata = { title: "Harmonic Projection | Harmonic Beacon", description: "A live online experience to enter your inner landscape through body, sound and the images already living inside you.", @@ -41,22 +86,102 @@ export const metadata: Metadata = { }, }; -export const viewport: Viewport = { - width: "device-width", - initialScale: 1, - themeColor: "#07120f", +const accountMetadata: Metadata = { + title: "Account | Harmonic Beacon", + description: "Manage your Harmonic Beacon identity, profile and sign-in methods.", + authors: [{ name: "Harmonic Beacon" }], + openGraph: { + title: "Account | Harmonic Beacon", + description: "Manage your Harmonic Beacon identity, profile and sign-in methods.", + type: "website", + }, }; +export async function generateMetadata(): Promise { + const incomingHeaders = await headers(); + return isAccountHost(incomingHeaders.get('host')) ? accountMetadata : eventMetadata; +} + +export async function generateViewport(): Promise { + const incomingHeaders = await headers(); + const accountHost = isAccountHost(incomingHeaders.get('host')); + return { + width: "device-width", + initialScale: 1, + themeColor: isCanonicalListenerHost(incomingHeaders) || accountHost ? "#16120D" : "#07120f", + }; +} + export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const locale = await requestLocale(); + const incomingHeaders = await headers(); + // Listener content starts from the browser's primary language and then + // honors the shared navigation preference. Keep that policy bound to the + // exact public host so event-language defaults elsewhere remain untouched. + const listenerHost = isCanonicalListenerHost(incomingHeaders); + const listenerAccountHost = listenerHost || isListenerStagingHost(incomingHeaders); + const accountHost = isAccountHost(incomingHeaders.get('host')); + const accountAuthorityAvailable = accountHost && process.env.BEACON_ACCOUNT_RUNTIME === '1' && + isCurrentAccountHost(incomingHeaders.get('host')); + let listenerAccountAvailable = false; + if (listenerAccountHost) { + try { + listenerAccountAvailable = validateListenerAccountRPEnvironment(); + } catch { + listenerAccountAvailable = false; + } + } + const accountHref = globalNavigationAccountHref( + incomingHeaders, + accountAuthorityAvailable || listenerAccountAvailable, + ); + const localHeaders = new Headers(incomingHeaders); + const listenerNavigationIdentity = accountHref && listenerAccountHost + ? await locallyKnownListenerNavigationIdentity(localHeaders).catch(() => null) + : null; + const accountSignedIn = accountHref + ? accountHost + ? await locallyKnownAccountSession(localHeaders).catch(() => false) + : Boolean(listenerNavigationIdentity) + : false; + // This application is the Live surface by default. Listener and its staging + // host opt into their own active item explicitly; local/E2E hosts continue + // to exercise the same global header as production Live. + const navigationSurface = globalNavigationSurface(incomingHeaders) ?? "events"; + const accountLocale = incomingHeaders.get('x-hb-account-locale'); + const locale = listenerHost || accountHost + ? accountHost && (accountLocale === 'es' || accountLocale === 'en') + ? accountLocale + : await requestBrowserLocale(incomingHeaders) + : await requestLocale(); + const analytics = analyticsBrowserConfig(incomingHeaders); return ( - + + + ) : undefined} + /> + {listenerNavigationIdentity && } {/* Main content */}
    {children}
    @@ -66,16 +191,28 @@ export default async function RootLayout({ position="top-center" toastOptions={{ style: { - background: "rgba(7, 18, 15, 0.96)", + background: listenerHost || accountHost ? "rgba(27, 21, 15, 0.97)" : "rgba(7, 18, 15, 0.96)", backdropFilter: "blur(16px)", - border: "1px solid rgba(238, 245, 233, 0.12)", - color: "#fff9e9", - fontFamily: "var(--font-syne), system-ui, sans-serif", + border: listenerHost || accountHost + ? "1px solid rgba(201, 162, 78, 0.22)" + : "1px solid rgba(238, 245, 233, 0.12)", + color: listenerHost || accountHost ? "#F4EEE2" : "#fff9e9", + fontFamily: listenerHost || accountHost + ? "var(--font-hb-inter), Inter, system-ui, sans-serif" + : "var(--font-syne), system-ui, sans-serif", fontSize: "13px", }, }} />
    + {analytics ?